security: 9 Findings aus Security-Audit behoben (CRITICAL + HIGH + MEDIUM)

CRITICAL:
- C-1: LDAP tls_verify Default False → True (MITM-Schutz)
- C-2: TOTP-Secret Fernet-verschlüsselt in DB (statt Plaintext)
  - core/crypto.py: encrypt_value() / decrypt_value() helper
  - Migration 0026: totp_secret VARCHAR(64→500), ldap tls_verify default=true
  - _totp_plain() helper mit Legacy-Fallback für bestehende Werte

HIGH:
- H-1: Kiosk Nonce-Cache asyncio.Lock (Race Condition behoben)
- H-2: File-Upload-Limit 10 MB (import_kimai.py + users.py CSV-Import)
- H-3: CORS allow_methods/allow_headers explizit eingeschränkt (war *)
- H-4: TrustedHostMiddleware aktiviert wenn ALLOWED_HOSTS gesetzt

MEDIUM:
- M-1: IP-Logging nutzt X-Forwarded-For hinter nginx-Proxy
- M-4: Audit-Log für password_changed, totp_enabled, totp_disabled
- M-5: CalDAV verify_ssl in Production erzwungen (_effective_verify_ssl)

152/152 Tests grün

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 19:45:09 +02:00
parent a639de13f8
commit 62c4e742ab
12 changed files with 319 additions and 31 deletions
+44 -7
View File
@@ -2,10 +2,12 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.crypto import decrypt_value, encrypt_value
from app.core.database import get_db
from app.core.dependencies import CurrentUser
from app.core.limiter import limiter
from app.core.security import hash_password, verify_password
from app.models.audit_log import AuditLog
from app.schemas.auth import (
LoginRequest,
MessageResponse,
@@ -103,12 +105,30 @@ async def change_password(
detail="Neues Passwort muss mindestens 1 Großbuchstaben und 1 Zahl enthalten"
)
current_user.password_hash = hash_password(data.new_password)
db.add(AuditLog(
company_id=current_user.company_id,
user_id=current_user.id,
action="password_changed",
entity_type="user",
entity_id=current_user.id,
))
await db.commit()
return MessageResponse(message="Passwort erfolgreich geändert")
# ── TOTP / 2FA ────────────────────────────────────────────────────────────────
def _totp_plain(user) -> str | None:
"""Gibt das entschlüsselte TOTP-Secret zurück, oder None."""
if not user.totp_secret:
return None
try:
return decrypt_value(user.totp_secret)
except ValueError:
# Fallback: Secret war noch im Klartext (Legacy-Daten vor 0026-Migration)
return user.totp_secret
@router.post("/totp/setup", response_model=TotpSetupResponse)
async def totp_setup(current_user: CurrentUser):
"""Generiert ein neues TOTP-Secret und gibt die otpauth-URI zurück. Noch nicht aktiviert."""
@@ -117,8 +137,8 @@ async def totp_setup(current_user: CurrentUser):
issuer = "TimeMaster"
label = current_user.email
uri = pyotp.totp.TOTP(secret).provisioning_uri(name=label, issuer_name=issuer)
# Secret temporär im User speichern (noch nicht totp_enabled)
current_user.totp_secret = secret
# Secret Fernet-verschlüsselt speichern (noch nicht totp_enabled)
current_user.totp_secret = encrypt_value(secret)
# Hinweis: DB-Commit passiert NICHT hier erst nach verify in /totp/confirm
# Damit das Secret nicht verloren geht, sofort speichern
return TotpSetupResponse(secret=secret, otpauth_uri=uri)
@@ -133,7 +153,7 @@ async def totp_setup_save(
import pyotp
if not current_user.totp_secret:
secret = pyotp.random_base32()
current_user.totp_secret = secret
current_user.totp_secret = encrypt_value(secret)
await db.commit()
return MessageResponse(message="Secret gespeichert")
@@ -146,12 +166,20 @@ async def totp_confirm(
):
"""Bestätigt den ersten TOTP-Code und aktiviert 2FA."""
import pyotp
if not current_user.totp_secret:
plain_secret = _totp_plain(current_user)
if not plain_secret:
raise HTTPException(400, "Kein TOTP-Secret vorhanden. Zuerst /totp/setup aufrufen.")
totp = pyotp.TOTP(current_user.totp_secret)
totp = pyotp.TOTP(plain_secret)
if not totp.verify(data.code, valid_window=1):
raise HTTPException(400, "Ungültiger Code")
current_user.totp_enabled = True
db.add(AuditLog(
company_id=current_user.company_id,
user_id=current_user.id,
action="totp_enabled",
entity_type="user",
entity_id=current_user.id,
))
await db.commit()
return MessageResponse(message="Zwei-Faktor-Authentifizierung aktiviert")
@@ -168,11 +196,19 @@ async def totp_disable(
raise HTTPException(400, "Passwort falsch")
if not current_user.totp_enabled or not current_user.totp_secret:
raise HTTPException(400, "2FA ist nicht aktiv")
totp = pyotp.TOTP(current_user.totp_secret)
plain_secret = _totp_plain(current_user)
totp = pyotp.TOTP(plain_secret or "")
if not totp.verify(data.code, valid_window=1):
raise HTTPException(400, "Ungültiger TOTP-Code")
current_user.totp_enabled = False
current_user.totp_secret = None
db.add(AuditLog(
company_id=current_user.company_id,
user_id=current_user.id,
action="totp_disabled",
entity_type="user",
entity_id=current_user.id,
))
await db.commit()
return MessageResponse(message="Zwei-Faktor-Authentifizierung deaktiviert")
@@ -202,7 +238,8 @@ async def totp_login(
if not user.totp_enabled or not user.totp_secret:
raise HTTPException(400, "2FA nicht aktiv")
totp = pyotp.TOTP(user.totp_secret)
plain_secret = _totp_plain(user)
totp = pyotp.TOTP(plain_secret or "")
if not totp.verify(data.code, valid_window=1):
raise HTTPException(400, "Ungültiger Code")