feat: Add cookie-based JWT authentication for user containers - secure access control

This commit is contained in:
XPS\Micro 2026-03-19 10:11:55 +01:00
parent 45bd329e13
commit 436b1c0b0e
4 changed files with 106 additions and 32 deletions

85
api.py
View File

@ -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 ( from flask_jwt_extended import (
create_access_token, create_access_token,
jwt_required, jwt_required,
@ -23,6 +23,34 @@ api_bp = Blueprint('api', __name__, url_prefix='/api')
token_blacklist = set() 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']) @api_bp.route('/auth/login', methods=['POST'])
def api_login(): def api_login():
"""API-Login mit Magic Link (Passwordless)""" """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") current_app.logger.info(f"[SIGNUP] User {user.email} erfolgreich registriert")
return jsonify({ user_data = {
'access_token': access_token, 'id': user.id,
'token_type': 'Bearer', 'email': user.email,
'expires_in': int(expires.total_seconds()), 'slug': user.slug,
'user': { 'is_admin': user.is_admin,
'id': user.id, 'state': user.state,
'email': user.email, 'container_id': user.container_id
'slug': user.slug, }
'is_admin': user.is_admin,
'state': user.state, return create_auth_response(access_token, user_data, int(expires.total_seconds())), 200
'container_id': user.container_id
}
}), 200
@api_bp.route('/auth/verify-login', methods=['GET']) @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") current_app.logger.info(f"[LOGIN] User {user.email} erfolgreich eingeloggt")
return jsonify({ user_data = {
'access_token': access_token, 'id': user.id,
'token_type': 'Bearer', 'email': user.email,
'expires_in': int(expires.total_seconds()), 'slug': user.slug,
'user': { 'is_admin': user.is_admin,
'id': user.id, 'state': user.state,
'email': user.email, 'container_id': user.container_id
'slug': user.slug, }
'is_admin': user.is_admin,
'state': user.state, return create_auth_response(access_token, user_data, int(expires.total_seconds())), 200
'container_id': user.container_id
}
}), 200
@api_bp.route('/auth/logout', methods=['POST']) @api_bp.route('/auth/logout', methods=['POST'])
@jwt_required() @jwt_required()
def api_logout(): def api_logout():
"""API-Logout - invalidiert Token""" """API-Logout - invalidiert Token und löscht Cookie"""
jti = get_jwt()['jti'] jti = get_jwt()['jti']
token_blacklist.add(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']) @api_bp.route('/user/me', methods=['GET'])

View File

@ -69,7 +69,8 @@ class ContainerManager:
labels=labels, labels=labels,
environment={ environment={
'USER_ID': str(user_id), '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'}, restart_policy={'Name': 'unless-stopped'},
mem_limit=Config.DEFAULT_MEMORY_LIMIT, mem_limit=Config.DEFAULT_MEMORY_LIMIT,
@ -243,7 +244,8 @@ class ContainerManager:
environment={ environment={
'USER_ID': str(user_id), 'USER_ID': str(user_id),
'USER_SLUG': slug, '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'}, restart_policy={'Name': 'unless-stopped'},
mem_limit=Config.DEFAULT_MEMORY_LIMIT, mem_limit=Config.DEFAULT_MEMORY_LIMIT,

View File

@ -3,11 +3,12 @@ Persönliches Wörterbuch - Flask Backend mit SQLite
Speichert Wörter und Bedeutungen in einer persistenten Datenbank pro Benutzer 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 from datetime import datetime
import sqlite3 import sqlite3
import os import os
import logging import logging
import jwt
app = Flask(__name__) app = Flask(__name__)
@ -59,6 +60,49 @@ def init_db():
conn.close() 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('/') @app.route('/')
def index(): def index():
"""Hauptseite mit HTML-Interface""" """Hauptseite mit HTML-Interface"""

View File

@ -1,3 +1,4 @@
Flask==3.0.0 Flask==3.0.0
Werkzeug==3.1.6 Werkzeug==3.1.6
requests==2.31.0 requests==2.31.0
PyJWT==2.8.1