initiales bauen mit mistral

This commit is contained in:
Björn Runden committed 2026-08-01 15:40:30 +02:00
1 parent 7e2e8bdf59
commit c74bca1e13
22 files changed
+5642

No files matched your search

+440
View File
@@ -0,0 +1,440 @@
# ==============================================================================
# Formeleon - Multi-tenant Form Generator
# Author: Björn Runden <br@runden.net>
# Module: routes/auth.py
# Description: Authentication routes for Zitadel OIDC integration.
# Provides login, logout, and callback handlers with dev mode bypass.
# ==============================================================================
"""
Authentication routes for Formeleon.
This module provides:
- OIDC authentication via Zitadel (production)
- Development mode bypass with fake user injection
- Login/logout routes
- Session management
Routes:
GET /auth/login - Initiate OIDC login flow or redirect to admin
GET /auth/callback - OIDC callback handler
GET /auth/logout - Logout and clear session
GET /auth/status - Check current authentication status
In development mode (DEV_MODE=true), the login route automatically
creates a fake authenticated session without redirecting to Zitadel.
"""
import uuid
import logging
from typing import Optional
from urllib.parse import urlencode
from flask import Blueprint, request, redirect, url_for, session, current_app, jsonify
from flask import render_template_string
from models import db, User, UserRole, Tenant, AuditLog
logger = logging.getLogger(__name__)
# Create auth blueprint
auth_bp = Blueprint('auth', __name__, template_folder='../templates')
def is_dev_mode() -> bool:
"""Check if application is in development mode."""
return current_app.config.get('DEV_MODE', False)
def create_dev_user_session() -> dict:
"""
Create a development mode user session with fake Zitadel user.
:return: Dictionary with user session data
"""
config = current_app.config
# Use configured dev user settings or defaults
dev_user = {
'id': config.get('DEV_USER_ID', 'user_dev_admin_001'),
'email': config.get('DEV_USER_EMAIL', 'dev@formeleon.local'),
'name': config.get('DEV_USER_NAME', 'Dev Admin'),
'role': config.get('DEV_USER_ROLE', 'admin'),
'sub': f'dev-{uuid.uuid4()}',
'tenant_id': config.get('DEV_TENANT_ID', 'tenant_dev_123'),
'dev_mode': True,
'authenticated': True
}
return dev_user
def ensure_dev_user_exists() -> User:
"""
Ensure the development user exists in the database.
:return: User object from database
"""
config = current_app.config
user_id = config.get('DEV_USER_ID', 'user_dev_admin_001')
user = User.query.get(user_id)
if not user:
role = UserRole.ADMIN if config.get('DEV_USER_ROLE', 'admin') == 'admin' else UserRole.USER
user = User(
id=user_id,
email=config.get('DEV_USER_EMAIL', 'dev@formeleon.local'),
name=config.get('DEV_USER_NAME', 'Dev Admin'),
role=role,
sub=f'dev-{uuid.uuid4()}'
)
db.session.add(user)
db.session.commit()
logger.info(f'Created dev user: {user.id}')
return user
def ensure_dev_tenant_exists() -> Tenant:
"""
Ensure the development tenant exists in the database.
:return: Tenant object from database
"""
config = current_app.config
tenant_id = config.get('DEV_TENANT_ID', 'tenant_dev_123')
tenant = Tenant.query.get(tenant_id)
if not tenant:
tenant = Tenant(
id=tenant_id,
name='Development Tenant',
public_key=f'dev-key-{uuid.uuid4().hex[:16]}',
allowed_origins='*'
)
db.session.add(tenant)
db.session.commit()
logger.info(f'Created dev tenant: {tenant.id}')
return tenant
@auth_bp.before_request
def check_dev_mode_auth():
"""
Middleware to handle development mode authentication.
If DEV_MODE is enabled and user is not authenticated,
automatically create a dev user session.
"""
# Skip for auth routes themselves
if request.path in ['/auth/login', '/auth/callback', '/auth/logout', '/auth/status']:
return
# Skip if already authenticated
if 'user_id' in session and session.get('authenticated'):
return
# In dev mode, auto-authenticate
if is_dev_mode():
dev_user = create_dev_user_session()
session['user_id'] = dev_user['id']
session['user'] = dev_user
session['authenticated'] = True
session.permanent = True
# Ensure user and tenant exist in database
ensure_dev_user_exists()
ensure_dev_tenant_exists()
@auth_bp.route('/login')
def login():
"""
Login route - initiates OIDC flow or handles dev mode.
In production: Redirects to Zitadel for OIDC authentication
In development: Creates fake session and redirects to admin
Query parameters:
next: URL to redirect to after successful login
"""
next_url = request.args.get('next', url_for('admin.dashboard'))
# Development mode: create fake session and redirect
if is_dev_mode():
dev_user = create_dev_user_session()
session.clear()
session['user_id'] = dev_user['id']
session['user'] = dev_user
session['authenticated'] = True
session.permanent = True
# Ensure user and tenant exist in database
ensure_dev_user_exists()
ensure_dev_tenant_exists()
logger.info(f'Dev mode login: {dev_user["email"]}')
AuditLog.log_action(
action='login',
resource_type='auth',
user=User.query.get(dev_user['id']),
tenant=Tenant.query.get(dev_user['tenant_id']),
details={'mode': 'dev', 'dev_mode': True}
)
return redirect(next_url)
# Production mode: redirect to Zitadel OIDC
if not current_app.config['ZITADEL_ISSUER']:
return jsonify({'error': 'OIDC not configured'}), 500
# Build authorization URL
from authlib.integrations.flask_client import OAuth
from flask import current_app
oauth = OAuth(current_app)
# Configure Zitadel if not already configured
if 'zitadel' not in oauth._clients:
zitadel_config = {
'client_id': current_app.config.get('ZITADEL_CLIENT_ID'),
'client_secret': current_app.config.get('ZITADEL_CLIENT_SECRET'),
'authorize_url': f"{current_app.config.get('ZITADEL_ISSUER')}/v1/oauth/authorize",
'access_token_url': f"{current_app.config.get('ZITADEL_ISSUER')}/v1/oauth/token",
'userinfo_endpoint': f"{current_app.config.get('ZITADEL_ISSUER')}/v1/userinfo",
'client_kwargs': {
'scope': ' '.join(current_app.config.get('ZITADEL_SCOPES', []))
}
}
oauth.register('zitadel', **zitadel_config)
# Generate state for CSRF protection
state = str(uuid.uuid4())
session['oauth_state'] = state
session['oauth_next'] = next_url
# Build authorization URL
redirect_uri = current_app.config.get('ZITADEL_REDIRECT_URI',
url_for('auth.callback', _external=True))
authorization_url = f"{current_app.config.get('ZITADEL_ISSUER')}/v1/oauth/authorize"
params = {
'client_id': current_app.config.get('ZITADEL_CLIENT_ID'),
'redirect_uri': redirect_uri,
'response_type': 'code',
'scope': ' '.join(current_app.config.get('ZITADEL_SCOPES', [])),
'state': state,
'nonce': str(uuid.uuid4())
}
auth_url = f"{authorization_url}?{urlencode(params)}"
logger.debug(f'Redirecting to OIDC provider: {auth_url}')
return redirect(auth_url)
@auth_bp.route('/callback')
def callback():
"""
OIDC callback handler.
Handles the callback from Zitadel after successful authentication.
Exchanges authorization code for tokens and retrieves user info.
"""
if is_dev_mode():
# In dev mode, shouldn't reach here, but handle gracefully
return redirect(url_for('admin.dashboard'))
# Verify state for CSRF protection
state = request.args.get('state')
stored_state = session.pop('oauth_state', None)
if not state or state != stored_state:
logger.error('OIDC callback: Invalid state parameter')
return jsonify({'error': 'Invalid state parameter'}), 403
# Get authorization code
code = request.args.get('code')
if not code:
error = request.args.get('error', 'No authorization code received')
logger.error(f'OIDC callback error: {error}')
return jsonify({'error': error}), 400
# Exchange code for tokens
try:
from authlib.integrations.flask_client import OAuth
oauth = OAuth(current_app)
redirect_uri = current_app.config.get('ZITADEL_REDIRECT_URI',
url_for('auth.callback', _external=True))
zitadel = oauth.create_client('zitadel')
# Get access token
token = zitadel.authorize_access_token(
redirect_uri=redirect_uri,
code=code
)
# Get user info
user_info = zitadel.userinfo(token=token)
logger.debug(f'OIDC userinfo: {user_info}')
# Extract user data
sub = user_info.get('sub')
email = user_info.get('email')
name = user_info.get('name', user_info.get('preferred_username', 'Unknown'))
if not email:
return jsonify({'error': 'No email in user info'}), 400
# Create or update user
user = User.query.filter_by(sub=sub).first()
if not user:
user_id = f'user_{email.split("@")[0]}_{str(uuid.uuid4())[:8]}'
user = User(
id=user_id,
email=email,
name=name,
role=UserRole.USER, # Default role for new OIDC users
sub=sub
)
db.session.add(user)
db.session.commit()
logger.info(f'Created new user from OIDC: {user.id}')
else:
# Update existing user
user.name = name
user.email = email
db.session.commit()
# Store in session
session.clear()
session['user_id'] = user.id
session['user'] = {
'id': user.id,
'email': user.email,
'name': user.name,
'role': user.role.value,
'sub': user.sub,
'authenticated': True,
'dev_mode': False
}
session['authenticated'] = True
session['access_token'] = token['access_token']
session['refresh_token'] = token.get('refresh_token')
session['token_expires'] = token.get('expires_at')
session.permanent = True
# Log the login
AuditLog.log_action(
action='login',
resource_type='auth',
user=user,
details={'mode': 'oidc', 'provider': 'zitadel'}
)
# Redirect to next URL or dashboard
next_url = session.pop('oauth_next', url_for('admin.dashboard'))
return redirect(next_url)
except Exception as e:
logger.error(f'OIDC callback failed: {e}')
return jsonify({'error': 'Authentication failed'}), 500
@auth_bp.route('/logout')
def logout():
"""
Logout route - clears session and redirects to login.
In production: Also revokes tokens with Zitadel if configured
In development: Simply clears the session
"""
# Get current user for audit logging
current_user = None
if 'user_id' in session:
current_user = User.query.get(session['user_id'])
# Clear session
session.clear()
# In production, revoke token with Zitadel
if not is_dev_mode() and 'access_token' in session:
try:
from authlib.integrations.flask_client import OAuth
oauth = OAuth(current_app)
zitadel = oauth.create_client('zitadel')
# Revoke token
zitadel.revoke_token(
token=session['access_token'],
token_type_hint='access_token'
)
except Exception as e:
logger.warning(f'Failed to revoke token: {e}')
# Log the logout
if current_user:
AuditLog.log_action(
action='logout',
resource_type='auth',
user=current_user,
details={'mode': 'dev' if is_dev_mode() else 'oidc'}
)
return redirect(url_for('auth.login'))
@auth_bp.route('/status')
def status():
"""
Check current authentication status.
:return: JSON with current authentication status
"""
if 'user_id' in session and session.get('authenticated'):
user_data = session.get('user', {})
return jsonify({
'authenticated': True,
'user': user_data,
'dev_mode': is_dev_mode()
})
return jsonify({
'authenticated': False,
'dev_mode': is_dev_mode()
})
@auth_bp.route('/userinfo')
def userinfo():
"""
Get detailed user information.
Requires authentication.
:return: JSON with user information
"""
if not ('user_id' in session and session.get('authenticated')):
return jsonify({'error': 'Unauthorized'}), 401
user_id = session['user_id']
user = User.query.get(user_id)
if not user:
return jsonify({'error': 'User not found'}), 404
return jsonify({
'user': user.to_dict(),
'tenants': [tenant.to_dict() for tenant in user.tenants],
'dev_mode': is_dev_mode()
})