From 436b1c0b0ed756dc7f494ed836057d8544195b45 Mon Sep 17 00:00:00 2001 From: "XPS\\Micro" Date: Thu, 19 Mar 2026 10:11:55 +0100 Subject: [PATCH] feat: Add cookie-based JWT authentication for user containers - secure access control --- api.py | 85 +++++++++++++++-------- container_manager.py | 6 +- user-template-dictionary/app.py | 46 +++++++++++- user-template-dictionary/requirements.txt | 1 + 4 files changed, 106 insertions(+), 32 deletions(-) diff --git a/api.py b/api.py index 519970c..f27df49 100644 --- a/api.py +++ b/api.py @@ -1,4 +1,4 @@ -from flask import Blueprint, jsonify, request, current_app, redirect +from flask import Blueprint, jsonify, request, current_app, redirect, make_response from flask_jwt_extended import ( create_access_token, jwt_required, @@ -23,6 +23,34 @@ api_bp = Blueprint('api', __name__, url_prefix='/api') token_blacklist = set() +def create_auth_response(access_token, user_data, expires_in): + """Erstellt eine JSON-Response mit JWT-Token als HttpOnly Cookie""" + response_data = { + 'access_token': access_token, + 'token_type': 'Bearer', + 'expires_in': expires_in, + 'user': user_data + } + + response = make_response(jsonify(response_data)) + + # Setze JWT als HttpOnly Cookie + # HttpOnly verhindert JavaScript-Zugriff + # Secure: nur über HTTPS + # SameSite: CSRF-Schutz + response.set_cookie( + 'spawner_token', + access_token, + max_age=expires_in, + httponly=True, + secure=True, # Nur über HTTPS + samesite='Lax', # CSRF-Schutz + path='/' # Für alle Pfade verfügbar + ) + + return response + + @api_bp.route('/auth/login', methods=['POST']) def api_login(): """API-Login mit Magic Link (Passwordless)""" @@ -252,19 +280,16 @@ def api_verify_signup(): current_app.logger.info(f"[SIGNUP] User {user.email} erfolgreich registriert") - return jsonify({ - 'access_token': access_token, - 'token_type': 'Bearer', - 'expires_in': int(expires.total_seconds()), - 'user': { - 'id': user.id, - 'email': user.email, - 'slug': user.slug, - 'is_admin': user.is_admin, - 'state': user.state, - 'container_id': user.container_id - } - }), 200 + user_data = { + 'id': user.id, + 'email': user.email, + 'slug': user.slug, + 'is_admin': user.is_admin, + 'state': user.state, + 'container_id': user.container_id + } + + return create_auth_response(access_token, user_data, int(expires.total_seconds())), 200 @api_bp.route('/auth/verify-login', methods=['GET']) @@ -351,28 +376,30 @@ def api_verify_login(): current_app.logger.info(f"[LOGIN] User {user.email} erfolgreich eingeloggt") - return jsonify({ - 'access_token': access_token, - 'token_type': 'Bearer', - 'expires_in': int(expires.total_seconds()), - 'user': { - 'id': user.id, - 'email': user.email, - 'slug': user.slug, - 'is_admin': user.is_admin, - 'state': user.state, - 'container_id': user.container_id - } - }), 200 + user_data = { + 'id': user.id, + 'email': user.email, + 'slug': user.slug, + 'is_admin': user.is_admin, + 'state': user.state, + 'container_id': user.container_id + } + + return create_auth_response(access_token, user_data, int(expires.total_seconds())), 200 @api_bp.route('/auth/logout', methods=['POST']) @jwt_required() def api_logout(): - """API-Logout - invalidiert Token""" + """API-Logout - invalidiert Token und löscht Cookie""" jti = get_jwt()['jti'] token_blacklist.add(jti) - return jsonify({'message': 'Erfolgreich abgemeldet'}), 200 + + # Erstelle Response und lösche Cookie + response = make_response(jsonify({'message': 'Erfolgreich abgemeldet'})) + response.delete_cookie('spawner_token', path='/') + + return response, 200 @api_bp.route('/user/me', methods=['GET']) diff --git a/container_manager.py b/container_manager.py index f03fbff..e2620ec 100644 --- a/container_manager.py +++ b/container_manager.py @@ -69,7 +69,8 @@ class ContainerManager: labels=labels, environment={ 'USER_ID': str(user_id), - 'USER_SLUG': slug + 'USER_SLUG': slug, + 'JWT_SECRET': Config.SECRET_KEY # Für Token-Validierung im Container }, restart_policy={'Name': 'unless-stopped'}, mem_limit=Config.DEFAULT_MEMORY_LIMIT, @@ -243,7 +244,8 @@ class ContainerManager: environment={ 'USER_ID': str(user_id), 'USER_SLUG': slug, - 'CONTAINER_TYPE': container_type + 'CONTAINER_TYPE': container_type, + 'JWT_SECRET': Config.SECRET_KEY # Für Token-Validierung im Container }, restart_policy={'Name': 'unless-stopped'}, mem_limit=Config.DEFAULT_MEMORY_LIMIT, diff --git a/user-template-dictionary/app.py b/user-template-dictionary/app.py index 0048868..9e020ca 100644 --- a/user-template-dictionary/app.py +++ b/user-template-dictionary/app.py @@ -3,11 +3,12 @@ Persönliches Wörterbuch - Flask Backend mit SQLite Speichert Wörter und Bedeutungen in einer persistenten Datenbank pro Benutzer """ -from flask import Flask, render_template, request, jsonify +from flask import Flask, render_template, request, jsonify, g from datetime import datetime import sqlite3 import os import logging +import jwt app = Flask(__name__) @@ -59,6 +60,49 @@ def init_db(): conn.close() +# ============================================================ +# JWT-Token Validierung +# ============================================================ +JWT_SECRET = os.getenv('JWT_SECRET', 'secret-key-from-spawner') # Sollte vom Spawner gesetzt werden + +def validate_jwt_token(): + """Validiere JWT-Token aus Cookie - wird vor jedem Request ausgeführt""" + # GET / ist öffentlich (index.html laden) + if request.path == '/' and request.method == 'GET': + return + + # GET /health ist öffentlich (Health Check) + if request.path == '/health' and request.method == 'GET': + return + + # Alle anderen Endpoints brauchen gültigen JWT-Token im Cookie + token = request.cookies.get('spawner_token') + + if not token: + return jsonify({'error': 'Authentifizierung erforderlich - kein Token'}), 401 + + try: + # Dekodiere und validiere JWT + # Hinweis: Der Secret-Key muss mit dem Spawner synchron sein! + payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256']) + # Speichere User-ID im g-Object für API-Endpunkte + g.user_id = payload.get('sub') # 'sub' ist die Standard-Claim für User-ID + logger.info(f"[DICTIONARY] Token validiert für User {g.user_id}") + except jwt.ExpiredSignatureError: + logger.warning("[DICTIONARY] JWT-Token abgelaufen") + return jsonify({'error': 'Token abgelaufen - bitte neu anmelden'}), 401 + except jwt.InvalidTokenError as e: + logger.warning(f"[DICTIONARY] Ungültiger JWT-Token: {str(e)}") + return jsonify({'error': 'Ungültiger Token - authentifizieren erforderlich'}), 401 + except Exception as e: + logger.error(f"[DICTIONARY] Token-Validierungsfehler: {str(e)}") + return jsonify({'error': 'Authentifizierungsfehler'}), 500 + + +# Registriere before_request Handler +app.before_request(validate_jwt_token) + + @app.route('/') def index(): """Hauptseite mit HTML-Interface""" diff --git a/user-template-dictionary/requirements.txt b/user-template-dictionary/requirements.txt index dd9d47d..0dfc95d 100644 --- a/user-template-dictionary/requirements.txt +++ b/user-template-dictionary/requirements.txt @@ -1,3 +1,4 @@ Flask==3.0.0 Werkzeug==3.1.6 requests==2.31.0 +PyJWT==2.8.1