mitai-jinkendo/.claude/docs/technical/AUTH.md
Lars 7940dc7560 docs: Struktur .claude/docs versionieren, working/, Gitea-Index, Regeln
- .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
2026-04-08 13:01:49 +02:00

905 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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