gemini (kostenintensives) app-aufbauen

This commit is contained in:
br committed 2026-08-01 16:42:30 +02:00
1 parent c74bca1e13
commit 8232f4d8f7
11 files changed
+657 -517

No files matched your search

+10
View File
@@ -45,7 +45,17 @@ Formeleon ist ein zentral gehosteter Service (ChamäleonCloud), der es Webseiten
3. **Datenbank initialisieren & starten:** 3. **Datenbank initialisieren & starten:**
```bash ```bash
# Migration-Repository initialisieren (nur einmalig!)
flask db init
# Datenbank-Schema erstellen
flask db migrate -m "initial migration"
flask db upgrade flask db upgrade
# Optionales Seeding mit Testdaten
flask seed-db
# Anwendung starten
flask run --port=5000 flask run --port=5000
``` ```
+3 -253
View File
@@ -35,6 +35,9 @@ import uuid
# corrected for py3.13 from config import Config, config_map # corrected for py3.13 from config import Config, config_map
from config import Config, config_map from config import Config, config_map
from models import db, Tenant, User, UserRole, Submission, FormConfig, AuditLog, SubmissionStatus from models import db, Tenant, User, UserRole, Submission, FormConfig, AuditLog, SubmissionStatus
from utils.cli import register_cli_commands
from utils.middleware import setup_dev_mode_middleware, get_current_user, get_current_tenant
from utils.helpers import get_request_context
# Initialize extensions globally (will be bound to app in create_app) # Initialize extensions globally (will be bound to app in create_app)
@@ -219,259 +222,6 @@ def register_error_handlers(app: Flask) -> None:
}, 500 }, 500
def register_cli_commands(app: Flask) -> None:
"""
Register custom CLI commands for the application.
:param app: Flask application instance
"""
import click
from flask.cli import with_appcontext
@app.cli.command('seed-db')
@with_appcontext
def seed_db():
"""Seed the database with test data for development."""
import uuid
# Check if data already exists
if Tenant.query.first():
print('Database already contains data. Use --force to overwrite.')
return
# Create dev tenant
tenant = Tenant(
id='tenant_dev_123',
name='Development Tenant',
public_key='dev-public-key-12345',
allowed_origins='http://localhost:5000,http://localhost:8080',
status=TenantStatus.ACTIVE
)
db.session.add(tenant)
# Create sample form
sample_form = FormConfig(
id='form_sample_001',
tenant_id='tenant_dev_123',
name='Contact Form',
description='Sample contact form for testing',
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,
'validation': {'pattern': r'^[^@]+@[^@]+\.[^@]+$'}
},
{
'name': 'subject',
'type': 'text',
'label': 'Subject',
'required': True
},
{
'name': 'message',
'type': 'textarea',
'label': 'Message',
'required': True,
'validation': {'min_length': 10}
}
],
'submit_button': {
'text': 'Send Message',
'style': 'primary'
}
},
settings={
'email_notification': {
'enabled': False,
'recipients': ['admin@formeleon.local']
},
'success_message': 'Thank you for your message!',
'recaptcha_enabled': True
}
)
db.session.add(sample_form)
# Create dev admin user
admin_user = User(
id='user_dev_admin_001',
email=app.config.get('DEV_USER_EMAIL', 'dev@formeleon.local'),
name=app.config.get('DEV_USER_NAME', 'Dev Admin'),
role=UserRole.ADMIN,
sub='dev-admin-sub-123'
)
db.session.add(admin_user)
# Associate user with tenant
from models import user_tenant_association
db.session.execute(
user_tenant_association.insert().values(
user_id=admin_user.id,
tenant_id=tenant.id
)
)
db.session.commit()
print(f'Seeded database with tenant {tenant.id}, form {sample_form.id}, and user {admin_user.id}')
@app.cli.command('create-tenant')
@click.argument('name')
@click.argument('public_key')
@click.option('--allowed-origins', default='*', help='Comma-separated allowed origins')
@with_appcontext
def create_tenant(name, public_key, allowed_origins):
"""Create a new tenant."""
tenant_id = f'tenant_{name.lower().replace(" ", "_")}_{str(uuid.uuid4())[:8]}'
tenant = Tenant(
id=tenant_id,
name=name,
public_key=public_key,
allowed_origins=allowed_origins
)
db.session.add(tenant)
db.session.commit()
print(f'Created tenant {tenant_id} with public key {public_key}')
@app.cli.command('create-user')
@click.argument('email')
@click.argument('name')
@click.option('--role', default='user', help='User role: admin, tenant_admin, user, viewer')
@with_appcontext
def create_user(email, name, role):
"""Create a new user."""
from models import UserRole as UR
role_enum = getattr(UR, role.upper(), UR.USER)
user_id = f'user_{email.split("@")[0]}_{str(uuid.uuid4())[:8]}'
user = User(
id=user_id,
email=email,
name=name,
role=role_enum
)
db.session.add(user)
db.session.commit()
print(f'Created user {user_id} with email {email} and role {role}')
def setup_dev_mode_middleware(app: Flask) -> None:
"""
Set up development mode middleware for fake authentication.
This middleware injects a fake Zitadel user session when DEV_MODE is enabled,
bypassing the need for actual OIDC authentication during development.
:param app: Flask application instance
"""
from functools import wraps
@app.before_request
def inject_dev_user():
"""Inject dev user into session if DEV_MODE is enabled."""
# Skip for static files, auth routes, and API routes
if (request.path.startswith('/static/') or
request.path.startswith('/auth/') or
request.path.startswith('/api/')):
return
# Skip if user is already authenticated
if 'user_id' in session:
return
# Inject dev user
dev_user = {
'id': 'user_dev_admin_001',
'email': app.config.get('DEV_USER_EMAIL', 'dev@formeleon.local'),
'name': app.config.get('DEV_USER_NAME', 'Dev Admin'),
'role': app.config.get('DEV_USER_ROLE', 'admin'),
'sub': 'dev-admin-sub-123',
'tenant_id': app.config.get('DEV_TENANT_ID', 'tenant_dev_123'),
'dev_mode': True
}
session['user_id'] = dev_user['id']
session['user'] = dev_user
session.permanent = True
# Load or create dev user in database
from models import User, UserRole, Tenant
user = User.query.get(dev_user['id'])
if not user:
role = UserRole.ADMIN if dev_user['role'] == 'admin' else UserRole.USER
user = User(
id=dev_user['id'],
email=dev_user['email'],
name=dev_user['name'],
role=role,
sub=dev_user['sub']
)
db.session.add(user)
db.session.commit()
# Ensure dev tenant exists
tenant = Tenant.query.get(dev_user['tenant_id'])
if not tenant:
tenant = Tenant(
id=dev_user['tenant_id'],
name='Development Tenant',
public_key='dev-public-key-12345',
allowed_origins='*'
)
db.session.add(tenant)
db.session.commit()
def get_current_user() -> Optional[User]:
"""
Get the current authenticated user from the request context.
:return: User object if authenticated, None otherwise
"""
from flask import session
if 'user_id' not in session:
return None
return User.query.get(session['user_id'])
def get_current_tenant() -> Optional[Tenant]:
"""
Get the current tenant from the request context.
:return: Tenant object if available, None otherwise
"""
from flask import session
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
def get_request_context() -> dict:
"""
Get standardized request context for audit logging.
:return: Dictionary with request context information
"""
return {
'ip_address': request.remote_addr,
'user_agent': request.user_agent.string if request.user_agent else None,
'path': request.path,
'method': request.method
}
# Create application instance # Create application instance
app = create_app() app = create_app()
+13 -2
View File
@@ -26,5 +26,16 @@ Im Dev-Modus wird die Google reCAPTCHA API nicht kontaktiert.
## 3. CLI Commands & Maintenance ## 3. CLI Commands & Maintenance
* **Datenbank-Seeding:** `flask seed-db` (Erstellt Test-Mandanten und Beispiel-Formulare). ### Datenbank-Migration
* **E-Mail Dump Trigger:** `flask run-scheduled-export --tenant-id=<ID> --period=daily` (Führt datenbank-agnostischen Export via SQLAlchemy aus und versendet die Mail). * **Migration-Repository initialisieren:** `flask db init` (nur einmalig erforderlich)
* **Migration erstellen:** `flask db migrate -m "Beschreibung der Änderungen"` (nach Modell-Änderungen)
* **Migration anwenden:** `flask db upgrade` (wendet ausstehende Migrationen an)
* **Migration zurücksetzen:** `flask db downgrade` (setzt die letzte Migration zurück)
### Daten & Verwaltung
* **Datenbank-Seeding:** `flask seed-db` (Erstellt Test-Mandanten und Beispiel-Formulare)
* **E-Mail Dump Trigger:** `flask run-scheduled-export --tenant-id=<ID> --period=daily` (Führt datenbank-agnostischen Export via SQLAlchemy aus und versendet die Mail)
### Tenant & User Verwaltung
* **Neuen Mandanten erstellen:** `flask create-tenant <Name> <Public-Key> [--allowed-origins=...]`
* **Neuen Benutzer erstellen:** `flask create-user <Email> <Name> [--role=admin|tenant_admin|user|viewer]`
+17 -142
View File
@@ -42,6 +42,8 @@ from werkzeug.exceptions import BadRequest, Unauthorized, Forbidden, NotFound
from models import db, Tenant, FormConfig, Submission, SubmissionStatus from models import db, Tenant, FormConfig, Submission, SubmissionStatus
from services.recaptcha import RecaptchaService from services.recaptcha import RecaptchaService
from services.tenant_service import TenantService
from services.submission_service import SubmissionService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -50,63 +52,6 @@ logger = logging.getLogger(__name__)
api_bp = Blueprint('api', __name__) 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 @api_bp.before_request
def before_api_request(): def before_api_request():
"""Before request handler for API routes.""" """Before request handler for API routes."""
@@ -186,7 +131,7 @@ def handshake():
origin = request.headers.get('Origin') or request.headers.get('Referer') or '' origin = request.headers.get('Origin') or request.headers.get('Referer') or ''
# Find tenant by public key # Find tenant by public key
tenant = get_tenant_by_public_key(public_key) tenant = TenantService.get_tenant_by_public_key(public_key)
if not tenant: if not tenant:
logger.warning(f'Handshake failed: Invalid public key {public_key[:8]}...') logger.warning(f'Handshake failed: Invalid public key {public_key[:8]}...')
@@ -196,7 +141,7 @@ def handshake():
if not tenant.is_active(): if not tenant.is_active():
raise Forbidden('Tenant is not active') raise Forbidden('Tenant is not active')
if not validate_origin(origin, tenant): if not TenantService.validate_origin(origin, tenant):
logger.warning(f'Handshake failed: Invalid origin {origin} for tenant {tenant.id}') logger.warning(f'Handshake failed: Invalid origin {origin} for tenant {tenant.id}')
raise Forbidden('Origin not allowed for this tenant') raise Forbidden('Origin not allowed for this tenant')
@@ -253,7 +198,7 @@ def get_form(form_id):
raise BadRequest('Public key required') raise BadRequest('Public key required')
# Find tenant by public key # Find tenant by public key
tenant = get_tenant_by_public_key(public_key) tenant = TenantService.get_tenant_by_public_key(public_key)
if not tenant: if not tenant:
raise Unauthorized('Invalid public key') raise Unauthorized('Invalid public key')
@@ -300,7 +245,7 @@ def submit_form(form_id):
raise BadRequest('Public key required') raise BadRequest('Public key required')
# Find tenant by public key # Find tenant by public key
tenant = get_tenant_by_public_key(public_key) tenant = TenantService.get_tenant_by_public_key(public_key)
if not tenant: if not tenant:
logger.warning(f'Submission failed: Invalid public key {public_key[:8]}...') logger.warning(f'Submission failed: Invalid public key {public_key[:8]}...')
@@ -311,22 +256,10 @@ def submit_form(form_id):
# Validate origin # Validate origin
origin = request.headers.get('Origin') or request.headers.get('Referer') or '' origin = request.headers.get('Origin') or request.headers.get('Referer') or ''
if not validate_origin(origin, tenant): if not TenantService.validate_origin(origin, tenant):
logger.warning(f'Submission failed: Invalid origin {origin} for tenant {tenant.id}') logger.warning(f'Submission failed: Invalid origin {origin} for tenant {tenant.id}')
raise Forbidden('Origin not allowed') 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 # Get form data from request
try: try:
if request.content_type == 'application/json': if request.content_type == 'application/json':
@@ -337,78 +270,20 @@ def submit_form(form_id):
logger.error(f'Failed to parse form data: {e}') logger.error(f'Failed to parse form data: {e}')
raise BadRequest('Invalid form data format') raise BadRequest('Invalid form data format')
if not payload: recaptcha_token = payload.get('g-recaptcha-response') or request.headers.get('X-ReCAPTCHA-Token') if payload else None
raise BadRequest('No form data provided')
# Validate reCAPTCHA if enabled response, status_code = SubmissionService.process_submission(
recaptcha_score = None form_id=form_id,
recaptcha_passed = None tenant=tenant,
origin=origin,
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, payload=payload,
ip_hash=hash_ip(request.remote_addr), ip_address=request.remote_addr,
user_agent=request.user_agent.string if request.user_agent else None, user_agent=request.user_agent.string if request.user_agent else None,
referer=origin, recaptcha_token=recaptcha_token,
recaptcha_score=recaptcha_score, content_type=request.content_type
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 return jsonify(response), status_code
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']) @api_bp.route('/validate/recaptcha', methods=['POST'])
@@ -483,7 +358,7 @@ def get_tenant_stats(tenant_id):
if not public_key: if not public_key:
raise BadRequest('Public key required') raise BadRequest('Public key required')
tenant = get_tenant_by_public_key(public_key) tenant = TenantService.get_tenant_by_public_key(public_key)
if not tenant or tenant.id != tenant_id: if not tenant or tenant.id != tenant_id:
raise Unauthorized('Invalid public key or tenant mismatch') raise Unauthorized('Invalid public key or tenant mismatch')
+18 -120
View File
@@ -34,6 +34,7 @@ from flask import Blueprint, request, redirect, url_for, session, current_app, j
from flask import render_template_string from flask import render_template_string
from models import db, User, UserRole, Tenant, AuditLog from models import db, User, UserRole, Tenant, AuditLog
from services.auth_service import AuthService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -42,86 +43,6 @@ logger = logging.getLogger(__name__)
auth_bp = Blueprint('auth', __name__, template_folder='../templates') 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 @auth_bp.before_request
def check_dev_mode_auth(): def check_dev_mode_auth():
""" """
@@ -139,16 +60,16 @@ def check_dev_mode_auth():
return return
# In dev mode, auto-authenticate # In dev mode, auto-authenticate
if is_dev_mode(): if AuthService.is_dev_mode():
dev_user = create_dev_user_session() dev_user = AuthService.create_dev_user_session()
session['user_id'] = dev_user['id'] session['user_id'] = dev_user['id']
session['user'] = dev_user session['user'] = dev_user
session['authenticated'] = True session['authenticated'] = True
session.permanent = True session.permanent = True
# Ensure user and tenant exist in database # Ensure user and tenant exist in database
ensure_dev_user_exists() AuthService.ensure_dev_user_exists()
ensure_dev_tenant_exists() AuthService.ensure_dev_tenant_exists()
@auth_bp.route('/login') @auth_bp.route('/login')
@@ -165,8 +86,8 @@ def login():
next_url = request.args.get('next', url_for('admin.dashboard')) next_url = request.args.get('next', url_for('admin.dashboard'))
# Development mode: create fake session and redirect # Development mode: create fake session and redirect
if is_dev_mode(): if AuthService.is_dev_mode():
dev_user = create_dev_user_session() dev_user = AuthService.create_dev_user_session()
session.clear() session.clear()
session['user_id'] = dev_user['id'] session['user_id'] = dev_user['id']
session['user'] = dev_user session['user'] = dev_user
@@ -174,8 +95,8 @@ def login():
session.permanent = True session.permanent = True
# Ensure user and tenant exist in database # Ensure user and tenant exist in database
ensure_dev_user_exists() AuthService.ensure_dev_user_exists()
ensure_dev_tenant_exists() AuthService.ensure_dev_tenant_exists()
logger.info(f'Dev mode login: {dev_user["email"]}') logger.info(f'Dev mode login: {dev_user["email"]}')
@@ -247,7 +168,7 @@ def callback():
Handles the callback from Zitadel after successful authentication. Handles the callback from Zitadel after successful authentication.
Exchanges authorization code for tokens and retrieves user info. Exchanges authorization code for tokens and retrieves user info.
""" """
if is_dev_mode(): if AuthService.is_dev_mode():
# In dev mode, shouldn't reach here, but handle gracefully # In dev mode, shouldn't reach here, but handle gracefully
return redirect(url_for('admin.dashboard')) return redirect(url_for('admin.dashboard'))
@@ -287,34 +208,11 @@ def callback():
logger.debug(f'OIDC userinfo: {user_info}') 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 # Create or update user
user = User.query.filter_by(sub=sub).first() user, error = AuthService.process_oidc_callback(user_info)
if not user: if error:
user_id = f'user_{email.split("@")[0]}_{str(uuid.uuid4())[:8]}' return jsonify({'error': error}), 400
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 # Store in session
session.clear() session.clear()
@@ -368,7 +266,7 @@ def logout():
session.clear() session.clear()
# In production, revoke token with Zitadel # In production, revoke token with Zitadel
if not is_dev_mode() and 'access_token' in session: if not AuthService.is_dev_mode() and 'access_token' in session:
try: try:
from authlib.integrations.flask_client import OAuth from authlib.integrations.flask_client import OAuth
oauth = OAuth(current_app) oauth = OAuth(current_app)
@@ -388,7 +286,7 @@ def logout():
action='logout', action='logout',
resource_type='auth', resource_type='auth',
user=current_user, user=current_user,
details={'mode': 'dev' if is_dev_mode() else 'oidc'} details={'mode': 'dev' if AuthService.is_dev_mode() else 'oidc'}
) )
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))
@@ -406,12 +304,12 @@ def status():
return jsonify({ return jsonify({
'authenticated': True, 'authenticated': True,
'user': user_data, 'user': user_data,
'dev_mode': is_dev_mode() 'dev_mode': AuthService.is_dev_mode()
}) })
return jsonify({ return jsonify({
'authenticated': False, 'authenticated': False,
'dev_mode': is_dev_mode() 'dev_mode': AuthService.is_dev_mode()
}) })
@@ -436,5 +334,5 @@ def userinfo():
return jsonify({ return jsonify({
'user': user.to_dict(), 'user': user.to_dict(),
'tenants': [tenant.to_dict() for tenant in user.tenants], 'tenants': [tenant.to_dict() for tenant in user.tenants],
'dev_mode': is_dev_mode() 'dev_mode': AuthService.is_dev_mode()
}) })
+135
View File
@@ -0,0 +1,135 @@
# ==============================================================================
# Formeleon - Multi-tenant Form Generator
# Author: Björn Runden <br@runden.net>
# Module: services/auth_service.py
# Description: Business logic for handling user authentication and session management.
# ==============================================================================
import uuid
import logging
from typing import Dict, Any, Tuple, Optional
from flask import current_app, session
from models import db, User, UserRole, Tenant, AuditLog
logger = logging.getLogger(__name__)
class AuthService:
"""Service layer for authentication and user management."""
@staticmethod
def is_dev_mode() -> bool:
"""Check if application is in development mode."""
return current_app.config.get('DEV_MODE', False)
@staticmethod
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
@staticmethod
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
@staticmethod
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
@staticmethod
def process_oidc_callback(user_info: dict) -> Tuple[Optional[User], Optional[str]]:
"""
Process user information from OIDC callback and create/update user.
:param user_info: User information dictionary from OIDC provider
:return: Tuple of (User object or None, error message or None)
"""
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 None, 'No email in user info'
# 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()
return user, None
+121
View File
@@ -0,0 +1,121 @@
# ==============================================================================
# Formeleon - Multi-tenant Form Generator
# Author: Björn Runden <br@runden.net>
# Module: services/submission_service.py
# Description: Business logic for processing and handling form submissions.
# ==============================================================================
import uuid
import logging
from typing import Dict, Any, Tuple
from flask import current_app
from werkzeug.exceptions import BadRequest, Unauthorized, Forbidden, NotFound
from models import db, Tenant, FormConfig, Submission, SubmissionStatus
from services.recaptcha import RecaptchaService
from utils.helpers import hash_ip
logger = logging.getLogger(__name__)
class SubmissionService:
"""Service layer for handling form submissions."""
@staticmethod
def process_submission(form_id: str, tenant: Tenant, origin: str, payload: dict,
ip_address: str, user_agent: str, recaptcha_token: str = None,
content_type: str = 'application/json') -> Tuple[Dict[str, Any], int]:
"""
Process a form submission with validation and reCAPTCHA checking.
:param form_id: ID of the form being submitted
:param tenant: Validated tenant object
:param origin: Validated origin string
:param payload: Submitted form data
:param ip_address: Submitter IP address
:param user_agent: Submitter user agent
:param recaptcha_token: Optional reCAPTCHA token
:param content_type: Request content type
:return: Tuple of (response_dict, http_status_code)
"""
if not payload:
raise BadRequest('No form data provided')
# 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')
# Validate reCAPTCHA if enabled
recaptcha_score = None
recaptcha_passed = None
if form.settings.get('recaptcha_enabled', True) if form.settings else True:
if recaptcha_token:
recaptcha_service = RecaptchaService()
is_valid, score = recaptcha_service.verify(
recaptcha_token,
ip_address
)
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(ip_address),
user_agent=user_agent,
referer=origin,
recaptcha_score=recaptcha_score,
recaptcha_passed=recaptcha_passed,
submission_metadata={
'origin': origin,
'user_agent': user_agent,
'content_type': 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 response, 200
+59
View File
@@ -0,0 +1,59 @@
# ==============================================================================
# Formeleon - Multi-tenant Form Generator
# Author: Björn Runden <br@runden.net>
# Module: services/tenant_service.py
# Description: Business logic for tenant management and validation.
# ==============================================================================
from typing import Optional
from models import Tenant
class TenantService:
"""Service layer for handling tenant-related business logic."""
@staticmethod
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
"""
if not public_key:
return None
return Tenant.query.filter_by(public_key=public_key).first()
@staticmethod
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
+150
View File
@@ -0,0 +1,150 @@
# ==============================================================================
# Formeleon - Multi-tenant Form Generator
# Author: Björn Runden <br@runden.net>
# Module: utils/cli.py
# Description: CLI Commands for database seeding and administration.
# ==============================================================================
import uuid
import click
from flask.cli import with_appcontext
from models import db, Tenant, User, UserRole, FormConfig, TenantStatus
def register_cli_commands(app):
"""
Register custom CLI commands for the application.
:param app: Flask application instance
"""
@app.cli.command('seed-db')
@with_appcontext
def seed_db():
"""Seed the database with test data for development."""
# Check if data already exists
if Tenant.query.first():
print('Database already contains data. Use --force to overwrite.')
return
# Create dev tenant
tenant = Tenant(
id='tenant_dev_123',
name='Development Tenant',
public_key='dev-public-key-12345',
allowed_origins='http://localhost:5000,http://localhost:8080',
status=TenantStatus.ACTIVE
)
db.session.add(tenant)
# Create sample form
sample_form = FormConfig(
id='form_sample_001',
tenant_id='tenant_dev_123',
name='Contact Form',
description='Sample contact form for testing',
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,
'validation': {'pattern': r'^[^@]+@[^@]+\.[^@]+$'}
},
{
'name': 'subject',
'type': 'text',
'label': 'Subject',
'required': True
},
{
'name': 'message',
'type': 'textarea',
'label': 'Message',
'required': True,
'validation': {'min_length': 10}
}
],
'submit_button': {
'text': 'Send Message',
'style': 'primary'
}
},
settings={
'email_notification': {
'enabled': False,
'recipients': ['admin@formeleon.local']
},
'success_message': 'Thank you for your message!',
'recaptcha_enabled': True
}
)
db.session.add(sample_form)
# Create dev admin user
admin_user = User(
id='user_dev_admin_001',
email=app.config.get('DEV_USER_EMAIL', 'dev@formeleon.local'),
name=app.config.get('DEV_USER_NAME', 'Dev Admin'),
role=UserRole.ADMIN,
sub='dev-admin-sub-123'
)
db.session.add(admin_user)
# Associate user with tenant
from models import user_tenant_association
db.session.execute(
user_tenant_association.insert().values(
user_id=admin_user.id,
tenant_id=tenant.id
)
)
db.session.commit()
print(f'Seeded database with tenant {tenant.id}, form {sample_form.id}, and user {admin_user.id}')
@app.cli.command('create-tenant')
@click.argument('name')
@click.argument('public_key')
@click.option('--allowed-origins', default='*', help='Comma-separated allowed origins')
@with_appcontext
def create_tenant(name, public_key, allowed_origins):
"""Create a new tenant."""
tenant_id = f'tenant_{name.lower().replace(" ", "_")}_{str(uuid.uuid4())[:8]}'
tenant = Tenant(
id=tenant_id,
name=name,
public_key=public_key,
allowed_origins=allowed_origins
)
db.session.add(tenant)
db.session.commit()
print(f'Created tenant {tenant_id} with public key {public_key}')
@app.cli.command('create-user')
@click.argument('email')
@click.argument('name')
@click.option('--role', default='user', help='User role: admin, tenant_admin, user, viewer')
@with_appcontext
def create_user(email, name, role):
"""Create a new user."""
from models import UserRole as UR
role_enum = getattr(UR, role.upper(), UR.USER)
user_id = f'user_{email.split("@")[0]}_{str(uuid.uuid4())[:8]}'
user = User(
id=user_id,
email=email,
name=name,
role=role_enum
)
db.session.add(user)
db.session.commit()
print(f'Created user {user_id} with email {email} and role {role}')
+34
View File
@@ -0,0 +1,34 @@
# ==============================================================================
# Formeleon - Multi-tenant Form Generator
# Author: Björn Runden <br@runden.net>
# Module: utils/helpers.py
# Description: Helper and utility functions for the application.
# ==============================================================================
import hashlib
from typing import Optional
from flask import request
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()
def get_request_context() -> dict:
"""
Get standardized request context for audit logging.
:return: Dictionary with request context information
"""
return {
'ip_address': request.remote_addr,
'user_agent': request.user_agent.string if request.user_agent else None,
'path': request.path,
'method': request.method
}
+97
View File
@@ -0,0 +1,97 @@
# ==============================================================================
# Formeleon - Multi-tenant Form Generator
# Author: Björn Runden <br@runden.net>
# Module: utils/middleware.py
# Description: Custom middlewares and context processors for the Flask app.
# ==============================================================================
from flask import session, request
from typing import Optional
from models import db, User, UserRole, Tenant
def setup_dev_mode_middleware(app):
"""
Set up development mode middleware for fake authentication.
This middleware injects a fake Zitadel user session when DEV_MODE is enabled,
bypassing the need for actual OIDC authentication during development.
:param app: Flask application instance
"""
@app.before_request
def inject_dev_user():
"""Inject dev user into session if DEV_MODE is enabled."""
# Skip for static files, auth routes, and API routes
if (request.path.startswith('/static/') or
request.path.startswith('/auth/') or
request.path.startswith('/api/')):
return
# Skip if user is already authenticated
if 'user_id' in session:
return
# Inject dev user
dev_user = {
'id': 'user_dev_admin_001',
'email': app.config.get('DEV_USER_EMAIL', 'dev@formeleon.local'),
'name': app.config.get('DEV_USER_NAME', 'Dev Admin'),
'role': app.config.get('DEV_USER_ROLE', 'admin'),
'sub': 'dev-admin-sub-123',
'tenant_id': app.config.get('DEV_TENANT_ID', 'tenant_dev_123'),
'dev_mode': True
}
session['user_id'] = dev_user['id']
session['user'] = dev_user
session.permanent = True
# Load or create dev user in database
user = User.query.get(dev_user['id'])
if not user:
role = UserRole.ADMIN if dev_user['role'] == 'admin' else UserRole.USER
user = User(
id=dev_user['id'],
email=dev_user['email'],
name=dev_user['name'],
role=role,
sub=dev_user['sub']
)
db.session.add(user)
db.session.commit()
# Ensure dev tenant exists
tenant = Tenant.query.get(dev_user['tenant_id'])
if not tenant:
tenant = Tenant(
id=dev_user['tenant_id'],
name='Development Tenant',
public_key='dev-public-key-12345',
allowed_origins='*'
)
db.session.add(tenant)
db.session.commit()
def get_current_user() -> Optional[User]:
"""
Get the current authenticated user from the request context.
:return: User object if authenticated, None otherwise
"""
if 'user_id' not in session:
return None
return User.query.get(session['user_id'])
def get_current_tenant() -> Optional[Tenant]:
"""
Get the current tenant from the request context.
:return: Tenant object if available, None otherwise
"""
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