gemini (kostenintensives) app-aufbauen
This commit is contained in:
1 parent
c74bca1e13
commit
8232f4d8f7
11 files changed
+659
-519
No files matched your search
+19
-144
@@ -42,6 +42,8 @@ from werkzeug.exceptions import BadRequest, Unauthorized, Forbidden, NotFound
|
||||
|
||||
from models import db, Tenant, FormConfig, Submission, SubmissionStatus
|
||||
from services.recaptcha import RecaptchaService
|
||||
from services.tenant_service import TenantService
|
||||
from services.submission_service import SubmissionService
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -50,63 +52,6 @@ logger = logging.getLogger(__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
|
||||
def before_api_request():
|
||||
"""Before request handler for API routes."""
|
||||
@@ -186,7 +131,7 @@ def handshake():
|
||||
origin = request.headers.get('Origin') or request.headers.get('Referer') or ''
|
||||
|
||||
# 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:
|
||||
logger.warning(f'Handshake failed: Invalid public key {public_key[:8]}...')
|
||||
@@ -196,7 +141,7 @@ def handshake():
|
||||
if not tenant.is_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}')
|
||||
raise Forbidden('Origin not allowed for this tenant')
|
||||
|
||||
@@ -253,7 +198,7 @@ def get_form(form_id):
|
||||
raise BadRequest('Public key required')
|
||||
|
||||
# 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:
|
||||
raise Unauthorized('Invalid public key')
|
||||
@@ -300,7 +245,7 @@ def submit_form(form_id):
|
||||
raise BadRequest('Public key required')
|
||||
|
||||
# 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:
|
||||
logger.warning(f'Submission failed: Invalid public key {public_key[:8]}...')
|
||||
@@ -311,22 +256,10 @@ def submit_form(form_id):
|
||||
|
||||
# Validate origin
|
||||
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}')
|
||||
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':
|
||||
@@ -337,78 +270,20 @@ def submit_form(form_id):
|
||||
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,
|
||||
recaptcha_token = payload.get('g-recaptcha-response') or request.headers.get('X-ReCAPTCHA-Token') if payload else None
|
||||
|
||||
response, status_code = SubmissionService.process_submission(
|
||||
form_id=form_id,
|
||||
tenant=tenant,
|
||||
origin=origin,
|
||||
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,
|
||||
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
|
||||
}
|
||||
recaptcha_token=recaptcha_token,
|
||||
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)
|
||||
|
||||
return jsonify(response), status_code
|
||||
|
||||
|
||||
@api_bp.route('/validate/recaptcha', methods=['POST'])
|
||||
@@ -483,7 +358,7 @@ def get_tenant_stats(tenant_id):
|
||||
if not public_key:
|
||||
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:
|
||||
raise Unauthorized('Invalid public key or tenant mismatch')
|
||||
|
||||
+18
-120
@@ -34,6 +34,7 @@ from flask import Blueprint, request, redirect, url_for, session, current_app, j
|
||||
from flask import render_template_string
|
||||
|
||||
from models import db, User, UserRole, Tenant, AuditLog
|
||||
from services.auth_service import AuthService
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -42,86 +43,6 @@ logger = logging.getLogger(__name__)
|
||||
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():
|
||||
"""
|
||||
@@ -139,16 +60,16 @@ def check_dev_mode_auth():
|
||||
return
|
||||
|
||||
# In dev mode, auto-authenticate
|
||||
if is_dev_mode():
|
||||
dev_user = create_dev_user_session()
|
||||
if AuthService.is_dev_mode():
|
||||
dev_user = AuthService.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()
|
||||
AuthService.ensure_dev_user_exists()
|
||||
AuthService.ensure_dev_tenant_exists()
|
||||
|
||||
|
||||
@auth_bp.route('/login')
|
||||
@@ -165,8 +86,8 @@ def 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()
|
||||
if AuthService.is_dev_mode():
|
||||
dev_user = AuthService.create_dev_user_session()
|
||||
session.clear()
|
||||
session['user_id'] = dev_user['id']
|
||||
session['user'] = dev_user
|
||||
@@ -174,8 +95,8 @@ def login():
|
||||
session.permanent = True
|
||||
|
||||
# Ensure user and tenant exist in database
|
||||
ensure_dev_user_exists()
|
||||
ensure_dev_tenant_exists()
|
||||
AuthService.ensure_dev_user_exists()
|
||||
AuthService.ensure_dev_tenant_exists()
|
||||
|
||||
logger.info(f'Dev mode login: {dev_user["email"]}')
|
||||
|
||||
@@ -247,7 +168,7 @@ def callback():
|
||||
Handles the callback from Zitadel after successful authentication.
|
||||
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
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
|
||||
@@ -287,34 +208,11 @@ def callback():
|
||||
|
||||
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()
|
||||
user, error = AuthService.process_oidc_callback(user_info)
|
||||
|
||||
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()
|
||||
if error:
|
||||
return jsonify({'error': error}), 400
|
||||
|
||||
# Store in session
|
||||
session.clear()
|
||||
@@ -368,7 +266,7 @@ def logout():
|
||||
session.clear()
|
||||
|
||||
# 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:
|
||||
from authlib.integrations.flask_client import OAuth
|
||||
oauth = OAuth(current_app)
|
||||
@@ -388,7 +286,7 @@ def logout():
|
||||
action='logout',
|
||||
resource_type='auth',
|
||||
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'))
|
||||
@@ -406,12 +304,12 @@ def status():
|
||||
return jsonify({
|
||||
'authenticated': True,
|
||||
'user': user_data,
|
||||
'dev_mode': is_dev_mode()
|
||||
'dev_mode': AuthService.is_dev_mode()
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'authenticated': False,
|
||||
'dev_mode': is_dev_mode()
|
||||
'dev_mode': AuthService.is_dev_mode()
|
||||
})
|
||||
|
||||
|
||||
@@ -436,5 +334,5 @@ def userinfo():
|
||||
return jsonify({
|
||||
'user': user.to_dict(),
|
||||
'tenants': [tenant.to_dict() for tenant in user.tenants],
|
||||
'dev_mode': is_dev_mode()
|
||||
'dev_mode': AuthService.is_dev_mode()
|
||||
})
|
||||
Reference in new issue
Block a user