# backend/auth/router.py # All /api/auth/* endpoints: login, logout, refresh, me, change-password, # and admin user CRUD. import logging import re from fastapi import APIRouter, Depends, HTTPException, Request, Response from pydantic import BaseModel, validator from backend.ratelimit import is_rate_limited from backend.ratelimit import record_failure as rl_record_failure from backend.ratelimit import record_success as rl_record_success from .jwt_handler import ( ACCESS_TOKEN_EXPIRE_SECONDS, create_access_token, create_refresh_token, decode_token, is_token_revoked, 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, 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"]) # ── 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): if len(v) < 8: raise ValueError("Minimum 8 caractères") return v class CreateUserRequest(BaseModel): username: str password: str display_name: str | None = None role: str = "user" vaults: list[str] = [] @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 # ── 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). """ user = get_user(body.username) if not user: # Timing-safe: simulate hash computation to prevent user enumeration hash_password("dummy_timing_protection") raise HTTPException(401, "Identifiants invalides") if not user.get("active"): raise HTTPException(403, "Compte désactivé") # IP-based rate limiting (10 failures / 15 min per IP) client_ip = request.client.host if request.client else "unknown" if is_rate_limited(client_ip): raise HTTPException(429, "Trop de tentatives depuis cette adresse IP (15min)") if is_locked(body.username): raise HTTPException(429, "Compte temporairement verrouillé (15min)") if not verify_password(body.password, user["password_hash"]): attempts = record_login_failure(body.username) rl_attempts, rl_remaining = rl_record_failure(client_ip) 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 TOTP verification if user.get("mfa_enabled") and user.get("mfa_secret"): logger.info(f"User '{body.username}' login deferred — MFA required") return { "mfa_required": True, "mfa_method": "totp", "username": body.username, "remember_me": body.remember_me, } return _issue_tokens(user, body.username, body.remember_me, response) def _issue_tokens(user: dict, username: str, remember_me: bool, response: Response) -> dict: """Issue JWT tokens after successful authentication (password or MFA verified).""" record_login_success(username) access_token = create_access_token(user) refresh_token, refresh_jti = create_refresh_token(username) import os max_age = 2592000 if remember_me else 604800 # 30d or 7d secure = os.environ.get("OBSIGATE_SECURE_COOKIES", "false").lower() == "true" 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, "token_type": "bearer", # nosec B105 — OAuth2 token_type, pas un mot de passe "expires_in": ACCESS_TOKEN_EXPIRE_SECONDS, "user": { "username": user["username"], "display_name": user["display_name"], "role": user["role"], "vaults": user["vaults"], }, } @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. """ 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") new_access_token = create_access_token(user) # Update cookies import os secure = os.environ.get("OBSIGATE_SECURE_COOKIES", "false").lower() == "true" 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, "token_type": "bearer", # nosec B105 — OAuth2 token_type, pas un mot de passe "expires_in": ACCESS_TOKEN_EXPIRE_SECONDS, } @router.post("/logout") async def logout( request: Request, response: Response, ): """Logout: revoke refresh token and 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 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"), } class UpdateMeRequest(BaseModel): """Fields the user can update on their own profile.""" display_name: str | None = None language: 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).""" from .user_store import update_user updates = {} 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 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"), } @router.post("/change-password") async def change_password( req: ChangePasswordRequest, current_user=Depends(require_auth), ): """Change own password.""" 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}) return {"message": "Mot de passe mis à jour"} # ── MFA endpoints ──────────────────────────────────────────────────── 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 and otpauth URI — client displays QR code. 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, }) return { "secret": secret, "qr_uri": qr_uri, "otpauth_uri": qr_uri, } @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"]) 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 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} @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"]) return { "mfa_enabled": user.get("mfa_enabled", False), "mfa_method": user.get("mfa_method"), } @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 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): raise HTTPException(401, "Code TOTP invalide") # Clear IP rate limit on success client_ip = request.client.host if request.client else "unknown" rl_record_success(client_ip) return _issue_tokens(user, body.username, body.remember_me, response) @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 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: 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 client_ip = request.client.host if request.client else "unknown" rl_record_success(client_ip) logger.info(f"User '{body.username}' logged in via recovery code") return _issue_tokens(user, body.username, False, response) # ── 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))