- .gitignore: .claude/docs, rules, commands tracken; settings.local weiter ignorieren - DOCUMENTATION.md: verbindliche Ablage functional/technical/working/issues - .claude/README.md: Agent-Einstieg; GITEA_ISSUES_INDEX aus MCP (Stand 2026-04-08) - Arbeitspapiere von docs/ nach .claude/docs/working/ verschoben - docs/MEMBERSHIP_SYSTEM.md als Stub; kanonisch technical/MEMBERSHIP_SYSTEM.md - CLAUDE.md Pflichtlektüre und Links angepasst; docs/README.md vereinfacht Made-with: Cursor
26 KiB
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:
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:
{
"email": "user@example.com",
"password": "geheim123"
}
Backend-Logik (backend/routers/auth.py):
@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:
{
"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:
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:
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:
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:
@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:
{
'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:
{
"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:
@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:
{
"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}:
@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):
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:
{
"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:
{
"email": "max@example.com"
}
Backend:
@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:
{
"token": "jT9z3xK...pQ2vL",
"new_password": "neuesPasswort123"
}
Backend:
@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
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:
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:
@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:
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):
import bcrypt
def hash_pin(pin: str) -> str:
"""Hash password with bcrypt."""
return bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode()
Verifizierung:
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:
# 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:
- User loggt sich mit altem Passwort ein
- Backend verifiziert gegen SHA256-Hash (erfolgreich)
- Backend erstellt bcrypt-Hash vom Klartext-Passwort
- SHA256-Hash wird in DB durch bcrypt-Hash ersetzt
- 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:
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:
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:
ALLOWED_ORIGINS: https://mitai.jinkendo.de
main.py:
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:
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:
@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:
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:
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):
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):
@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:
@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:
if cur.fetchone():
raise HTTPException(400, "E-Mail-Adresse bereits registriert")
Passwort-Reset:
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:
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)