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