# backend/auth/router.py # All /api/auth/* endpoints: login, logout, refresh, me, change-password, # and admin user CRUD. import base64 import binascii import logging import os import re from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response from pydantic import BaseModel, validator from backend.ratelimit import is_account_rate_limited, is_rate_limited from backend.ratelimit import record_account_failure as rl_record_account_failure from backend.ratelimit import record_account_success as rl_record_account_success from backend.ratelimit import record_failure as rl_record_failure from backend.ratelimit import record_success as rl_record_success from backend.services.net import get_client_ip, is_trusted_proxy from .jwt_handler import ( ACCESS_TOKEN_EXPIRE_SECONDS, API_TOKEN_EXPIRY_CHOICES, create_access_token, create_api_token, create_refresh_token, decode_token, delete_api_token, is_token_revoked, list_api_tokens, revoke_token, ) from .mfa import ( generate_qr_uri, generate_recovery_codes, generate_secret, hash_recovery_code, verify_recovery_code, verify_totp, ) from .middleware import is_auth_enabled, require_admin, require_auth from .password import hash_password, validate_password_strength, verify_password from .user_store import ( create_user, delete_user, get_all_users, get_user, has_users, is_locked, record_login_failure, record_login_success, update_user, ) logger = logging.getLogger("obsigate.auth.router") router = APIRouter(prefix="/api/auth", tags=["auth"]) def is_secure_cookies(request: Request | None = None) -> bool: """True when auth cookies must carry the ``Secure`` flag (#87 T3/T8). ``OBSIGATE_SECURE_COOKIES=true|false|auto`` (défaut : ``auto``) : ``true``/``false`` forcent le comportement ; ``auto`` met ``Secure`` si la requête arrive en https (production derrière TLS) et l'omet sinon (dev local en http — les navigateurs jettent les cookies ``Secure`` sur http, ce qui casserait silencieusement les logins localhost). Derrière un reverse proxy qui termine TLS, le schéma perçu est http : avec ``OBSIGATE_TRUST_PROXY=true``, ``X-Forwarded-Proto`` est honoré (même garde que ``get_client_ip``, BUG-030). """ forced = os.environ.get("OBSIGATE_SECURE_COOKIES", "auto").lower() if forced in ("1", "true", "yes", "on"): return True if forced in ("0", "false", "no", "off"): return False if request is None: return False if request.url.scheme == "https": return True if is_trusted_proxy(): proto = request.headers.get("x-forwarded-proto", "").split(",")[0].strip().lower() if proto == "https": return True return False # ── Pydantic request models ────────────────────────────────────────── class LoginRequest(BaseModel): username: str password: str remember_me: bool = False # True → refresh token 30d instead of 7d class ChangePasswordRequest(BaseModel): current_password: str new_password: str @validator("new_password") def password_strength(cls, v): return validate_password_strength(v) class CreateUserRequest(BaseModel): username: str password: str display_name: str | None = None role: str = "user" vaults: list[str] = [] @validator("password") def password_valid(cls, v): return validate_password_strength(v) @validator("username") def username_valid(cls, v): if not re.match(r"^[a-zA-Z0-9_-]{2,32}$", v): raise ValueError("2-32 caractères alphanumériques, _ ou -") return v.lower() @validator("role") def role_valid(cls, v): if v not in ("admin", "user"): raise ValueError("Rôle invalide") return v class UpdateUserRequest(BaseModel): display_name: str | None = None vaults: list[str] | None = None active: bool | None = None password: str | None = None role: str | None = None @validator("password") def password_valid(cls, v): if v is None: return v return validate_password_strength(v) # ── Profile avatar (#113) ─────────────────────────────────────────── #: Avatar data-URL pattern — PNG/JPEG/WebP only (no SVG: XSS surface). _AVATAR_DATA_URL_RE = re.compile( r"^data:image/(?:png|jpeg|webp);base64,[A-Za-z0-9+/]+={0,2}$" ) #: ~300 KB of base64 payload (a 256px JPEG is ~15 KB; generous headroom). _AVATAR_MAX_CHARS = 400_000 def _validate_avatar(data_url: str) -> str | None: """Validate an avatar data-URL for storage on the user profile. Returns the normalized data-URL, or ``None`` when clearing the avatar (empty string). Raises ``HTTPException(400)`` on anything else. """ if data_url == "": return None if len(data_url) > _AVATAR_MAX_CHARS: raise HTTPException(400, "Avatar image too large") if not _AVATAR_DATA_URL_RE.match(data_url): raise HTTPException(400, "Avatar must be a PNG, JPEG or WebP data URL") try: raw = base64.b64decode(data_url.split(",", 1)[1], validate=True) except (ValueError, binascii.Error) as exc: # pragma: no cover — regex guards raise HTTPException(400, "Avatar payload is not valid base64") from exc # Confirm the decoded bytes really are a supported image (magic numbers). is_png = raw.startswith(b"\x89PNG\r\n\x1a\n") is_jpeg = raw.startswith(b"\xff\xd8\xff") is_webp = ( len(raw) >= 12 and raw[:4] == b"RIFF" and raw[8:12] == b"WEBP" ) if not (is_png or is_jpeg or is_webp): raise HTTPException(400, "Avatar payload is not a PNG, JPEG or WebP image") return data_url # ── Public endpoints ────────────────────────────────────────────────── @router.get("/status") async def auth_status(): """Public endpoint: returns whether auth is enabled. The frontend uses this to decide whether to show the login screen. Also returns whether any users exist (for first-run detection). """ return { "auth_enabled": is_auth_enabled(), "has_users": has_users(), } @router.post("/login") async def login(body: LoginRequest, response: Response, request: Request): """Authenticate a user. Returns access token and sets refresh cookie. Implements timing-safe responses to prevent user enumeration: a failed login with an unknown user takes the same time as one with a known user (dummy hash is computed). BUG-039: unknown, inactive, locked and per-account rate-limited accounts all answer the same ``401`` so the HTTP status can never reveal whether an account exists. """ client_ip = get_client_ip(request) # IP-based rate limiting (10 failures / 15 min per IP). It is not # account-specific, so a 429 here cannot be used to enumerate accounts. if is_rate_limited(client_ip): raise HTTPException(429, "Trop de tentatives depuis cette adresse IP (15min)") user = get_user(body.username) # BUG-039: uniform 401 + equivalent timing for every account-state outcome. if not user or not user.get("active"): # Timing-safe: simulate hash computation to prevent user enumeration hash_password("dummy_timing_protection") raise HTTPException(401, "Identifiants invalides") # BUG-031: per-account budget still applies when the attacker rotates IPs. # Kept indistinguishable from a wrong password (BUG-039). if is_account_rate_limited(body.username) or is_locked(body.username): hash_password("dummy_timing_protection") raise HTTPException(401, "Identifiants invalides") if not verify_password(body.password, user["password_hash"]): attempts = record_login_failure(body.username) rl_attempts, rl_remaining = rl_record_failure(client_ip) rl_record_account_failure(body.username) remaining = max(0, 5 - attempts) detail = "Identifiants invalides" if 0 < remaining <= 2: detail += f" ({remaining} tentative(s) restante(s))" raise HTTPException(401, detail) # Success — clear rate limits rl_record_success(client_ip) # If MFA is enabled, don't issue token yet — require second factor if user.get("mfa_enabled"): method = "totp" if user.get("mfa_secret") else _preferred_mfa_method(user) logger.info(f"User '{body.username}' login deferred — MFA required ({method})") return { "mfa_required": True, "mfa_method": method, "username": body.username, "remember_me": body.remember_me, } return _issue_tokens(user, body.username, body.remember_me, response, request) def _issue_tokens(user: dict, username: str, remember_me: bool, response: Response, request: Request | None = None) -> dict: """Issue JWT tokens after successful authentication (password or MFA verified).""" record_login_success(username) rl_record_account_success(username) access_token = create_access_token(user) refresh_token, refresh_jti = create_refresh_token(username, remember=remember_me) max_age = 2592000 if remember_me else 604800 # 30d or 7d secure = is_secure_cookies(request) response.set_cookie( key="refresh_token", value=refresh_token, max_age=max_age, httponly=True, samesite="strict", secure=secure, path="/api/auth/refresh", ) logger.info(f"User '{username}' logged in") response.set_cookie( key="access_token", value=access_token, max_age=ACCESS_TOKEN_EXPIRE_SECONDS, httponly=True, samesite="lax", secure=secure, path="/", ) return { "access_token": access_token, # OAuth2 token_type, pas un mot de passe (B105) : "token_type": "bearer", # nosec B105 "expires_in": ACCESS_TOKEN_EXPIRE_SECONDS, "user": { "username": user["username"], "display_name": user["display_name"], "role": user["role"], "vaults": user["vaults"], "avatar": user.get("avatar"), }, } @router.post("/refresh") async def refresh_token_endpoint(request: Request, response: Response): """Renew access token via refresh token cookie. Called automatically by the frontend when the access token expires. The refresh token is rotated on every use (BUG-027) and rejected if it predates the user's last password change (BUG-028). """ refresh_tok = request.cookies.get("refresh_token") if not refresh_tok: raise HTTPException(401, "Refresh token manquant") payload = decode_token(refresh_tok) if not payload or payload.get("type") != "refresh": raise HTTPException(401, "Refresh token invalide") if is_token_revoked(payload["jti"]): raise HTTPException(401, "Session expirée, veuillez vous reconnecter") user = get_user(payload["sub"]) if not user or not user.get("active"): raise HTTPException(401, "Utilisateur introuvable ou inactif") # BUG-028: reject refresh tokens issued before the last password change. pca = user.get("password_changed_at") iat = payload.get("iat") if pca is not None and iat is not None: try: stale = int(iat) < int(float(pca)) except (TypeError, ValueError): stale = True if stale: raise HTTPException(401, "Session expirée, veuillez vous reconnecter") secure = is_secure_cookies(request) remember_me = bool(payload.get("remember", False)) # BUG-027: rotate the refresh token — the old one is now single-use. revoke_token(payload["jti"]) new_refresh_token, _new_jti = create_refresh_token(user["username"], remember=remember_me) max_age = 2592000 if remember_me else 604800 response.set_cookie( key="refresh_token", value=new_refresh_token, max_age=max_age, httponly=True, samesite="strict", secure=secure, path="/api/auth/refresh", ) new_access_token = create_access_token(user) response.set_cookie( key="access_token", value=new_access_token, max_age=ACCESS_TOKEN_EXPIRE_SECONDS, httponly=True, samesite="lax", secure=secure, path="/", ) return { "access_token": new_access_token, # OAuth2 token_type, pas un mot de passe (B105) : "token_type": "bearer", # nosec B105 "expires_in": ACCESS_TOKEN_EXPIRE_SECONDS, } @router.post("/logout") async def logout( request: Request, response: Response, ): """Logout: revoke refresh and access tokens, then delete cookies.""" refresh_tok = request.cookies.get("refresh_token") if refresh_tok: payload = decode_token(refresh_tok) if payload: try: revoke_token(payload["jti"]) except Exception: pass # token already revoked # BUG-027: revoke the access token too, otherwise it stays valid until expiry. access_tok = None auth_header = request.headers.get("authorization", "") if auth_header.lower().startswith("bearer "): access_tok = auth_header[7:].strip() if not access_tok: access_tok = request.cookies.get("access_token") if access_tok: access_payload = decode_token(access_tok) if access_payload and access_payload.get("type") == "access": try: revoke_token(access_payload["jti"]) except Exception: pass response.delete_cookie("refresh_token", path="/api/auth/refresh") response.delete_cookie("access_token", path="/") response.delete_cookie("access_token", path="/api") # just in case return {"message": "Deconnecte avec succes"} @router.get("/me") async def get_me(current_user=Depends(require_auth)): """Return current authenticated user info.""" return { "username": current_user["username"], "display_name": current_user["display_name"], "role": current_user["role"], "vaults": current_user["vaults"], "language": current_user.get("language", "fr"), "last_login": current_user.get("last_login"), "avatar": current_user.get("avatar"), } class UpdateMeRequest(BaseModel): """Fields the user can update on their own profile.""" display_name: str | None = None language: str | None = None #: Image data-URL (PNG/JPEG/WebP), or ``""`` to remove the avatar (#113). avatar: str | None = None @router.patch("/me") async def patch_me(req: UpdateMeRequest, current_user=Depends(require_auth)): """Update current user's profile fields (display_name, language, avatar).""" from .user_store import update_user updates: dict[str, object] = {} if req.display_name is not None: updates["display_name"] = req.display_name if req.language is not None: if req.language not in ("fr", "en"): raise HTTPException(400, "language must be 'fr' or 'en'") updates["language"] = req.language if req.avatar is not None: updates["avatar"] = _validate_avatar(req.avatar) if not updates: raise HTTPException(400, "No fields to update") updated = update_user(current_user["username"], updates) return { "username": updated["username"], "display_name": updated["display_name"], "role": updated["role"], "vaults": updated["vaults"], "language": updated.get("language", "fr"), "last_login": updated.get("last_login"), "avatar": updated.get("avatar"), } @router.post("/change-password") async def change_password( req: ChangePasswordRequest, response: Response, request: Request, current_user=Depends(require_auth), ): """Change own password. BUG-028: changing the password invalidates all previously issued tokens; a fresh pair is issued to keep the current session alive. """ user = get_user(current_user["username"]) assert user is not None, f"User {current_user['username']} not found" if not verify_password(req.current_password, user["password_hash"]): raise HTTPException(400, "Mot de passe actuel incorrect") update_user(current_user["username"], {"password": req.new_password}) updated = get_user(current_user["username"]) result: dict = {"message": "Mot de passe mis à jour"} if updated is not None: result.update(_issue_tokens(updated, updated["username"], False, response, request)) return result # ── MFA endpoints ──────────────────────────────────────────────────── def _enforce_mfa_rate_limit(request: Request, username: str) -> str: """Reject MFA attempts from a rate-limited IP or on a locked account. BUG-023: the second-factor endpoints were previously unprotected, making the 6-digit TOTP brute-forceable. Returns the resolved client IP. """ client_ip = get_client_ip(request) if is_rate_limited(client_ip): raise HTTPException(429, "Trop de tentatives depuis cette adresse IP (15min)") if is_account_rate_limited(username): raise HTTPException(429, "Trop de tentatives sur ce compte (15min)") if is_locked(username): raise HTTPException(429, "Compte temporairement verrouillé (15min)") return client_ip def _record_mfa_failure(client_ip: str, username: str) -> None: """Record a failed MFA attempt for the IP, the account and the lockout.""" record_login_failure(username) rl_record_failure(client_ip) rl_record_account_failure(username) class MfaVerifyRequest(BaseModel): username: str code: str remember_me: bool = False class MfaRecoveryRequest(BaseModel): username: str recovery_code: str class MfaDisableRequest(BaseModel): password: str code: str class MfaEnableRequest(BaseModel): code: str @router.post("/mfa/totp/setup") async def mfa_totp_setup(current_user=Depends(require_auth)): """Generate a TOTP secret and QR URI for MFA setup. Returns the secret, the otpauth URI and a ready-to-display QR code (`qr_data_url`, SVG `data:` URI — no third-party service, CSP-safe). Does NOT enable MFA yet; call /mfa/totp/enable after first successful verify. """ from .user_store import update_user secret = generate_secret() qr_uri = generate_qr_uri(secret, current_user["username"]) # Store secret temporarily (not yet enabled) update_user(current_user["username"], { "mfa_secret_pending": secret, }) # BUG-068: the QR code is generated locally (segno, stdlib-free SVG data # URI). The previous client-side https://api.qrserver.com image was blocked # by the CSP (img-src 'self' data: blob:) and leaked the otpauth URI — # including the TOTP secret — to a third party. qr_data_url: str | None = None try: import segno qr_data_url = segno.make(qr_uri).svg_data_uri(scale=5) except Exception: qr_data_url = None return { "secret": secret, "qr_uri": qr_uri, "otpauth_uri": qr_uri, "qr_data_url": qr_data_url, } @router.post("/mfa/totp/enable") async def mfa_totp_enable( req: MfaEnableRequest, current_user=Depends(require_auth), ): """Enable MFA after verifying the first TOTP code. On success: generates recovery codes, enables MFA, returns recovery codes. """ from .user_store import get_user, update_user user = get_user(current_user["username"]) if user is None: raise HTTPException(404, "Utilisateur introuvable") secret = user.get("mfa_secret_pending") if not secret: raise HTTPException(400, "Aucune configuration MFA en cours. Commencez par /mfa/totp/setup") if not verify_totp(secret, req.code): raise HTTPException(400, "Code TOTP invalide") # Generate recovery codes recovery_codes = generate_recovery_codes() hashed_codes = [hash_recovery_code(c) for c in recovery_codes] # Enable MFA update_user(current_user["username"], { "mfa_enabled": True, "mfa_secret": secret, "mfa_method": "totp", "mfa_recovery_codes": hashed_codes, "mfa_secret_pending": None, # clear pending }) logger.info(f"MFA enabled for user '{current_user['username']}'") return { "mfa_enabled": True, "recovery_codes": recovery_codes, # shown once, client must display/save } @router.post("/mfa/totp/disable") async def mfa_totp_disable( req: MfaDisableRequest, current_user=Depends(require_auth), ): """Disable MFA. Requires current password + valid TOTP code.""" from .user_store import get_user, update_user user = get_user(current_user["username"]) if user is None: raise HTTPException(404, "Utilisateur introuvable") if not user.get("mfa_enabled"): raise HTTPException(400, "MFA non activé") if not verify_password(req.password, user["password_hash"]): raise HTTPException(400, "Mot de passe incorrect") if not verify_totp(user["mfa_secret"], req.code): raise HTTPException(400, "Code TOTP invalide") update_user(current_user["username"], { "mfa_enabled": False, "mfa_secret": None, "mfa_method": None, "mfa_recovery_codes": [], }) logger.info(f"MFA disabled for user '{current_user['username']}'") return {"mfa_enabled": False} # ── WebAuthn endpoints (ROADMAP #64) ───────────────────────────────── def _preferred_mfa_method(user: dict) -> str: """Which second factor to offer at login: webauthn when keys exist, else totp.""" if user.get("webauthn_credentials"): return "webauthn" return "totp" class WebauthnRegisterRequest(BaseModel): credential: dict label: str = "" class WebauthnVerifyRequest(BaseModel): username: str credential: dict remember_me: bool = False class WebauthnRemoveRequest(BaseModel): credential_id: str password: str @router.post("/mfa/webauthn/register/options") async def mfa_webauthn_register_options(request: Request, current_user=Depends(require_auth)): """Start WebAuthn key enrolment — returns publicKey creation options for the browser.""" from .webauthn_mfa import begin_registration, resolve_relying_party # BUG-070: rp_id/origins derive from the request (exact host incl. port) # unless explicitly configured — the old localhost defaults rejected # every real access URL ("Unexpected client data origin"). rp, _ = resolve_relying_party(request) options = begin_registration(current_user["username"], current_user.get("display_name", ""), rp_id_override=rp) return {"options": options} @router.post("/mfa/webauthn/register") async def mfa_webauthn_register( req: WebauthnRegisterRequest, request: Request, current_user=Depends(require_auth), ): """Verify the created credential, store it, and enable MFA if not already on. Returns recovery codes when MFA is newly enabled (they were never issued). """ from datetime import datetime, timezone from .user_store import get_user, update_user from .webauthn_mfa import complete_registration, resolve_relying_party user = get_user(current_user["username"]) if user is None: raise HTTPException(404, "Utilisateur introuvable") rp, origins = resolve_relying_party(request) try: record = complete_registration(current_user["username"], req.credential, label=req.label, rp_id_override=rp, origins_override=origins) except ValueError as e: raise HTTPException(400, str(e)) except Exception as e: logger.warning(f"WebAuthn registration failed for {current_user['username']}: {e}") raise HTTPException(400, "Validation du credential WebAuthn échouée") record["registered_at"] = datetime.now(timezone.utc).isoformat() creds = list(user.get("webauthn_credentials", [])) creds = [c for c in creds if c.get("credential_id") != record["credential_id"]] creds.append(record) updates: dict = {"webauthn_credentials": creds} issued_recovery: list[str] = [] if not user.get("mfa_enabled"): issued_recovery = generate_recovery_codes() updates.update({ "mfa_enabled": True, "mfa_method": "webauthn", "mfa_recovery_codes": [hash_recovery_code(c) for c in issued_recovery], }) update_user(current_user["username"], updates) logger.info(f"WebAuthn credential registered for user '{current_user['username']}' " f"({record['label']})") return { "ok": True, "credentials": user_credentials_response(creds), "mfa_enabled": True, "recovery_codes": issued_recovery, } def user_credentials_response(creds: list[dict]) -> list[dict]: from .webauthn_mfa import credentials_for_api return credentials_for_api(creds) @router.get("/mfa/webauthn/credentials") async def mfa_webauthn_list(current_user=Depends(require_auth)): from .user_store import get_user user = get_user(current_user["username"]) if user is None: raise HTTPException(404, "Utilisateur introuvable") return {"credentials": user_credentials_response(user.get("webauthn_credentials", []))} @router.post("/mfa/webauthn/credentials/remove") async def mfa_webauthn_remove( req: WebauthnRemoveRequest, current_user=Depends(require_auth), ): """Remove a WebAuthn key. Requires password. Disables MFA if no second factor remains.""" from .user_store import get_user, update_user from .webauthn_mfa import clear_pending user = get_user(current_user["username"]) if user is None: raise HTTPException(404, "Utilisateur introuvable") if not verify_password(req.password, user["password_hash"]): raise HTTPException(400, "Mot de passe incorrect") creds = [c for c in user.get("webauthn_credentials", []) if c.get("credential_id") != req.credential_id] if len(creds) == len(user.get("webauthn_credentials", [])): raise HTTPException(404, "Credential inconnu") updates: dict = {"webauthn_credentials": creds} if not creds and not user.get("mfa_secret"): updates.update({"mfa_enabled": False, "mfa_method": None, "mfa_recovery_codes": []}) elif not creds and user.get("mfa_secret"): updates["mfa_method"] = "totp" update_user(current_user["username"], updates) clear_pending(current_user["username"]) return {"ok": True, "credentials": user_credentials_response(creds), "mfa_enabled": bool(updates.get("mfa_enabled", user.get("mfa_enabled"))) and bool(creds or user.get("mfa_secret"))} @router.post("/mfa/webauthn/options") async def mfa_webauthn_login_options(request: Request, body: dict = Body(...)): """Unauthenticated: begin the login assertion for a user with registered keys. Enumeration-safe: always 200 — returns null options (caller falls back to TOTP/recovery UI) when the user has no WebAuthn key or MFA is off. """ username = str(body.get("username", "")) user = get_user(username) creds = (user or {}).get("webauthn_credentials", []) if not user or not user.get("mfa_enabled") or not creds: return {"mfa_method": "totp", "options": None} from .webauthn_mfa import begin_authentication, resolve_relying_party rp, _ = resolve_relying_party(request) options = begin_authentication(username, creds, rp_id_override=rp) if options is None: return {"mfa_method": "totp", "options": None} return {"mfa_method": "webauthn", "options": options} @router.post("/mfa/webauthn/verify") async def mfa_webauthn_verify( body: WebauthnVerifyRequest, response: Response, request: Request, ): """Unauthenticated: verify the WebAuthn assertion and issue JWT tokens.""" from .user_store import get_user, update_user from .webauthn_mfa import complete_authentication, resolve_relying_party client_ip = _enforce_mfa_rate_limit(request, body.username) user = get_user(body.username) if not user: hash_password("dummy_timing_protection") raise HTTPException(401, "Identifiants invalides") if not user.get("mfa_enabled"): raise HTTPException(400, "MFA non activé pour cet utilisateur") rp, origins = resolve_relying_party(request) creds = user.get("webauthn_credentials", []) try: credential_id = body.credential.get("id", "") stored = next((c for c in creds if c.get("credential_id") == credential_id), None) if stored is None: raise ValueError("Credential non enregistré") new_count = complete_authentication(body.username, body.credential, stored, rp_id_override=rp, origins_override=origins) except ValueError as e: _record_mfa_failure(client_ip, body.username) raise HTTPException(401, str(e)) except Exception as e: _record_mfa_failure(client_ip, body.username) logger.warning(f"WebAuthn verification failed for {body.username}: {e}") raise HTTPException(401, "Vérification WebAuthn échouée") updated = [dict(c) for c in creds] for c in updated: if c.get("credential_id") == body.credential.get("id"): c["sign_count"] = new_count update_user(body.username, {"webauthn_credentials": updated}) rl_record_success(client_ip) logger.info(f"User '{body.username}' logged in via WebAuthn") return _issue_tokens(user, body.username, body.remember_me, response, request) @router.get("/mfa/status") async def mfa_status(current_user=Depends(require_auth)): """Return current user's MFA status.""" from .user_store import get_user user = get_user(current_user["username"]) if user is None: # BUG-081 : auth désactivée (OBSIGATE_AUTH_ENABLED=false) → le # pseudo-user "anonymous" n'a aucune entrée en store : pas de MFA, # et surtout pas de 500 (`AttributeError` sur `user.get`). return { "mfa_enabled": False, "mfa_method": None, "totp_enabled": False, "webauthn_credentials": 0, } return { "mfa_enabled": user.get("mfa_enabled", False), "mfa_method": user.get("mfa_method"), "totp_enabled": bool(user.get("mfa_secret")), "webauthn_credentials": len(user.get("webauthn_credentials", [])), } @router.post("/mfa/totp/verify") async def mfa_totp_verify(body: MfaVerifyRequest, response: Response, request: Request): """Verify TOTP code during login (second factor). Called after login returns mfa_required=true. On success: issues JWT tokens. """ from .user_store import get_user client_ip = _enforce_mfa_rate_limit(request, body.username) user = get_user(body.username) if not user: # Timing-safe: simulate work hash_password("dummy_timing_protection") raise HTTPException(401, "Identifiants invalides") if not user.get("mfa_enabled") or not user.get("mfa_secret"): raise HTTPException(400, "MFA non activé pour cet utilisateur") if not verify_totp(user["mfa_secret"], body.code): _record_mfa_failure(client_ip, body.username) raise HTTPException(401, "Code TOTP invalide") # Clear IP rate limit on success rl_record_success(client_ip) return _issue_tokens(user, body.username, body.remember_me, response, request) @router.post("/mfa/recovery") async def mfa_recovery_login(body: MfaRecoveryRequest, response: Response, request: Request): """Login with a recovery code (when TOTP device is unavailable). Each recovery code is single-use. """ from .user_store import get_user, update_user client_ip = _enforce_mfa_rate_limit(request, body.username) user = get_user(body.username) if not user: hash_password("dummy_timing_protection") raise HTTPException(401, "Identifiants invalides") if not user.get("mfa_enabled"): raise HTTPException(400, "MFA non activé pour cet utilisateur") hashed_codes = user.get("mfa_recovery_codes", []) if not hashed_codes: raise HTTPException(400, "Aucun code de récupération disponible") idx = verify_recovery_code(body.recovery_code, hashed_codes) if idx is None: _record_mfa_failure(client_ip, body.username) raise HTTPException(401, "Code de récupération invalide") # Remove used recovery code (single-use) hashed_codes.pop(idx) update_user(body.username, {"mfa_recovery_codes": hashed_codes}) # Clear IP rate limit rl_record_success(client_ip) logger.info(f"User '{body.username}' logged in via recovery code") return _issue_tokens(user, body.username, False, response, request) # ── Admin endpoints ─────────────────────────────────────────────────── @router.get("/admin/users") async def list_users(admin=Depends(require_admin)): """List all users (admin only). Password hashes are never included.""" return get_all_users() @router.post("/admin/users") async def create_user_endpoint( req: CreateUserRequest, admin=Depends(require_admin), ): """Create a new user (admin only).""" try: user = create_user( req.username, req.password, req.role, req.vaults, req.display_name ) return user except ValueError as e: raise HTTPException(400, str(e)) @router.patch("/admin/users/{username}") async def update_user_endpoint( username: str, req: UpdateUserRequest, admin=Depends(require_admin), ): """Update a user (admin only).""" updates = req.dict(exclude_none=True) try: return update_user(username, updates) except ValueError as e: raise HTTPException(404, str(e)) @router.delete("/admin/users/{username}") async def delete_user_endpoint( username: str, admin=Depends(require_admin), ): """Delete a user (admin only). Cannot delete own account.""" if username == admin["username"]: raise HTTPException(400, "Impossible de supprimer son propre compte") try: delete_user(username) return {"message": f"Utilisateur '{username}' supprimé"} except ValueError as e: raise HTTPException(404, str(e)) # ── API / MCP tokens (feature #107) ────────────────────────────────── # One long-lived token authenticates BOTH the REST API and the MCP # endpoint (/mcp): the MCP server resolves the caller through the same # get_current_user() dependency, so the same Bearer JWT works everywhere. class CreateApiTokenRequest(BaseModel): name: str expiry: str # 1d | 30d | 180d | 365d | never @router.get("/tokens") async def list_user_tokens(current_user=Depends(require_auth)): """List the caller's API/MCP tokens (metadata only — the secret is never stored).""" return { "tokens": list_api_tokens(current_user["username"]), "expiry_choices": list(API_TOKEN_EXPIRY_CHOICES.keys()), } @router.post("/tokens") async def create_user_token( req: CreateApiTokenRequest, request: Request, current_user=Depends(require_auth), ): """Create a long-lived API/MCP token. The raw JWT is returned ONCE.""" try: record, token = create_api_token(current_user, req.name.strip(), req.expiry) except ValueError as e: raise HTTPException(400, str(e)) from backend.audit import log_config_change log_config_change(current_user["username"], {"action": "api_token_create", "name": record["name"], "expiry": record["expiry_key"]}, ip=get_client_ip(request)) return {"token": token, **record} @router.delete("/tokens/{jti}") async def delete_user_token( jti: str, request: Request, current_user=Depends(require_auth), ): """Revoke + delete an API/MCP token (immediate effect on API and MCP).""" try: record = delete_api_token(jti, current_user["username"]) except KeyError: raise HTTPException(404, "Token introuvable") from backend.audit import log_config_change log_config_change(current_user["username"], {"action": "api_token_revoke", "name": record["name"]}, ip=get_client_ip(request)) return {"message": f"Token '{record['name']}' révoqué"}