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
This commit is contained in:
Hitonabi
2026-07-21 17:19:34 +02:00
parent 518155051f
commit 3e921fc0c9
3 changed files with 309 additions and 1 deletions
+98
View File
@@ -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
+121 -1
View File
@@ -1,4 +1,4 @@
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from fastapi import WebSocket, WebSocketDisconnect from fastapi import WebSocket, WebSocketDisconnect
@@ -9,9 +9,14 @@ import os
import subprocess import subprocess
import asyncio import asyncio
import json import json
import time
from fastapi.security import OAuth2PasswordBearer
from .config import settings from .config import settings
from .cache import init_cache, set 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 .prescan import PreScan
from .nfo_generator import NFOGenerator from .nfo_generator import NFOGenerator
from .image_downloader import ImageDownloader from .image_downloader import ImageDownloader
@@ -22,6 +27,9 @@ app = FastAPI(
version="1.0.0" version="1.0.0"
) )
# OAuth2 Scheme
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
# SSE-Connections # SSE-Connections
sse_connections: List = [] sse_connections: List = []
@@ -31,6 +39,36 @@ async def startup_event():
"""Initialisiere Cache beim Start.""" """Initialisiere Cache beim Start."""
init_cache() 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 # CORS hinzufügen
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@@ -249,3 +287,85 @@ async def jellyfin_format(request: JellyfinFormatRequest):
} }
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(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")
+90
View File
@@ -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