mitai-jinkendo/.claude/docs/technical/AUTH.md

905 lines
26 KiB
Markdown
Raw Normal View History

# 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)