from fastapi import APIRouter, Depends, HTTPException, Request, Response 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, PasswordResetConfirm, PasswordResetRequest, RefreshRequest, RegisterRequest, TokenResponse, TotpConfirmRequest, TotpDisableRequest, TotpLoginRequest, TotpSetupResponse, ) from app.schemas.user import InviteAccept, UserOut from app.services.auth_service import auth_service class ChangePasswordRequest(BaseModel): current_password: str new_password: str = Field(min_length=8) router = APIRouter(prefix="/auth", tags=["Auth"]) _COOKIE_NAME = "refresh_token" _COOKIE_PATH = "/" _COOKIE_MAX_AGE = 30 * 24 * 60 * 60 # 30 Tage def _set_refresh_cookie(response: Response, token: str) -> None: """Setzt den Refresh-Token als HttpOnly+SameSite=Strict Cookie.""" from app.core.config import settings response.set_cookie( key=_COOKIE_NAME, value=token, httponly=True, secure=settings.is_production, samesite="strict", max_age=_COOKIE_MAX_AGE, path=_COOKIE_PATH, ) def _delete_refresh_cookie(response: Response) -> None: """Löscht den Refresh-Token-Cookie.""" response.delete_cookie(key=_COOKIE_NAME, path=_COOKIE_PATH) @router.post("/register", response_model=TokenResponse, status_code=201) @limiter.limit("3/hour") async def register(request: Request, data: RegisterRequest, db: AsyncSession = Depends(get_db)): """Create a new company + admin account.""" return await auth_service.register(data, db) @router.post("/login", response_model=TokenResponse) @limiter.limit("10/minute") async def login(request: Request, response: Response, data: LoginRequest, db: AsyncSession = Depends(get_db)): result = await auth_service.login(data, db, request) if result.refresh_token: _set_refresh_cookie(response, result.refresh_token) result.refresh_token = None # Nicht im JSON-Body zurückgeben return result @router.post("/refresh", response_model=TokenResponse) @limiter.limit("30/minute") async def refresh(request: Request, response: Response, data: RefreshRequest | None = None, db: AsyncSession = Depends(get_db)): # Body-Token hat Vorrang wenn explizit angegeben (API-Clients, Tests, Replay-Detection) # Cookie als Fallback für Browser-Clients token = (data.refresh_token if data and data.refresh_token else None) or request.cookies.get(_COOKIE_NAME) if not token: raise HTTPException(status_code=401, detail="Kein Refresh-Token") result = await auth_service.refresh(token, db) if result.refresh_token: _set_refresh_cookie(response, result.refresh_token) result.refresh_token = None # Nicht im JSON-Body zurückgeben return result @router.post("/logout", response_model=MessageResponse) @limiter.limit("60/minute") async def logout(request: Request, response: Response, data: RefreshRequest | None = None, db: AsyncSession = Depends(get_db)): # Body-Token hat Vorrang wenn explizit angegeben, Cookie als Fallback token = (data.refresh_token if data and data.refresh_token else None) or request.cookies.get(_COOKIE_NAME) if token: await auth_service.logout(token, db) _delete_refresh_cookie(response) return MessageResponse(message="Logged out successfully") @router.post("/password-reset", response_model=MessageResponse) @limiter.limit("3/hour") async def request_password_reset(request: Request, data: PasswordResetRequest, db: AsyncSession = Depends(get_db)): result = await auth_service.request_password_reset(data.email, db) if result == "ldap": from fastapi import HTTPException raise HTTPException( status_code=400, detail="Dein Konto wird über LDAP verwaltet. Bitte setze dein Passwort direkt beim LDAP-Administrator zurück.", ) return MessageResponse(message="Falls diese E-Mail-Adresse registriert ist, wurde ein Reset-Link verschickt.") @router.post("/password-reset/confirm", response_model=MessageResponse) @limiter.limit("5/hour") async def confirm_password_reset(request: Request, data: PasswordResetConfirm, db: AsyncSession = Depends(get_db)): await auth_service.confirm_password_reset(data.token, data.new_password, db) return MessageResponse(message="Password updated successfully") @router.post("/invite/accept", response_model=UserOut) @limiter.limit("10/hour") async def accept_invite(request: Request, data: InviteAccept, db: AsyncSession = Depends(get_db)): from app.services.user_service import user_service user = await user_service.accept_invite(data, db) return UserOut.model_validate(user) @router.get("/me", response_model=UserOut) async def me(current_user: CurrentUser): return UserOut.model_validate(current_user) @router.post("/change-password", response_model=MessageResponse) async def change_password( data: ChangePasswordRequest, current_user: CurrentUser, db: AsyncSession = Depends(get_db), ): """Passwort ändern (eingeloggter User, benötigt aktuelles Passwort).""" if not verify_password(data.current_password, current_user.password_hash): raise HTTPException(status_code=400, detail="Aktuelles Passwort ist falsch") import re if not re.search(r'[A-Z]', data.new_password) or not re.search(r'[0-9]', data.new_password): raise HTTPException( status_code=400, 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.""" # Fix K-4: Verhindere Überschreiben eines aktiven TOTP-Secrets mit gestohlenem Access-Token if current_user.totp_enabled: raise HTTPException( status_code=400, detail="TOTP ist bereits aktiv. Bitte zuerst deaktivieren (POST /auth/totp/disable).", ) import pyotp secret = pyotp.random_base32() issuer = "TimeMaster" label = current_user.email uri = pyotp.totp.TOTP(secret).provisioning_uri(name=label, issuer_name=issuer) # 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) @router.post("/totp/setup/save", response_model=MessageResponse) async def totp_setup_save( current_user: CurrentUser, db: AsyncSession = Depends(get_db), ): """Speichert das generierte Secret temporär (ohne Aktivierung).""" import pyotp if not current_user.totp_secret: secret = pyotp.random_base32() current_user.totp_secret = encrypt_value(secret) await db.commit() return MessageResponse(message="Secret gespeichert") @router.post("/totp/confirm", response_model=MessageResponse) async def totp_confirm( data: TotpConfirmRequest, current_user: CurrentUser, db: AsyncSession = Depends(get_db), ): """Bestätigt den ersten TOTP-Code und aktiviert 2FA.""" import pyotp plain_secret = _totp_plain(current_user) if not plain_secret: raise HTTPException(400, "Kein TOTP-Secret vorhanden. Zuerst /totp/setup aufrufen.") 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") @router.post("/totp/disable", response_model=MessageResponse) async def totp_disable( data: TotpDisableRequest, current_user: CurrentUser, db: AsyncSession = Depends(get_db), ): """Deaktiviert TOTP. Benötigt aktuelles Passwort + gültigen TOTP-Code.""" import pyotp if not verify_password(data.password, current_user.password_hash or ""): raise HTTPException(400, "Passwort falsch") if not current_user.totp_enabled or not current_user.totp_secret: raise HTTPException(400, "2FA ist nicht aktiv") 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") TOTP_MAX_ATTEMPTS = 5 TOTP_LOCKOUT_SECONDS = 900 # 15 Minuten async def _check_totp_lockout(user_id: str, redis) -> None: """Wirft HTTP 429 wenn TOTP-Login wegen zu vieler Fehlversuche gesperrt ist.""" key = f"totp_lockout:{user_id}" if await redis.exists(key): ttl = await redis.ttl(key) wait_min = ttl // 60 + 1 raise HTTPException(429, detail=f"TOTP gesperrt. Bitte {wait_min} Minute(n) warten.") async def _record_totp_failure(user_id: str, redis) -> None: """Zählt TOTP-Fehlversuch und setzt Lockout nach TOTP_MAX_ATTEMPTS Fehlversuchen.""" fail_key = f"totp_fails:{user_id}" lock_key = f"totp_lockout:{user_id}" fails = await redis.incr(fail_key) await redis.expire(fail_key, TOTP_LOCKOUT_SECONDS) if fails >= TOTP_MAX_ATTEMPTS: await redis.set(lock_key, "1", ex=TOTP_LOCKOUT_SECONDS) await redis.delete(fail_key) async def _clear_totp_failures(user_id: str, redis) -> None: """Löscht TOTP-Fehlversuche nach erfolgreichem Login.""" await redis.delete(f"totp_fails:{user_id}") await redis.delete(f"totp_lockout:{user_id}") @router.post("/totp/login", response_model=TokenResponse) @limiter.limit("10/minute") async def totp_login( request: Request, response: Response, data: TotpLoginRequest, db: AsyncSession = Depends(get_db), ): """Zweiter Login-Schritt: partial_token + TOTP-Code → volle Tokens.""" import pyotp import redis.asyncio as aioredis from uuid import UUID from app.core.config import settings from app.core.security import decode_partial_token from app.models.user import User from jose import JWTError try: user_id = decode_partial_token(data.partial_token) except JWTError: raise HTTPException(401, "Ungültiger oder abgelaufener Token") user = await db.get(User, UUID(user_id)) if not user or not user.is_active: raise HTTPException(401, "Benutzer nicht gefunden") if not user.totp_enabled or not user.totp_secret: raise HTTPException(400, "2FA nicht aktiv") redis_client = aioredis.from_url(settings.redis_url, decode_responses=True) try: # M-5: Lockout-Check vor TOTP-Verifikation await _check_totp_lockout(user_id, redis_client) plain_secret = _totp_plain(user) totp = pyotp.TOTP(plain_secret or "") if not totp.verify(data.code, valid_window=1): # M-5: Fehlversuch zählen await _record_totp_failure(user_id, redis_client) raise HTTPException(400, "Ungültiger Code") # M-5: Erfolg → Fehlversuche zurücksetzen await _clear_totp_failures(user_id, redis_client) finally: await redis_client.aclose() from datetime import datetime, timezone user.last_login = datetime.now(timezone.utc) result = await auth_service._create_session(user, db, request=request) if result.refresh_token: _set_refresh_cookie(response, result.refresh_token) result.refresh_token = None # Nicht im JSON-Body zurückgeben return result