"""Login-Flow: Passwort -> Pflicht-TOTP -> Session-Cookie (siehe Konzept 4.5/6.2).""" from __future__ import annotations import base64 import io import secrets from datetime import datetime, timedelta, timezone import qrcode from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from app.auth.deps import CurrentUser, get_current_user from app.db import get_db from app.models.schemas import ( ChangePasswordRequest, LoginRequest, TotpConfirmRequest, TotpLoginRequest, ) from app.security.audit import write_audit_event from app.security.passwords import hash_password, needs_rehash, verify_password from app.security.pending_totp import create_pending_token, decode_pending_token from app.security.rate_limit import login_rate_limiter from app.security.sessions import SESSION_COOKIE_NAME, create_session_token from app.security.totp import ( decrypt_totp_secret, encrypt_totp_secret, generate_recovery_codes, generate_totp_secret, hash_recovery_code, provisioning_uri, verify_totp_code, ) router = APIRouter(prefix="/auth", tags=["auth"]) # Konstante Dummy-Hash-Verifikation gegen Username-Enumeration per Timing-Seitenkanal. _DUMMY_HASH = hash_password(secrets.token_hex(16)) def _client_ip(request: Request) -> str: # Nur die direkte Peer-IP; X-Forwarded-For wird ausschliesslich vertrauenswuerdig # ausgewertet, wenn nginx mit set_real_ip_from konfiguriert ist (Konzept 7.2a). return request.client.host if request.client else "unknown" @router.post("/login") async def login(payload: LoginRequest, request: Request): ip = _client_ip(request) if not login_rate_limiter.allow(ip): raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Anmeldeversuche, bitte warten.") conn = get_db() cursor = await conn.execute( "SELECT id, password_hash, is_active, failed_logins, locked_until, totp_enrolled " "FROM users WHERE username = ?", (payload.username,), ) row = await cursor.fetchone() if row is None: verify_password(_DUMMY_HASH, payload.password) # Timing angleichen await write_audit_event( conn, event_type="login_failed", user_id=None, client_ip=ip, details={"reason": "unknown_user", "username": payload.username}, ) await conn.commit() raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Benutzername oder Passwort falsch") user_id, pw_hash, is_active, failed_logins, locked_until, totp_enrolled = row if locked_until and locked_until > datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"): await write_audit_event( conn, event_type="login_failed", user_id=user_id, client_ip=ip, details={"reason": "locked"}, ) await conn.commit() raise HTTPException(status.HTTP_423_LOCKED, "Konto vorruebergehend gesperrt") if not is_active or not verify_password(pw_hash, payload.password): new_failed = failed_logins + 1 # Bewusst OHNE dynamisch zusammengesetztes SQL (kein f-String mit # Query-Fragmenten, auch wenn hier nie Nutzereingaben einfliessen) -- # zwei feste, vollstaendig parametrisierte Statements statt eines # "SQL-Query-Building"-Musters, das Scanner (z.B. bandit B608) und # Reviewer sonst jedes Mal erneut pruefen muessten. if new_failed >= 5: delay_s = 30 * (2 ** min(new_failed - 5, 6)) # progressive Verzoegerung, gedeckelt locked_until_ts = (datetime.now(timezone.utc) + timedelta(seconds=delay_s)).strftime( "%Y-%m-%dT%H:%M:%S.%fZ" ) await conn.execute( "UPDATE users SET failed_logins = ?, locked_until = ? WHERE id = ?", (new_failed, locked_until_ts, user_id), ) else: await conn.execute( "UPDATE users SET failed_logins = ? WHERE id = ?", (new_failed, user_id) ) await write_audit_event( conn, event_type="login_failed", user_id=user_id, client_ip=ip, details={"reason": "bad_password", "failed_logins": new_failed}, ) await conn.commit() raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Benutzername oder Passwort falsch") await conn.execute( "UPDATE users SET failed_logins = 0, locked_until = NULL WHERE id = ?", (user_id,) ) if needs_rehash(pw_hash): await conn.execute( "UPDATE users SET password_hash = ? WHERE id = ?", (hash_password(payload.password), user_id), ) await write_audit_event( conn, event_type="login_password_ok", user_id=user_id, client_ip=ip, details={} ) await conn.commit() pending_token = create_pending_token(user_id) return {"pending_token": pending_token, "totp_enrolled": bool(totp_enrolled)} @router.post("/totp/enroll/start") async def totp_enroll_start(body: dict, request: Request): """Erster Schritt der TOTP-Pflicht-Einrichtung (nur wenn noch nicht enrolled).""" pending_token = body.get("pending_token", "") user_id = decode_pending_token(pending_token) if user_id is None: raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Ungueltiges oder abgelaufenes Token") conn = get_db() cursor = await conn.execute( "SELECT username, totp_enrolled FROM users WHERE id = ?", (user_id,) ) row = await cursor.fetchone() if row is None: raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Ungueltig") username, totp_enrolled = row if totp_enrolled: raise HTTPException(status.HTTP_400_BAD_REQUEST, "TOTP ist bereits eingerichtet") secret = generate_totp_secret() await conn.execute( "UPDATE users SET totp_secret_enc = ? WHERE id = ?", (encrypt_totp_secret(secret), user_id), ) await write_audit_event( conn, event_type="totp_enroll_started", user_id=user_id, client_ip=_client_ip(request), details={}, ) await conn.commit() uri = provisioning_uri(secret, username) qr_img = qrcode.make(uri) buf = io.BytesIO() qr_img.save(buf, format="PNG") qr_b64 = base64.b64encode(buf.getvalue()).decode() return { "provisioning_uri": uri, "qr_png_base64": qr_b64, "recovery_codes_hint": "Recovery-Codes werden erst nach erfolgreicher Bestaetigung angezeigt.", } @router.post("/totp/enroll/confirm") async def totp_enroll_confirm(body: dict, request: Request, response: Response): pending_token = body.get("pending_token", "") code = str(body.get("code", "")) user_id = decode_pending_token(pending_token) if user_id is None: raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Ungueltiges oder abgelaufenes Token") conn = get_db() cursor = await conn.execute( "SELECT totp_secret_enc, session_version FROM users WHERE id = ?", (user_id,) ) row = await cursor.fetchone() if row is None or row[0] is None: raise HTTPException(status.HTTP_400_BAD_REQUEST, "TOTP-Einrichtung wurde nicht gestartet") secret = decrypt_totp_secret(row[0]) if not verify_totp_code(secret, code): await write_audit_event( conn, event_type="totp_enroll_failed", user_id=user_id, client_ip=_client_ip(request), details={}, ) await conn.commit() raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Code ungueltig") recovery_codes = generate_recovery_codes() await conn.execute("UPDATE users SET totp_enrolled = 1 WHERE id = ?", (user_id,)) for rc in recovery_codes: await conn.execute( "INSERT INTO recovery_codes (user_id, code_hash) VALUES (?, ?)", (user_id, hash_recovery_code(rc)), ) await write_audit_event( conn, event_type="totp_enroll_confirmed", user_id=user_id, client_ip=_client_ip(request), details={}, ) await conn.commit() token = create_session_token(user_id, row[1]) response.set_cookie( SESSION_COOKIE_NAME, token, httponly=True, secure=True, samesite="strict", path="/" ) return {"recovery_codes": recovery_codes} @router.post("/login/totp") async def login_totp(payload: TotpLoginRequest, request: Request, response: Response): user_id = decode_pending_token(payload.pending_token) if user_id is None: raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Ungueltiges oder abgelaufenes Token") conn = get_db() cursor = await conn.execute( "SELECT totp_secret_enc, totp_enrolled, session_version FROM users WHERE id = ?", (user_id,), ) row = await cursor.fetchone() if row is None or not row[1]: raise HTTPException(status.HTTP_400_BAD_REQUEST, "TOTP nicht eingerichtet") secret_enc, _enrolled, session_version = row ip = _client_ip(request) ok = verify_totp_code(decrypt_totp_secret(secret_enc), payload.code) if not ok: # Recovery-Code als Fallback pruefen. code_hash = hash_recovery_code(payload.code) rc_cursor = await conn.execute( "SELECT id FROM recovery_codes WHERE user_id = ? AND code_hash = ? AND used_at IS NULL", (user_id, code_hash), ) rc_row = await rc_cursor.fetchone() if rc_row is not None: await conn.execute( "UPDATE recovery_codes SET used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", (rc_row[0],), ) await write_audit_event( conn, event_type="login_recovery_code_used", user_id=user_id, client_ip=ip, details={} ) ok = True if not ok: await write_audit_event( conn, event_type="login_totp_failed", user_id=user_id, client_ip=ip, details={} ) await conn.commit() raise HTTPException(status.HTTP_401_UNAUTHORIZED, "TOTP-Code ungueltig") await write_audit_event( conn, event_type="login_success", user_id=user_id, client_ip=ip, details={} ) await conn.commit() token = create_session_token(user_id, session_version) response.set_cookie( SESSION_COOKIE_NAME, token, httponly=True, secure=True, samesite="strict", path="/" ) return {"status": "ok"} @router.post("/logout") async def logout(request: Request, response: Response, user: CurrentUser = Depends(get_current_user)): conn = get_db() await write_audit_event( conn, event_type="logout", user_id=user.id, client_ip=_client_ip(request), details={} ) await conn.commit() response.delete_cookie(SESSION_COOKIE_NAME, path="/") return {"status": "ok"} @router.post("/logout-everywhere") async def logout_everywhere( request: Request, response: Response, user: CurrentUser = Depends(get_current_user) ): """Invalidiert alle ausgestellten Session-Cookies dieses Users sofort.""" conn = get_db() await conn.execute( "UPDATE users SET session_version = session_version + 1 WHERE id = ?", (user.id,) ) await write_audit_event( conn, event_type="logout_everywhere", user_id=user.id, client_ip=_client_ip(request), details={} ) await conn.commit() response.delete_cookie(SESSION_COOKIE_NAME, path="/") return {"status": "ok"} @router.post("/change-password") async def change_password( payload: ChangePasswordRequest, request: Request, response: Response, user: CurrentUser = Depends(get_current_user), ): conn = get_db() cursor = await conn.execute("SELECT password_hash, session_version FROM users WHERE id = ?", (user.id,)) row = await cursor.fetchone() if row is None or not verify_password(row[0], payload.current_password): raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Aktuelles Passwort falsch") new_hash = hash_password(payload.new_password) new_version = row[1] + 1 # invalidiert alle anderen laufenden Sessions dieses Users await conn.execute( "UPDATE users SET password_hash = ?, session_version = ?, must_change_password = 0, " "password_changed_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", (new_hash, new_version, user.id), ) await write_audit_event( conn, event_type="password_changed", user_id=user.id, client_ip=_client_ip(request), details={} ) await conn.commit() token = create_session_token(user.id, new_version) response.set_cookie( SESSION_COOKIE_NAME, token, httponly=True, secure=True, samesite="strict", path="/" ) return {"status": "ok"} @router.get("/me") async def me(user: CurrentUser = Depends(get_current_user)): tenant_admin_of = [] if user.tenant_admin_ids: conn = get_db() placeholders = ",".join("?" for _ in user.tenant_admin_ids) cursor = await conn.execute( f"SELECT id, name FROM tenants WHERE id IN ({placeholders}) ORDER BY name", tuple(user.tenant_admin_ids), ) tenant_admin_of = [{"id": r[0], "name": r[1]} for r in await cursor.fetchall()] return { "id": user.id, "username": user.username, "is_admin": user.is_admin, "is_any_admin": user.is_any_admin, "tenant_admin_of": tenant_admin_of, }