feat: Add cookie-based JWT authentication for user containers - secure access control
This commit is contained in:
parent
45bd329e13
commit
436b1c0b0e
85
api.py
85
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 (
|
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'])
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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"""
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user