initiales bauen mit mistral
This commit is contained in:
1 parent
7e2e8bdf59
commit
c74bca1e13
22 files changed
+5642
No files matched your search
+758
@@ -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
@@ -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
@@ -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()
|
||||
})
|
||||
Reference in new issue
Block a user