# Auth-Flow & Sicherheit ## Übersicht Mitai Jinkendo nutzt **Token-basiertes Session-Management** mit bcrypt-Passwort-Hashing. Die Authentifizierung ist als FastAPI Dependency implementiert (`require_auth()`), die automatisch auf alle geschützten Endpoints angewendet wird. **Sicherheits-Features:** - ✅ bcrypt-Hashing (Work Factor ~12 Rounds) - ✅ Auto-Migration SHA256 → bcrypt - ✅ Rate Limiting (slowapi) - ✅ CORS-Konfiguration - ✅ E-Mail-Verifizierung für Registrierung - ✅ Passwort-Reset via E-Mail (1h Token) - ✅ Session-Expiry (30 Tage Standard) - ✅ Admin-Role-Check --- ## Login-Flow (Schritt für Schritt) ### 1. User-Eingabe (Frontend) **LoginScreen.jsx:** ```javascript const handleLogin = async () => { const data = await login({ email: email.trim().toLowerCase(), password }) window.location.href = '/' // Hard-Redirect nach Login } ``` ### 2. POST /api/auth/login (Backend) **Request:** ```json { "email": "user@example.com", "password": "geheim123" } ``` **Backend-Logik (`backend/routers/auth.py`):** ```python @router.post("/login") @limiter.limit("5/minute") # Rate Limiting async def login(req: LoginRequest, request: Request): # 1. E-Mail-Lookup cur.execute("SELECT * FROM profiles WHERE email=%s", (req.email.lower().strip(),)) prof = cur.fetchone() if not prof: raise HTTPException(401, "Ungültige Zugangsdaten") # 2. Passwort-Verifizierung if not verify_pin(req.password, prof['pin_hash']): raise HTTPException(401, "Ungültige Zugangsdaten") # 3. Auto-Migration SHA256 → bcrypt if not prof['pin_hash'].startswith('$2'): # bcrypt-Hash startet mit $2b$ oder $2a$ new_hash = hash_pin(req.password) cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, prof['id'])) # 4. Session erstellen token = make_token() # secrets.token_urlsafe(32) → 43 Zeichen session_days = prof.get('session_days', 30) expires = datetime.now() + timedelta(days=session_days) cur.execute(""" INSERT INTO sessions (token, profile_id, expires_at, created) VALUES (%s, %s, %s, CURRENT_TIMESTAMP) """, (token, prof['id'], expires)) return { "token": token, "profile_id": prof['id'], "name": prof['name'], "role": prof['role'], "expires_at": expires.isoformat() } ``` **Response:** ```json { "token": "jT9z3xK...(43 chars)...pQ2vL", "profile_id": "550e8400-e29b-41d4-a716-446655440000", "name": "Max Mustermann", "role": "user", "expires_at": "2026-04-22T14:30:00" } ``` ### 3. Token-Speicherung (Frontend) **AuthContext.jsx:** ```javascript const login = async (credentials) => { const data = await api.login(credentials) // Token speichern localStorage.setItem('bodytrack_token', data.token) localStorage.setItem('bodytrack_active_profile', data.profile_id) // Volles Profil laden const profile = await fetch('/api/auth/me', { headers: { 'X-Auth-Token': data.token } }) setSession({ token: data.token, profile_id: data.profile_id, role: data.role, profile: await profile.json() }) } ``` ### 4. Nachfolgende Requests **api.js:** ```javascript function hdrs(extra={}) { const h = {...extra} const token = getToken() // localStorage.getItem('bodytrack_token') if (token) h['X-Auth-Token'] = token return h } async function req(path, opts={}) { const res = await fetch(BASE+path, {...opts, headers:hdrs(opts.headers||{})}) // ... } ``` **Jeder API-Call sendet automatisch:** ``` GET /api/weight Headers: X-Auth-Token: jT9z3xK...pQ2vL ``` ### 5. Auth-Validierung (Backend) **require_auth() Dependency:** ```python def require_auth(x_auth_token: Optional[str] = Header(default=None)): """FastAPI Dependency - requires valid authentication.""" session = get_session(x_auth_token) if not session: raise HTTPException(401, "Nicht eingeloggt") return session def get_session(token: str): """Get session data for a given token.""" if not token: return None with get_db() as conn: cur = get_cursor(conn) cur.execute(""" SELECT s.*, p.role, p.name, p.ai_enabled, p.ai_limit_day, p.export_enabled FROM sessions s JOIN profiles p ON s.profile_id=p.id WHERE s.token=%s AND s.expires_at > CURRENT_TIMESTAMP """, (token,)) return cur.fetchone() ``` **Endpoint-Beispiel:** ```python @router.get("/api/weight") def list_weight(session: dict = Depends(require_auth)): profile_id = session['profile_id'] # Immer aus Session, nie aus Header! # ... Query mit profile_id ``` **Session-Dict:** ```python { 'profile_id': 'uuid-string', 'role': 'user' | 'admin', 'name': 'Max Mustermann', 'ai_enabled': True, 'ai_limit_day': 10, 'export_enabled': True, 'expires_at': datetime(2026, 4, 22, 14, 30, 0) } ``` --- ## Registrierung-Flow (v9c) ### 1. Selbst-Registrierung **POST /api/auth/register:** ```json { "name": "Max Mustermann", "email": "max@example.com", "password": "sicheresPasswort123" } ``` **Validierung:** - E-Mail: Muss `@` enthalten - Passwort: Mindestens 8 Zeichen - Name: Mindestens 2 Zeichen - E-Mail-Duplikat-Check **Backend:** ```python @router.post("/register") @limiter.limit("3/hour") # Rate Limiting async def register(req: RegisterRequest, request: Request): email = req.email.lower().strip() # Duplikat-Check cur.execute("SELECT id FROM profiles WHERE email=%s", (email,)) if cur.fetchone(): raise HTTPException(400, "E-Mail-Adresse bereits registriert") # Profil erstellen (inaktiv bis verifiziert) profile_id = str(secrets.token_hex(16)) pin_hash = hash_pin(req.password) verification_token = secrets.token_urlsafe(32) verification_expires = datetime.now() + timedelta(hours=24) trial_ends = datetime.now() + timedelta(days=14) # 14-Tage-Trial cur.execute(""" INSERT INTO profiles ( id, name, email, pin_hash, auth_type, role, tier, email_verified, verification_token, verification_expires, trial_ends_at, created ) VALUES (%s, %s, %s, %s, 'email', 'user', 'free', FALSE, %s, %s, %s, CURRENT_TIMESTAMP) """, (profile_id, req.name, email, pin_hash, verification_token, verification_expires, trial_ends)) # Verifizierungs-E-Mail senden send_email(email, "Willkommen bei Mitai Jinkendo", f"Verify: {APP_URL}/verify?token={verification_token}") ``` **Response:** ```json { "ok": true, "message": "Registrierung erfolgreich! Bitte prüfe dein E-Mail-Postfach." } ``` ### 2. E-Mail-Verifizierung **User klickt Link in E-Mail:** ``` https://mitai.jinkendo.de/verify?token=jT9z3xK...pQ2vL ``` **GET /api/auth/verify/{token}:** ```python @router.get("/verify/{token}") async def verify_email(token: str): # Token-Lookup cur.execute(""" SELECT id, name, email, email_verified, verification_expires FROM profiles WHERE verification_token=%s """, (token,)) prof = cur.fetchone() if not prof: raise HTTPException(400, "Verifikations-Link ungültig") if prof['email_verified']: raise HTTPException(400, "E-Mail bereits bestätigt") if datetime.now() > prof['verification_expires']: raise HTTPException(400, "Link abgelaufen") # Verifizierung cur.execute(""" UPDATE profiles SET email_verified=TRUE, verification_token=NULL, verification_expires=NULL WHERE id=%s """, (prof['id'],)) # Auto-Login (Session erstellen) session_token = make_token() expires = datetime.now() + timedelta(days=30) cur.execute(""" INSERT INTO sessions (token, profile_id, expires_at, created) VALUES (%s, %s, %s, CURRENT_TIMESTAMP) """, (session_token, prof['id'], expires)) return { "ok": True, "token": session_token, "profile": {"id": prof['id'], "name": prof['name'], "email": prof['email']} } ``` **Frontend (Verify.jsx):** ```javascript const verifyToken = urlParams.get('token') const data = await api.verifyEmail(verifyToken) // Auto-Login nach Verifizierung setAuthFromToken(data.token, data.profile) window.location.href = '/' ``` ### 3. Resend Verification **POST /api/auth/resend-verification:** ```json { "email": "max@example.com" } ``` **Backend:** - Generiert neuen Token (24h Gültigkeit) - Sendet erneut E-Mail - Rate Limit: 3/hour --- ## Passwort-Reset-Flow ### 1. Passwort vergessen **POST /api/auth/forgot-password:** ```json { "email": "max@example.com" } ``` **Backend:** ```python @router.post("/forgot-password") @limiter.limit("3/minute") async def password_reset_request(req: PasswordResetRequest, request: Request): email = req.email.lower().strip() cur.execute("SELECT id, name FROM profiles WHERE email=%s", (email,)) prof = cur.fetchone() if not prof: # Don't reveal if email exists (Anti-Enumeration) return {"ok": True, "message": "Falls die E-Mail existiert, wurde ein Link gesendet."} # Reset-Token erstellen token = secrets.token_urlsafe(32) expires = datetime.now() + timedelta(hours=1) # 1 Stunde gültig # In sessions-Tabelle speichern (mit Präfix "reset_") cur.execute(""" INSERT INTO sessions (token, profile_id, expires_at, created) VALUES (%s, %s, %s, CURRENT_TIMESTAMP) """, (f"reset_{token}", prof['id'], expires)) # E-Mail senden send_email(prof['email'], "Passwort zurücksetzen", f"Reset: {APP_URL}/reset-password?token={token}") ``` ### 2. Neues Passwort setzen **User klickt Link in E-Mail:** ``` https://mitai.jinkendo.de/reset-password?token=jT9z3xK...pQ2vL ``` **POST /api/auth/reset-password:** ```json { "token": "jT9z3xK...pQ2vL", "new_password": "neuesPasswort123" } ``` **Backend:** ```python @router.post("/reset-password") def password_reset_confirm(req: PasswordResetConfirm): cur.execute(""" SELECT profile_id FROM sessions WHERE token=%s AND expires_at > CURRENT_TIMESTAMP """, (f"reset_{req.token}",)) sess = cur.fetchone() if not sess: raise HTTPException(400, "Ungültiger oder abgelaufener Reset-Link") # Passwort ändern new_hash = hash_pin(req.new_password) cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, sess['profile_id'])) # Reset-Token löschen cur.execute("DELETE FROM sessions WHERE token=%s", (f"reset_{req.token}",)) return {"ok": True, "message": "Passwort erfolgreich zurückgesetzt"} ``` --- ## Session-Management ### Session-Struktur (Datenbank) **Tabelle: `sessions`** ```sql CREATE TABLE sessions ( token VARCHAR(64) PRIMARY KEY, -- secrets.token_urlsafe(32) profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, expires_at TIMESTAMP WITH TIME ZONE NOT NULL, created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_sessions_profile_id ON sessions(profile_id); CREATE INDEX idx_sessions_expires_at ON sessions(expires_at); ``` ### Token-Format **`secrets.token_urlsafe(32)`** - 32 Bytes Zufallsdaten - Base64-URL-safe-kodiert - Resultierende Länge: 43 Zeichen - Charset: `A-Za-z0-9_-` **Beispiel:** `jT9z3xKpLmN8vQrStUwXyZ1aB2cD3eF4gH5iJ6kL7mN8oP9qR` ### Session-Lebensdauer **Standard:** 30 Tage (konfigurierbar pro Profil via `profiles.session_days`) **Nach Login:** ```python session_days = prof.get('session_days', 30) expires = datetime.now() + timedelta(days=session_days) ``` **Automatische Bereinigung:** - Abgelaufene Sessions werden bei Login automatisch gelöscht (via `WHERE expires_at > CURRENT_TIMESTAMP`) - Geplant: Cron-Job für Cleanup alter Sessions (v10+) ### Logout **POST /api/auth/logout:** ```python @router.post("/logout") def logout(x_auth_token: Optional[str]=Header(default=None)): if x_auth_token: with get_db() as conn: cur = get_cursor(conn) cur.execute("DELETE FROM sessions WHERE token=%s", (x_auth_token,)) return {"ok": True} ``` **Frontend:** ```javascript const logout = async () => { await fetch('/api/auth/logout', { method: 'POST', headers: { 'X-Auth-Token': token } }) localStorage.removeItem('bodytrack_token') setSession(null) } ``` --- ## Passwort-Sicherheit ### 1. bcrypt-Hashing **Verwendete Bibliothek:** `bcrypt` (Python) **Konfiguration:** - Work Factor: Default (~12 Rounds) - Automatisches Salting (integriert in bcrypt) **Hash-Format:** ``` $2b$12$LQv3c1yqBWVHxkd0LHAkCOYz3/yDZL1AJ.zCBU8fKWChSrW8GZF1a │ │ │ │ │ │ │ │ └─ Salt (22 Zeichen) └─ Hash (31 Zeichen) │ │ └─ Cost-Faktor (12 = 2^12 = 4096 Iterationen) │ └─ bcrypt-Variante ('a' oder 'b') └─ Präfix ``` **Hashing-Funktion (`auth.py`):** ```python import bcrypt def hash_pin(pin: str) -> str: """Hash password with bcrypt.""" return bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode() ``` **Verifizierung:** ```python def verify_pin(pin: str, stored_hash: str) -> bool: """Verify password - supports both bcrypt and legacy SHA256.""" if not stored_hash: return False # Detect bcrypt hash (starts with $2b$ or $2a$) if stored_hash.startswith('$2'): try: return bcrypt.checkpw(pin.encode(), stored_hash.encode()) except Exception: return False # Legacy SHA256 support (auto-upgrade to bcrypt on next login) return stored_hash == hashlib.sha256(pin.encode()).hexdigest() ``` ### 2. SHA256 → bcrypt Auto-Migration **Problem:** Alte Accounts hatten SHA256-Hashes (unsicher, kein Salting) **Lösung:** Automatische Migration beim Login **Logik:** ```python # Beim Login if prof['pin_hash'] and not prof['pin_hash'].startswith('$2'): # Passwort verifiziert erfolgreich (via SHA256) # → Upgrade zu bcrypt new_hash = hash_pin(req.password) cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, prof['id'])) ``` **Ablauf:** 1. User loggt sich mit altem Passwort ein 2. Backend verifiziert gegen SHA256-Hash (erfolgreich) 3. Backend erstellt bcrypt-Hash vom Klartext-Passwort 4. SHA256-Hash wird in DB durch bcrypt-Hash ersetzt 5. Nächster Login → bcrypt-Verifizierung **Vorteil:** Keine Passwort-Resets nötig, Migration transparent ### 3. Passwort-Anforderungen **Minimum:** - Länge: 8 Zeichen (bei Registrierung) - Länge: 4 Zeichen (bei PIN-Change, für Abwärtskompatibilität) **Empfohlen (nicht erzwungen):** - Groß- und Kleinbuchstaben - Zahlen - Sonderzeichen **Keine Komplexitäts-Prüfung:** Bewusst nicht implementiert (Fokus auf Länge statt Komplexität) --- ## Rate Limiting **Bibliothek:** slowapi (Redis-freie In-Memory Rate Limiting) ### Konfiguration **main.py:** ```python from slowapi import Limiter, _rate_limit_exceeded_handler from slowapi.util import get_remote_address from slowapi.errors import RateLimitExceeded limiter = Limiter(key_func=get_remote_address) app.state.limiter = limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) ``` ### Rate Limits | Endpoint | Limit | Grund | |----------|-------|-------| | `/api/auth/login` | 5/minute | Brute-Force-Schutz | | `/api/auth/register` | 3/hour | Spam-Prevention | | `/api/auth/forgot-password` | 3/minute | E-Mail-Flooding-Schutz | | `/api/auth/resend-verification` | 3/hour | E-Mail-Flooding-Schutz | **Verwendung:** ```python from slowapi import Limiter @router.post("/login") @limiter.limit("5/minute") async def login(req: LoginRequest, request: Request): # Request-Objekt muss übergeben werden für IP-Extraktion pass ``` **Response bei Überschreitung:** ``` HTTP 429 Too Many Requests { "detail": "Rate limit exceeded: 5 per 1 minute" } ``` **Key-Funktion:** `get_remote_address` → IP-basiert (nicht User-basiert) **Hinweis:** In-Memory = Reset bei Server-Neustart --- ## CORS-Konfiguration ### Produktion **docker-compose.yml:** ```yaml ALLOWED_ORIGINS: https://mitai.jinkendo.de ``` **main.py:** ```python app.add_middleware( CORSMiddleware, allow_origins=os.getenv("ALLOWED_ORIGINS", "*").split(","), allow_credentials=True, allow_methods=["GET","POST","PUT","DELETE","OPTIONS"], allow_headers=["*"], ) ``` ### Development **docker-compose.dev-env.yml:** ```yaml ALLOWED_ORIGINS: https://dev.mitai.jinkendo.de,http://localhost:3099 ``` **Wichtig:** Keine Wildcards (`*`) in Produktion! --- ## Öffentliche vs. geschützte Endpoints ### Öffentliche Endpoints (kein Auth) | Endpoint | Methode | Zweck | |----------|---------|-------| | `/` | GET | Health Check | | `/api/auth/status` | GET | Status-Check (First-Run-Detection) | | `/api/auth/login` | POST | Login | | `/api/auth/register` | POST | Registrierung | | `/api/auth/verify/{token}` | GET | E-Mail-Verifizierung | | `/api/auth/forgot-password` | POST | Passwort-Reset anfordern | | `/api/auth/reset-password` | POST | Passwort-Reset bestätigen | | `/api/auth/resend-verification` | POST | Verifizierungs-E-Mail erneut senden | ### Geschützte Endpoints (require_auth) **Alle anderen Endpoints** benötigen `X-Auth-Token` Header. **Beispiel:** ```python @router.get("/api/weight") def list_weight(session: dict = Depends(require_auth)): profile_id = session['profile_id'] # ... ``` ### Admin-Only Endpoints (require_admin) | Endpoint | Zweck | |----------|-------| | `/api/admin/*` | Admin-Panel | | `/api/features` | Feature-Verwaltung (POST/PUT/DELETE) | | `/api/tiers` | Tier-Verwaltung (POST/PUT/DELETE) | | `/api/tier-limits` | Tier-Limits-Matrix (PUT) | | `/api/coupons` | Coupon-Verwaltung (POST/PUT/DELETE) | | `/api/user-restrictions` | User-Restrictions (POST/PUT/DELETE) | | `/api/access-grants` | Access-Grants (POST/PUT/DELETE) | | `/api/admin/training-types` | Trainingstypen-Admin (POST/PUT/DELETE) | | `/api/admin/activity-mappings` | Activity-Mappings (POST/PUT/DELETE) | **require_admin() Dependency:** ```python def require_admin(x_auth_token: Optional[str] = Header(default=None)): """FastAPI dependency - requires admin authentication.""" session = get_session(x_auth_token) if not session: raise HTTPException(401, "Nicht eingeloggt") if session['role'] != 'admin': raise HTTPException(403, "Nur für Admins") return session ``` --- ## E-Mail-System ### SMTP-Konfiguration **Umgebungsvariablen:** ```env SMTP_HOST=smtp.strato.de SMTP_PORT=587 SMTP_USER=noreply@jinkendo.de SMTP_PASS=***** SMTP_FROM=noreply@jinkendo.de APP_URL=https://mitai.jinkendo.de ``` ### E-Mail-Versand **Helper-Funktion (`backend/routers/auth.py`):** ```python def send_email(to_email: str, subject: str, body: str): """Send email via SMTP (reusable helper).""" try: smtp_host = os.getenv("SMTP_HOST") smtp_port = int(os.getenv("SMTP_PORT", 587)) smtp_user = os.getenv("SMTP_USER") smtp_pass = os.getenv("SMTP_PASS") smtp_from = os.getenv("SMTP_FROM", "noreply@jinkendo.de") if not smtp_host or not smtp_user or not smtp_pass: print("SMTP not configured, skipping email") return False msg = MIMEText(body) msg['Subject'] = subject msg['From'] = smtp_from msg['To'] = to_email with smtplib.SMTP(smtp_host, smtp_port) as server: server.starttls() server.login(smtp_user, smtp_pass) server.send_message(msg) return True except Exception as e: print(f"Email error: {e}") return False ``` ### E-Mail-Templates **Registrierung (Verifizierung):** ``` Betreff: Willkommen bei Mitai Jinkendo – E-Mail bestätigen Hallo {name}, willkommen bei Mitai Jinkendo! Bitte bestätige deine E-Mail-Adresse um die Registrierung abzuschließen: https://mitai.jinkendo.de/verify?token={verification_token} Der Link ist 24 Stunden gültig. Dein Mitai Jinkendo Team ``` **Passwort-Reset:** ``` Betreff: Passwort zurücksetzen – Mitai Jinkendo Hallo {name}, du hast einen Passwort-Reset angefordert. Reset-Link: https://mitai.jinkendo.de/reset-password?token={token} Der Link ist 1 Stunde gültig. Falls du diese Anfrage nicht gestellt hast, ignoriere diese E-Mail. Dein Mitai Jinkendo Team ``` --- ## Bekannte Sicherheitsentscheidungen ### 1. Profile-ID aus Session, nie aus Header **❌ Falsch (Sicherheitslücke):** ```python @router.get("/weight") def list_weight(x_profile_id: str = Header(default=None), session=Depends(require_auth)): profile_id = x_profile_id # User könnte beliebige ID senden! ``` **✅ Richtig:** ```python @router.get("/weight") def list_weight(session: dict = Depends(require_auth)): profile_id = session['profile_id'] # Immer aus validierter Session ``` **Grund:** Session ist an Token gebunden → User kann nur eigene Daten abrufen ### 2. E-Mail-Enumeration-Schutz **Problem:** Registrierung könnte verraten ob E-Mail bereits existiert **Lösung:** ```python if cur.fetchone(): raise HTTPException(400, "E-Mail-Adresse bereits registriert") ``` **Passwort-Reset:** ```python if not prof: # Don't reveal if email exists return {"ok": True, "message": "Falls die E-Mail existiert, wurde ein Link gesendet."} ``` **Trade-off:** Registrierung gibt Info preis (UX > Sicherheit), Reset nicht (Sicherheit > UX) ### 3. Reset-Token-Präfix **Problem:** Reset-Token könnte mit regulären Session-Token kollidieren **Lösung:** ```python cur.execute("INSERT INTO sessions (token, ...) VALUES (%s, ...)", (f"reset_{token}", ...)) ``` **Vorteil:** Eindeutige Identifikation, kein Risiko von Kollisionen ### 4. Keine Passwort-Komplexitäts-Prüfung **Entscheidung:** Nur Mindestlänge (8 Zeichen), keine Sonderzeichen-Pflicht **Grund:** - Länge > Komplexität (NIST-Empfehlung) - Komplexitäts-Anforderungen führen zu schlechteren Passwörtern (z.B. `Password123!`) - Benutzerfreundlichkeit **Alternative:** Passwort-Strength-Meter im Frontend (geplant) ### 5. In-Memory Rate Limiting **Problem:** Rate Limits resetten bei Server-Neustart **Akzeptiert weil:** - Redis-Overhead für Self-Hosting zu hoch - Server-Neustarts selten (<1x pro Woche) - Bei Neustart = Attack-Vektoren resetten sich auch (DDoS-Protection) **Geplant (v10+):** Redis-Integration optional --- ## Zusammenfassung: Auth-Flow ``` ┌─────────────────────────────────────────────────────────────┐ │ 1. Login (POST /api/auth/login) │ │ ↓ E-Mail + Passwort │ │ ↓ verify_pin(password, pin_hash) │ │ ↓ SHA256 → bcrypt Migration (falls nötig) │ │ ↓ Token generieren (secrets.token_urlsafe(32)) │ │ ↓ INSERT INTO sessions (token, profile_id, expires_at) │ │ → Return {token, profile_id, role, expires_at} │ └─────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────┐ │ 2. Frontend speichert Token │ │ localStorage.setItem('bodytrack_token', token) │ └─────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────┐ │ 3. Nachfolgende Requests │ │ GET /api/weight │ │ Headers: X-Auth-Token: {token} │ └─────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────┐ │ 4. Backend validiert Token (require_auth) │ │ ↓ SELECT FROM sessions WHERE token=... AND expires_at>NOW│ │ ↓ JOIN profiles ON profile_id │ │ → Return session dict {profile_id, role, ai_enabled, ...}│ └─────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────┐ │ 5. Endpoint-Logik │ │ profile_id = session['profile_id'] │ │ data = SELECT FROM weight_log WHERE profile_id=... │ │ → Return data │ └─────────────────────────────────────────────────────────────┘ ``` **Sicherheits-Highlights:** - ✅ bcrypt mit Auto-Salting - ✅ SHA256 → bcrypt Migration (transparent) - ✅ Rate Limiting (5/min Login, 3/hour Register) - ✅ E-Mail-Verifizierung (24h Token) - ✅ Passwort-Reset (1h Token via E-Mail) - ✅ Session-Expiry (30 Tage) - ✅ Admin-Role-Check (require_admin) - ✅ Profile-ID-Isolation (immer aus Session) - ✅ CORS-Whitelisting (Production) **Bekannte Limitationen:** - In-Memory Rate Limiting (Reset bei Server-Neustart) - Keine 2FA (geplant für v10+) - Keine Passwort-Strength-Meter (geplant) - Keine Session-Revocation-UI (nur via DB)