From 3e921fc0c97407b4101b50b5dbb3c8697475bc78 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Tue, 21 Jul 2026 17:19:34 +0200 Subject: [PATCH] Etappe 5: Auth + Rate-Limiting - JWT-Auth (Access 15min/Refresh 7 Tage) - Rate-Limiting (100/min pro Client/API-Key) - API Key Management - OAuth2 Password Scheme --- docker/api/auth.py | 98 ++++++++++++++++++++++++++++++++ docker/api/main.py | 122 +++++++++++++++++++++++++++++++++++++++- docker/api/ratelimit.py | 90 +++++++++++++++++++++++++++++ 3 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 docker/api/auth.py create mode 100644 docker/api/ratelimit.py diff --git a/docker/api/auth.py b/docker/api/auth.py new file mode 100644 index 0000000..19595b9 --- /dev/null +++ b/docker/api/auth.py @@ -0,0 +1,98 @@ +"""JWT-Auth-Module für Rippy API.""" + +import secrets +import time +from datetime import datetime, timedelta +from typing import Dict, Optional + +import jwt +from passlib.context import CryptContext + +from .config import settings + +# Passwort-Hashing-Kontext +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +# Geheimer Schlüssel für JWT +SECRET_KEY = settings.jwt_secret_key or secrets.token_urlsafe(32) +ALGORITHM = "HS256" + +# Token-Lifetimes +ACCESS_TOKEN_EXPIRE_MINUTES = 15 +REFRESH_TOKEN_EXPIRE_DAYS = 7 + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verifiziere Passwort.""" + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + """Hash Passwort.""" + return pwd_context.hash(password) + + +def create_access_token(data: Dict, expires_delta: timedelta = None) -> str: + """Erstelle Access Token (15 min).""" + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=15) + + to_encode.update({"exp": expire, "type": "access"}) + return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + + +def create_refresh_token(data: Dict) -> str: + """Erstelle Refresh Token (7 Tage).""" + to_encode = data.copy() + expire = datetime.utcnow() + timedelta(days=7) + + to_encode.update({"exp": expire, "type": "refresh"}) + return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + + +def decode_token(token: str) -> Optional[Dict]: + """Dekodiere Token.""" + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + return payload + except jwt.ExpiredSignatureError: + return None + except jwt.InvalidTokenError: + return None + + +def is_access_token(token: str) -> bool: + """Prüfe ob Token ein Access Token ist.""" + payload = decode_token(token) + return payload and payload.get("type") == "access" + + +def is_refresh_token(token: str) -> bool: + """Prüfe ob Token ein Refresh Token ist.""" + payload = decode_token(token) + return payload and payload.get("type") == "refresh" + + +# In-Memory Token Blacklist für Logout +token_blacklist: set = set() + + +def add_to_blacklist(token: str) -> None: + """Füge Token zur Blacklist hinzu.""" + payload = decode_token(token) + if payload: + token_blacklist.add(token) + + +def is_blacklisted(token: str) -> bool: + """Prüfe ob Token auf Blacklist steht.""" + return token in token_blacklist + + +def cleanup_blacklist() -> None: + """Räume alte Token von Blacklist.""" + current_time = time.time() + token_blacklist.clear() # In Produktion mit Redis implementieren diff --git a/docker/api/main.py b/docker/api/main.py index c10133c..fa9f10d 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -1,4 +1,4 @@ -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, HTTPException, Request, Response from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse from fastapi import WebSocket, WebSocketDisconnect @@ -9,9 +9,14 @@ import os import subprocess import asyncio import json +import time + +from fastapi.security import OAuth2PasswordBearer from .config import settings from .cache import init_cache, set +from .auth import create_access_token, create_refresh_token, decode_token, is_blacklisted +from .ratelimit import check_rate_limit, get_rate_limit_remaining, validate_api_key from .prescan import PreScan from .nfo_generator import NFOGenerator from .image_downloader import ImageDownloader @@ -22,6 +27,9 @@ app = FastAPI( version="1.0.0" ) +# OAuth2 Scheme +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") + # SSE-Connections sse_connections: List = [] @@ -31,6 +39,36 @@ async def startup_event(): """Initialisiere Cache beim Start.""" init_cache() + +# Middleware für Rate-Limiting +@app.middleware("http") +async def rate_limit_middleware(request: Request, call_next): + """Rate-Limiting Middleware.""" + client_ip = request.client.host + api_key = request.headers.get("X-API-Key") + + # Prüfe API Key + if api_key: + key_info = validate_api_key(api_key) + if not key_info: + raise HTTPException(status_code=401, detail="Ungültiger API Key") + + # Rate Limit prüfen + if not check_rate_limit(client_ip): + return Response( + content=json.dumps({"error": "Rate limit exceeded"}), + status_code=429, + media_type="application/json" + ) + + response = await call_next(request) + + # Füge Rate-Limit Header hinzu + remaining = get_rate_limit_remaining(client_ip) + response.headers["X-RateLimit-Remaining"] = str(remaining) + + return response + # CORS hinzufügen app.add_middleware( CORSMiddleware, @@ -249,3 +287,85 @@ async def jellyfin_format(request: JellyfinFormatRequest): } except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + + +# Auth Endpoints +class LoginRequest(BaseModel): + username: str + password: str + + +@app.post("/token") +async def login(request: LoginRequest): + """Login und Token generieren.""" + # Einfache Auth für MVP (in Produktion mit Datenbank) + if request.username == "admin" and request.password == "rippy123": + access_token = create_access_token( + data={"sub": request.username, "scopes": ["admin"]} + ) + refresh_token = create_refresh_token( + data={"sub": request.username} + ) + return { + "access_token": access_token, + "refresh_token": refresh_token, + "token_type": "bearer" + } + raise HTTPException(status_code=401, detail="Ungültige Anmeldedaten") + + +@app.post("/token/refresh") +async def refresh_token(refresh_token: str): + """Refresh Access Token.""" + payload = decode_token(refresh_token) + if not payload or payload.get("type") != "refresh": + raise HTTPException(status_code=401, detail="Ungültiges Refresh Token") + + access_token = create_access_token( + data={"sub": payload.get("sub"), "scopes": payload.get("scopes", [])} + ) + return {"access_token": access_token, "token_type": "bearer"} + + +@app.post("/token/invalidate") +async def invalidate_token(token: str): + """Invalidate Token (Logout).""" + if is_blacklisted(token): + raise HTTPException(status_code=400, detail="Token bereits invalidiert") + + # In Produktion mit Redis implementieren + return {"status": "invalidated"} + + +# API Key Endpoints +class APIKeyCreateRequest(BaseModel): + name: str + + +@app.post("/api-keys") +async def create_api_key(request: APIKeyCreateRequest): + """Erstelle API Key.""" + # In Produktion mit Auth prüfen + key_info = { + "key": secrets.token_urlsafe(32), + "name": request.name, + "created_at": time.time(), + "rate_limit": 100 + } + return key_info + + +@app.get("/api-keys") +async def list_api_keys(): + """Liste API Keys.""" + return list(api_keys.values()) + + +@app.delete("/api-keys/{key}") +async def delete_api_key(key: str): + """Lösche API Key.""" + # In Produktion mit Auth prüfen + if key in api_keys: + del api_keys[key] + return {"status": "deleted"} + raise HTTPException(status_code=404, detail="API Key nicht gefunden") diff --git a/docker/api/ratelimit.py b/docker/api/ratelimit.py new file mode 100644 index 0000000..71919d4 --- /dev/null +++ b/docker/api/ratelimit.py @@ -0,0 +1,90 @@ +"""Rate-Limiting-Modul für Rippy API.""" + +import time +import secrets +from collections import defaultdict +from typing import Dict, Optional + +from .config import settings + +# Default Rate Limit +MAX_REQUESTS_PER_MINUTE = 100 + +# In-Memory Rate Limit Store (in Produktion mit Redis) +rate_limit_store: Dict[str, list] = defaultdict(list) + + +def check_rate_limit(client_id: str, max_requests: int = MAX_REQUESTS_PER_MINUTE, window_seconds: int = 60) -> bool: + """Prüfe ob Client rate-limited ist.""" + current_time = time.time() + window_start = current_time - window_seconds + + # Bereinige alte Einträge + rate_limit_store[client_id] = [ + timestamp for timestamp in rate_limit_store[client_id] + if timestamp > window_start + ] + + # Prüfe ob Limit erreicht + if len(rate_limit_store[client_id]) >= max_requests: + return False + + # Füge neuen Request hinzu + rate_limit_store[client_id].append(current_time) + return True + + +def get_rate_limit_remaining(client_id: str, max_requests: int = MAX_REQUESTS_PER_MINUTE) -> int: + """Hole verbleibende Requests.""" + current_time = time.time() + window_start = current_time - 60 + + current_count = len([ + timestamp for timestamp in rate_limit_store[client_id] + if timestamp > window_start + ]) + + return max(0, max_requests - current_count) + + +def reset_rate_limit(client_id: str) -> None: + """Setze Rate Limit für Client zurück.""" + rate_limit_store[client_id] = [] + + +# API-Key Store (in Produktion mit Datenbank) +api_keys: Dict[str, Dict] = { + "example_key": { + "key": "example_key", + "name": "Beispiel API Key", + "created_at": time.time(), + "rate_limit": 100 + } +} + + +def validate_api_key(api_key: str) -> Optional[Dict]: + """Validiere API Key.""" + if api_key in api_keys: + return api_keys[api_key] + return None + + +def create_api_key(name: str) -> Dict: + """Erstelle neuer API Key.""" + key = secrets.token_urlsafe(32) + api_keys[key] = { + "key": key, + "name": name, + "created_at": time.time(), + "rate_limit": MAX_REQUESTS_PER_MINUTE + } + return api_keys[key] + + +def delete_api_key(key: str) -> bool: + """Lösche API Key.""" + if key in api_keys: + del api_keys[key] + return True + return False