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:
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user