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

26 KiB
Raw Blame 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:

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:

  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:

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)