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 (
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'])

View File

@ -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,

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
"""
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"""

View File

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