- .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
905 lines
26 KiB
Markdown
905 lines
26 KiB
Markdown
# 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)
|