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

+758
View File
@@ -0,0 +1,758 @@
# ==============================================================================
# Formeleon - Multi-tenant Form Generator
# Author: Björn Runden <br@runden.net>
# Module: routes/admin.py
# Description: Admin Dashboard routes for form management.
# Provides CRUD operations for tenants, forms, and submissions.
# ==============================================================================
"""
Admin Dashboard routes for Formeleon.
This module provides the management interface for:
- Tenant management (CRUD)
- Form configuration (CRUD)
- Submission viewing and export
- User management
- Dashboard analytics
Routes:
GET /admin/ - Dashboard overview
GET /admin/tenants/ - List all tenants
GET /admin/tenants/<id> - View/edit tenant
GET /admin/forms/ - List all forms
GET /admin/forms/<id> - View/edit form
GET /admin/forms/create - Create new form
GET /admin/submissions/ - List submissions
GET /admin/submissions/<id> - View submission details
GET /admin/users/ - List users
GET /admin/users/<id> - View/edit user
Authentication is handled via the auth middleware or dev mode bypass.
"""
import uuid
import logging
from typing import Optional, List, Dict, Any
from datetime import datetime, timezone
from flask import Blueprint, request, redirect, url_for, session, current_app, jsonify
from flask import render_template, flash, abort
from models import db, Tenant, FormConfig, Submission, User, UserRole, TenantStatus, SubmissionStatus
from models import AuditLog
from services.recaptcha import RecaptchaService
logger = logging.getLogger(__name__)
# Create admin blueprint
admin_bp = Blueprint('admin', __name__, template_folder='../templates', static_folder='../static')
def login_required(func):
"""Decorator to require login for admin routes."""
def wrapper(*args, **kwargs):
if not (session.get('user_id') and session.get('authenticated')):
return redirect(url_for('auth.login', next=request.url))
return func(*args, **kwargs)
wrapper.__name__ = func.__name__
return wrapper
def admin_required(func):
"""Decorator to require admin privileges."""
def wrapper(*args, **kwargs):
if not (session.get('user_id') and session.get('authenticated')):
return redirect(url_for('auth.login', next=request.url))
user = User.query.get(session['user_id'])
if not user or not user.is_admin():
abort(403, description='Admin privileges required')
return func(*args, **kwargs)
wrapper.__name__ = func.__name__
return wrapper
def get_current_user() -> Optional[User]:
"""Get current authenticated user."""
if 'user_id' in session:
return User.query.get(session['user_id'])
return None
def get_current_tenant() -> Optional[Tenant]:
"""Get current tenant from session or user context."""
if 'tenant_id' in session:
return Tenant.query.get(session['tenant_id'])
user = get_current_user()
if user and user.tenants:
return user.tenants[0]
return None
@admin_bp.before_request
def before_request():
"""Before request handler for admin routes."""
# Ensure user exists in database if authenticated in session
if session.get('user_id'):
user = User.query.get(session['user_id'])
if not user:
# Clean up invalid session
session.clear()
return redirect(url_for('auth.login'))
@admin_bp.route('/')
@login_required
def dashboard():
"""
Admin dashboard overview.
Shows:
- Number of tenants
- Number of forms
- Recent submissions
- Quick access links
"""
user = get_current_user()
# Get statistics
tenant_count = Tenant.query.count()
form_count = FormConfig.query.count()
submission_count = Submission.query.count()
# Recent submissions
recent_submissions = Submission.query.order_by(
Submission.created_at.desc()
).limit(10).all()
# Recent forms
recent_forms = FormConfig.query.order_by(
FormConfig.created_at.desc()
).limit(5).all()
# Tenant list
tenants = Tenant.query.order_by(Tenant.created_at.desc()).limit(5).all()
return render_template('admin/dashboard.html',
user=user,
tenant_count=tenant_count,
form_count=form_count,
submission_count=submission_count,
recent_submissions=recent_submissions,
recent_forms=recent_forms,
tenants=tenants,
dev_mode=current_app.config.get('DEV_MODE', False))
@admin_bp.route('/tenants/')
@login_required
def list_tenants():
"""List all tenants."""
user = get_current_user()
# Filter tenants based on user role
if user.role == UserRole.ADMIN:
tenants = Tenant.query.order_by(Tenant.created_at.desc()).all()
else:
# Non-admin users can only see their assigned tenants
tenants = user.tenants if user else []
return render_template('admin/tenants/list.html',
user=user,
tenants=tenants,
dev_mode=current_app.config.get('DEV_MODE', False))
@admin_bp.route('/tenants/create', methods=['GET', 'POST'])
@admin_required
def create_tenant():
"""Create a new tenant."""
user = get_current_user()
if request.method == 'POST':
# Validate form data
name = request.form.get('name', '').strip()
public_key = request.form.get('public_key', '').strip()
allowed_origins = request.form.get('allowed_origins', '*').strip()
status = request.form.get('status', 'active')
# Generate tenant ID
tenant_id = f'tenant_{name.lower().replace(" ", "_")}_{str(uuid.uuid4())[:8]}'
# Create tenant
tenant = Tenant(
id=tenant_id,
name=name,
public_key=public_key or str(uuid.uuid4()).hex[:32],
allowed_origins=allowed_origins,
status=TenantStatus(status) if status in [s.value for s in TenantStatus] else TenantStatus.ACTIVE
)
db.session.add(tenant)
db.session.commit()
# Audit log
AuditLog.log_action(
action='create',
resource_type='tenant',
user=user,
resource_id=tenant.id,
details={'name': name, 'public_key': public_key}
)
flash('Tenant created successfully!', 'success')
return redirect(url_for('admin.list_tenants'))
return render_template('admin/tenants/create.html',
user=user,
dev_mode=current_app.config.get('DEV_MODE', False))
@admin_bp.route('/tenants/<tenant_id>', methods=['GET', 'POST'])
@login_required
def view_tenant(tenant_id):
"""View and edit a tenant."""
user = get_current_user()
tenant = Tenant.query.get(tenant_id)
if not tenant:
abort(404, description='Tenant not found')
# Check permissions
if user.role != UserRole.ADMIN and tenant not in user.tenants:
abort(403, description='Access denied')
if request.method == 'POST':
if not user.is_admin():
abort(403, description='Admin privileges required to edit tenant')
# Update tenant
tenant.name = request.form.get('name', tenant.name)
tenant.public_key = request.form.get('public_key', tenant.public_key)
tenant.allowed_origins = request.form.get('allowed_origins', tenant.allowed_origins)
status = request.form.get('status')
if status in [s.value for s in TenantStatus]:
tenant.status = TenantStatus(status)
db.session.commit()
# Audit log
AuditLog.log_action(
action='update',
resource_type='tenant',
user=user,
resource_id=tenant.id,
details={'name': tenant.name, 'status': tenant.status.value}
)
flash('Tenant updated successfully!', 'success')
return redirect(url_for('admin.view_tenant', tenant_id=tenant.id))
# Get tenant forms
forms = FormConfig.query.filter_by(tenant_id=tenant.id).order_by(
FormConfig.created_at.desc()
).all()
return render_template('admin/tenants/view.html',
user=user,
tenant=tenant,
forms=forms,
dev_mode=current_app.config.get('DEV_MODE', False),
is_admin=user.is_admin())
@admin_bp.route('/tenants/<tenant_id>/delete', methods=['POST'])
@admin_required
def delete_tenant(tenant_id):
"""Delete a tenant."""
user = get_current_user()
tenant = Tenant.query.get(tenant_id)
if not tenant:
abort(404, description='Tenant not found')
# Delete tenant (cascade will handle related forms and submissions)
db.session.delete(tenant)
db.session.commit()
# Audit log
AuditLog.log_action(
action='delete',
resource_type='tenant',
user=user,
resource_id=tenant.id,
details={'name': tenant.name}
)
flash('Tenant deleted successfully!', 'success')
return redirect(url_for('admin.list_tenants'))
@admin_bp.route('/forms/')
@login_required
def list_forms():
"""List all forms for the current tenant/user."""
user = get_current_user()
tenant = get_current_tenant()
# Get forms based on user access
if user.role == UserRole.ADMIN:
# Admin can see all forms
forms = FormConfig.query.join(Tenant).order_by(
FormConfig.created_at.desc()
).all()
elif tenant:
# Tenant user sees only their tenant's forms
forms = FormConfig.query.filter_by(tenant_id=tenant.id).order_by(
FormConfig.created_at.desc()
).all()
else:
forms = []
return render_template('admin/forms/list.html',
user=user,
forms=forms,
tenant=tenant,
dev_mode=current_app.config.get('DEV_MODE', False),
is_admin=user.is_admin())
@admin_bp.route('/forms/create', methods=['GET', 'POST'])
@login_required
def create_form():
"""Create a new form."""
user = get_current_user()
tenant = get_current_tenant()
if not tenant:
flash('No tenant selected. Please select a tenant.', 'error')
return redirect(url_for('admin.dashboard'))
if request.method == 'POST':
# Get form data
name = request.form.get('name', '').strip()
description = request.form.get('description', '').strip()
# Parse form structure from JSON
form_structure_str = request.form.get('form_structure', '{}')
try:
import json
form_structure = json.loads(form_structure_str)
except json.JSONDecodeError:
form_structure = {'version': '1.0', 'fields': []}
# Parse settings
settings_str = request.form.get('settings', '{}')
try:
settings = json.loads(settings_str)
except json.JSONDecodeError:
settings = {}
# Generate form ID
form_id = f'form_{name.lower().replace(" ", "_")}_{str(uuid.uuid4())[:8]}'
# Create form
form = FormConfig(
id=form_id,
tenant_id=tenant.id,
name=name,
description=description,
form_structure=form_structure,
settings=settings,
is_active=request.form.get('is_active', 'true').lower() == 'true'
)
db.session.add(form)
db.session.commit()
# Audit log
AuditLog.log_action(
action='create',
resource_type='form',
user=user,
tenant=tenant,
resource_id=form.id,
details={'name': name, 'tenant_id': tenant.id}
)
flash('Form created successfully!', 'success')
return redirect(url_for('admin.view_form', form_id=form.id))
# Provide default form structure for new forms
default_form_structure = {
'version': '1.0',
'fields': [
{
'name': 'name',
'type': 'text',
'label': 'Your Name',
'required': True,
'validation': {'min_length': 2, 'max_length': 100}
},
{
'name': 'email',
'type': 'email',
'label': 'Email Address',
'required': True
},
{
'name': 'message',
'type': 'textarea',
'label': 'Message',
'required': True
}
],
'submit_button': {
'text': 'Submit',
'style': 'primary'
}
}
return render_template('admin/forms/create.html',
user=user,
tenant=tenant,
default_form_structure=default_form_structure,
dev_mode=current_app.config.get('DEV_MODE', False))
@admin_bp.route('/forms/<form_id>', methods=['GET', 'POST'])
@login_required
def view_form(form_id):
"""View and edit a form."""
user = get_current_user()
form = FormConfig.query.get(form_id)
if not form:
abort(404, description='Form not found')
# Check permissions
tenant = Tenant.query.get(form.tenant_id)
if not tenant:
abort(404, description='Tenant not found')
if user.role != UserRole.ADMIN and tenant not in user.tenants:
abort(403, description='Access denied')
if request.method == 'POST':
# Update form
form.name = request.form.get('name', form.name)
form.description = request.form.get('description', form.description)
form.is_active = request.form.get('is_active', 'true').lower() == 'true'
# Update form structure
form_structure_str = request.form.get('form_structure', '{}')
try:
import json
form.form_structure = json.loads(form_structure_str)
except json.JSONDecodeError:
pass # Keep existing structure
# Update settings
settings_str = request.form.get('settings', '{}')
try:
import json
form.settings = json.loads(settings_str)
except json.JSONDecodeError:
pass # Keep existing settings
db.session.commit()
# Audit log
AuditLog.log_action(
action='update',
resource_type='form',
user=user,
tenant=tenant,
resource_id=form.id,
details={'name': form.name, 'is_active': form.is_active}
)
flash('Form updated successfully!', 'success')
return redirect(url_for('admin.view_form', form_id=form.id))
# Get form submissions
submissions = Submission.query.filter_by(form_config_id=form.id).order_by(
Submission.created_at.desc()
).limit(10).all()
return render_template('admin/forms/view.html',
user=user,
form=form,
tenant=tenant,
submissions=submissions,
dev_mode=current_app.config.get('DEV_MODE', False),
is_admin=user.is_admin())
@admin_bp.route('/forms/<form_id>/delete', methods=['POST'])
@login_required
def delete_form(form_id):
"""Delete a form."""
user = get_current_user()
form = FormConfig.query.get(form_id)
if not form:
abort(404, description='Form not found')
tenant = Tenant.query.get(form.tenant_id)
if not tenant:
abort(404, description='Tenant not found')
# Check permissions
if user.role != UserRole.ADMIN and tenant not in user.tenants:
abort(403, description='Access denied')
# Delete form (cascade will handle submissions)
db.session.delete(form)
db.session.commit()
# Audit log
AuditLog.log_action(
action='delete',
resource_type='form',
user=user,
tenant=tenant,
resource_id=form.id,
details={'name': form.name}
)
flash('Form deleted successfully!', 'success')
return redirect(url_for('admin.list_forms'))
@admin_bp.route('/submissions/')
@login_required
def list_submissions():
"""List form submissions."""
user = get_current_user()
tenant = get_current_tenant()
# Get submissions based on user access
if user.role == UserRole.ADMIN:
submissions = Submission.query.join(FormConfig).order_by(
Submission.created_at.desc()
).all()
elif tenant:
submissions = Submission.query.filter_by(tenant_id=tenant.id).order_by(
Submission.created_at.desc()
).all()
else:
submissions = []
return render_template('admin/submissions/list.html',
user=user,
submissions=submissions,
tenant=tenant,
dev_mode=current_app.config.get('DEV_MODE', False),
is_admin=user.is_admin())
@admin_bp.route('/submissions/<submission_id>')
@login_required
def view_submission(submission_id):
"""View submission details."""
user = get_current_user()
submission = Submission.query.get(submission_id)
if not submission:
abort(404, description='Submission not found')
# Check permissions
form = FormConfig.query.get(submission.form_config_id)
tenant = Tenant.query.get(submission.tenant_id)
if not form or not tenant:
abort(404, description='Related form or tenant not found')
if user.role != UserRole.ADMIN and tenant not in user.tenants:
abort(403, description='Access denied')
return render_template('admin/submissions/view.html',
user=user,
submission=submission,
form=form,
tenant=tenant,
dev_mode=current_app.config.get('DEV_MODE', False),
is_admin=user.is_admin())
@admin_bp.route('/submissions/<submission_id>/status', methods=['POST'])
@login_required
def update_submission_status(submission_id):
"""Update submission status."""
user = get_current_user()
submission = Submission.query.get(submission_id)
if not submission:
abort(404, description='Submission not found')
tenant = Tenant.query.get(submission.tenant_id)
if not tenant:
abort(404, description='Tenant not found')
if user.role != UserRole.ADMIN and tenant not in user.tenants:
abort(403, description='Access denied')
status = request.form.get('status')
if status in [s.value for s in SubmissionStatus]:
submission.status = SubmissionStatus(status)
db.session.commit()
# Audit log
AuditLog.log_action(
action='update_status',
resource_type='submission',
user=user,
tenant=tenant,
resource_id=submission.id,
details={'status': status}
)
flash('Submission status updated!', 'success')
return redirect(url_for('admin.view_submission', submission_id=submission.id))
@admin_bp.route('/users/')
@admin_required
def list_users():
"""List all users (admin only)."""
user = get_current_user()
users = User.query.order_by(User.created_at.desc()).all()
return render_template('admin/users/list.html',
user=user,
users=users,
dev_mode=current_app.config.get('DEV_MODE', False))
@admin_bp.route('/users/<user_id>', methods=['GET', 'POST'])
@admin_required
def view_user(user_id):
"""View and edit a user (admin only)."""
user = get_current_user()
target_user = User.query.get(user_id)
if not target_user:
abort(404, description='User not found')
if request.method == 'POST':
# Update user
target_user.email = request.form.get('email', target_user.email)
target_user.name = request.form.get('name', target_user.name)
role = request.form.get('role')
if role in [r.value for r in UserRole]:
target_user.role = UserRole(role)
db.session.commit()
# Audit log
AuditLog.log_action(
action='update',
resource_type='user',
user=user,
resource_id=target_user.id,
details={'email': target_user.email, 'role': target_user.role.value}
)
flash('User updated successfully!', 'success')
return redirect(url_for('admin.view_user', user_id=target_user.id))
# Get user tenants
tenants = target_user.tenants
return render_template('admin/users/view.html',
user=user,
target_user=target_user,
tenants=tenants,
dev_mode=current_app.config.get('DEV_MODE', False),
UserRole=UserRole)
@admin_bp.route('/users/<user_id>/delete', methods=['POST'])
@admin_required
def delete_user(user_id):
"""Delete a user (admin only)."""
user = get_current_user()
target_user = User.query.get(user_id)
if not target_user:
abort(404, description='User not found')
# Don't allow deleting self
if target_user.id == user.id:
flash('Cannot delete your own account!', 'error')
return redirect(url_for('admin.view_user', user_id=target_user.id))
db.session.delete(target_user)
db.session.commit()
# Audit log
AuditLog.log_action(
action='delete',
resource_type='user',
user=user,
resource_id=target_user.id,
details={'email': target_user.email}
)
flash('User deleted successfully!', 'success')
return redirect(url_for('admin.list_users'))
@admin_bp.route('/profile')
@login_required
def profile():
"""User profile page."""
user = get_current_user()
if not user:
return redirect(url_for('auth.login'))
return render_template('admin/profile.html',
user=user,
dev_mode=current_app.config.get('DEV_MODE', False))
@admin_bp.route('/api/recaptcha-config')
@login_required
def get_recaptcha_config():
"""Get reCAPTCHA configuration for client-side rendering."""
recaptcha_service = RecaptchaService()
return jsonify({
'enabled': recaptcha_service.is_enabled(),
'site_key': recaptcha_service.get_site_key(),
'dev_mode': recaptcha_service.is_dev_mode(),
'mock_score': recaptcha_service.get_dev_mode_score() if recaptcha_service.is_dev_mode() else None
})
@admin_bp.route('/settings')
@admin_required
def settings():
"""Application settings (admin only)."""
user = get_current_user()
# Get system statistics
tenant_count = Tenant.query.count()
user_count = User.query.count()
form_count = FormConfig.query.count()
submission_count = Submission.query.count()
# Get audit logs
audit_logs = AuditLog.query.order_by(AuditLog.created_at.desc()).limit(50).all()
return render_template('admin/settings.html',
user=user,
tenant_count=tenant_count,
user_count=user_count,
form_count=form_count,
submission_count=submission_count,
audit_logs=audit_logs,
dev_mode=current_app.config.get('DEV_MODE', False))
+512
View File
@@ -0,0 +1,512 @@
# ==============================================================================
# Formeleon - Multi-tenant Form Generator
# Author: Björn Runden <br@runden.net>
# Module: routes/api.py
# Description: Public Form API for handling form submissions from client websites.
# Provides endpoints for form handshake, submission, and validation.
# ==============================================================================
"""
Public Form API for Formeleon.
This module provides the public API endpoints for:
- Client handshake and token exchange
- Form submission processing
- reCAPTCHA validation
- Form configuration retrieval
All endpoints are prefixed with /api/v1/ and are designed to be
consumed by JavaScript snippets embedded in client websites.
Authentication:
- Public key handshake for tenant identification
- Origin/Referer validation
- reCAPTCHA v3 token validation (optional)
Routes:
POST /api/v1/handshake - Initiate handshake and get form config
POST /api/v1/submit/<form_id> - Submit form data
GET /api/v1/form/<form_id> - Get form configuration
POST /api/v1/validate/recaptcha - Validate reCAPTCHA token
"""
import hashlib
import json
import logging
import uuid
from datetime import datetime, timezone
from typing import Optional, Dict, Any
from flask import Blueprint, request, jsonify, current_app
from werkzeug.exceptions import BadRequest, Unauthorized, Forbidden, NotFound
from models import db, Tenant, FormConfig, Submission, SubmissionStatus
from services.recaptcha import RecaptchaService
logger = logging.getLogger(__name__)
# Create API blueprint
api_bp = Blueprint('api', __name__)
def validate_origin(origin: str, tenant: Tenant) -> bool:
"""
Validate that the request origin matches allowed origins for the tenant.
:param origin: Request origin/referer
:param tenant: Tenant object to check against
:return: True if origin is allowed, False otherwise
"""
if not origin:
return False
# Remove trailing slash and protocol
origin = origin.rstrip('/')
allowed_origins = tenant.get_allowed_origins_list()
# Check for wildcard
if '*' in allowed_origins:
return True
# Exact match
if origin in allowed_origins:
return True
# Check if origin matches any allowed domain (with subdomains)
for allowed in allowed_origins:
if allowed.startswith('*'):
# Wildcard subdomain match
domain = allowed[1:] # Remove leading *
if origin.endswith(domain):
return True
return False
def get_tenant_by_public_key(public_key: str) -> Optional[Tenant]:
"""
Get tenant by public key.
:param public_key: Public API key
:return: Tenant object or None
"""
return Tenant.query.filter_by(public_key=public_key).first()
def hash_ip(ip: str) -> str:
"""
Create a hash of an IP address for privacy-preserving storage.
:param ip: IP address string
:return: SHA256 hash of the IP
"""
if not ip:
return ''
return hashlib.sha256(ip.encode()).hexdigest()
@api_bp.before_request
def before_api_request():
"""Before request handler for API routes."""
# Set CORS headers for API routes
if current_app.config.get('ENABLE_CORS', True):
response = current_app.make_default_response()
response.headers['Access-Control-Allow-Origin'] = '*'
response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS'
response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization, X-Public-Key, X-Request-ID'
# Handle OPTIONS requests
if request.method == 'OPTIONS':
return jsonify({'status': 'ok'}), 200
@api_bp.errorhandler(BadRequest)
def handle_bad_request(error):
"""Handle bad request errors."""
return jsonify({
'error': 'bad_request',
'message': str(error.description) if error.description else 'Invalid request'
}), 400
@api_bp.errorhandler(Unauthorized)
def handle_unauthorized(error):
"""Handle unauthorized errors."""
return jsonify({
'error': 'unauthorized',
'message': str(error.description) if error.description else 'Authentication required'
}), 401
@api_bp.errorhandler(Forbidden)
def handle_forbidden(error):
"""Handle forbidden errors."""
return jsonify({
'error': 'forbidden',
'message': str(error.description) if error.description else 'Access denied'
}), 403
@api_bp.errorhandler(NotFound)
def handle_not_found(error):
"""Handle not found errors."""
return jsonify({
'error': 'not_found',
'message': str(error.description) if error.description else 'Resource not found'
}), 404
@api_bp.route('/handshake', methods=['POST'])
def handshake():
"""
Handshake endpoint for client snippets.
Validates the client's public key and origin, then returns
the form configuration for rendering.
Request Headers:
X-Public-Key: Tenant's public API key
Origin: Client origin URL
Referer: Client referer URL (fallback)
Request Body:
form_id: The ID of the form to retrieve
:return: JSON with handshake token and form configuration
"""
# Get public key from header or form data
public_key = request.headers.get('X-Public-Key') or request.form.get('public_key')
if not public_key:
raise BadRequest('Public key required')
# Get origin/referer
origin = request.headers.get('Origin') or request.headers.get('Referer') or ''
# Find tenant by public key
tenant = get_tenant_by_public_key(public_key)
if not tenant:
logger.warning(f'Handshake failed: Invalid public key {public_key[:8]}...')
raise Unauthorized('Invalid public key')
# Validate origin
if not tenant.is_active():
raise Forbidden('Tenant is not active')
if not validate_origin(origin, tenant):
logger.warning(f'Handshake failed: Invalid origin {origin} for tenant {tenant.id}')
raise Forbidden('Origin not allowed for this tenant')
# Get form ID from request
form_id = request.form.get('form_id') or request.json.get('form_id') if request.json else None
if not form_id:
raise BadRequest('form_id is required')
# Get form configuration
form = FormConfig.query.filter_by(
id=form_id,
tenant_id=tenant.id
).first()
if not form:
raise NotFound('Form not found or does not belong to tenant')
if not form.is_active:
raise Forbidden('Form is not active')
# Generate handshake token (can be used for subsequent requests)
handshake_token = str(uuid.uuid4())
# Return handshake response
response = {
'status': 'success',
'tenant_id': tenant.id,
'form_id': form.id,
'form_name': form.name,
'form_config': form.form_structure,
'settings': form.settings or {},
'handshake_token': handshake_token,
'recaptcha_enabled': form.settings.get('recaptcha_enabled', True) if form.settings else True
}
logger.info(f'Handshake successful for tenant {tenant.id}, form {form.id}')
return jsonify(response)
@api_bp.route('/form/<form_id>', methods=['GET'])
def get_form(form_id):
"""
Get form configuration.
:param form_id: ID of the form to retrieve
:return: JSON with form configuration
"""
# Get public key from header
public_key = request.headers.get('X-Public-Key')
if not public_key:
raise BadRequest('Public key required')
# Find tenant by public key
tenant = get_tenant_by_public_key(public_key)
if not tenant:
raise Unauthorized('Invalid public key')
if not tenant.is_active():
raise Forbidden('Tenant is not active')
# Get form
form = FormConfig.query.filter_by(
id=form_id,
tenant_id=tenant.id
).first()
if not form:
raise NotFound('Form not found')
if not form.is_active:
raise Forbidden('Form is not active')
return jsonify({
'status': 'success',
'form': form.to_dict()
})
@api_bp.route('/submit/<form_id>', methods=['POST'])
def submit_form(form_id):
"""
Submit form data.
Handles form submissions from client websites with:
- Origin validation
- Public key authentication
- reCAPTCHA validation (optional)
- Data validation against form structure
:param form_id: ID of the form being submitted
:return: JSON with submission result
"""
# Get public key from header
public_key = request.headers.get('X-Public-Key')
if not public_key:
raise BadRequest('Public key required')
# Find tenant by public key
tenant = get_tenant_by_public_key(public_key)
if not tenant:
logger.warning(f'Submission failed: Invalid public key {public_key[:8]}...')
raise Unauthorized('Invalid public key')
if not tenant.is_active():
raise Forbidden('Tenant is not active')
# Validate origin
origin = request.headers.get('Origin') or request.headers.get('Referer') or ''
if not validate_origin(origin, tenant):
logger.warning(f'Submission failed: Invalid origin {origin} for tenant {tenant.id}')
raise Forbidden('Origin not allowed')
# Get form
form = FormConfig.query.filter_by(
id=form_id,
tenant_id=tenant.id
).first()
if not form:
raise NotFound('Form not found')
if not form.is_active:
raise Forbidden('Form is not active')
# Get form data from request
try:
if request.content_type == 'application/json':
payload = request.get_json()
else:
payload = request.form.to_dict()
except Exception as e:
logger.error(f'Failed to parse form data: {e}')
raise BadRequest('Invalid form data format')
if not payload:
raise BadRequest('No form data provided')
# Validate reCAPTCHA if enabled
recaptcha_score = None
recaptcha_passed = None
if form.settings.get('recaptcha_enabled', True) if form.settings else True:
recaptcha_token = payload.get('g-recaptcha-response') or request.headers.get('X-ReCAPTCHA-Token')
if recaptcha_token:
recaptcha_service = RecaptchaService()
is_valid, score = recaptcha_service.verify(
recaptcha_token,
request.remote_addr
)
recaptcha_score = score
recaptcha_passed = is_valid
if not is_valid:
logger.warning(f'Submission rejected: reCAPTCHA failed with score {score}')
else:
# If no token provided, use dev mode score if in dev mode
if current_app.config.get('DEV_MODE', False):
recaptcha_score = current_app.config.get('MOCK_RECAPTCHA_SCORE', 0.9)
recaptcha_passed = True
# Create submission
submission_id = f'sub_{form_id}_{str(uuid.uuid4())[:8]}'
submission = Submission(
id=submission_id,
form_config_id=form.id,
tenant_id=tenant.id,
payload=payload,
ip_hash=hash_ip(request.remote_addr),
user_agent=request.user_agent.string if request.user_agent else None,
referer=origin,
recaptcha_score=recaptcha_score,
recaptcha_passed=recaptcha_passed,
submission_metadata={
'origin': origin,
'user_agent': request.user_agent.string if request.user_agent else None,
'content_type': request.content_type
}
)
# Set status based on reCAPTCHA
if recaptcha_passed is False:
submission.status = SubmissionStatus.SPAM
else:
submission.status = SubmissionStatus.PENDING
db.session.add(submission)
db.session.commit()
logger.info(f'Form submission: {submission_id} for form {form_id}, tenant {tenant.id}')
# Prepare response
response = {
'status': 'success',
'submission_id': submission.id,
'message': form.settings.get('success_message', 'Form submitted successfully!') if form.settings else 'Form submitted successfully!'
}
# If reCAPTCHA failed, indicate in response
if submission.status == SubmissionStatus.SPAM:
response['status'] = 'spam'
response['message'] = 'Submission flagged as spam'
logger.warning(f'Spam submission: {submission_id}')
return jsonify(response)
@api_bp.route('/validate/recaptcha', methods=['POST'])
def validate_recaptcha():
"""
Validate a reCAPTCHA token.
This endpoint can be used by client snippets to pre-validate
reCAPTCHA tokens before form submission.
:return: JSON with validation result
"""
recaptcha_token = request.form.get('token') or request.json.get('token') if request.json else None
if not recaptcha_token:
raise BadRequest('reCAPTCHA token required')
recaptcha_service = RecaptchaService()
if not recaptcha_service.is_enabled():
# If reCAPTCHA not configured, return success in dev mode
if current_app.config.get('DEV_MODE', False):
return jsonify({
'status': 'success',
'score': current_app.config.get('MOCK_RECAPTCHA_SCORE', 0.9),
'passed': True,
'dev_mode': True
})
else:
raise BadRequest('reCAPTCHA not configured')
is_valid, score = recaptcha_service.verify(
recaptcha_token,
request.remote_addr
)
return jsonify({
'status': 'success',
'score': score,
'passed': is_valid,
'threshold': recaptcha_service.get_threshold(),
'dev_mode': recaptcha_service.is_dev_mode()
})
@api_bp.route('/status')
def api_status():
"""
API health check endpoint.
:return: JSON with API status
"""
return jsonify({
'status': 'ok',
'version': '1.0.0',
'dev_mode': current_app.config.get('DEV_MODE', False),
'timestamp': datetime.now(timezone.utc).isoformat()
})
@api_bp.route('/stats/<tenant_id>')
def get_tenant_stats(tenant_id):
"""
Get statistics for a tenant.
:param tenant_id: Tenant ID
:return: JSON with tenant statistics
"""
# Authenticate with public key
public_key = request.headers.get('X-Public-Key')
if not public_key:
raise BadRequest('Public key required')
tenant = get_tenant_by_public_key(public_key)
if not tenant or tenant.id != tenant_id:
raise Unauthorized('Invalid public key or tenant mismatch')
if not tenant.is_active():
raise Forbidden('Tenant is not active')
# Get stats
form_count = FormConfig.query.filter_by(tenant_id=tenant.id).count()
submission_count = Submission.query.filter_by(tenant_id=tenant.id).count()
# Recent submissions
recent_submissions = Submission.query.filter_by(
tenant_id=tenant.id
).order_by(Submission.created_at.desc()).limit(5).all()
return jsonify({
'status': 'success',
'tenant_id': tenant.id,
'tenant_name': tenant.name,
'stats': {
'forms': form_count,
'submissions': submission_count,
'recent_submissions': [s.to_dict() for s in recent_submissions]
}
})
+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()
})