- sanitizer XSS serveur (markdown + page de partage) [BUG-021/022] - rate-limit/lockout MFA [BUG-023] - isolation vaults par segments [BUG-024] - caps regex ReDoS [BUG-025] - SSRF webhooks + secrets externalises [BUG-026] - rotation/revocation des jetons [BUG-027] - politique de mot de passe + invalidation sessions [BUG-028] - verrous users.json [BUG-029] - IP reelle dans les audits [BUG-030] - rate-limit par compte [BUG-031] - symlinks hors vault ignores [BUG-032] - recherche simple via inverted index [BUG-033] - token en memoire + cookie HttpOnly, CSP durcie [BUG-034] Tests: pytest 961 passed / 6 skipped, ruff 0, mypy 0, frontend vert.
863 lines
29 KiB
Python
863 lines
29 KiB
Python
# 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, 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
|
|
|
|
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, 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"])
|
|
|
|
|
|
# ── 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)
|
|
|
|
|
|
# ── 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 = get_client_ip(request)
|
|
if is_rate_limited(client_ip):
|
|
raise HTTPException(429, "Trop de tentatives depuis cette adresse IP (15min)")
|
|
|
|
# BUG-031: per-account budget still applies when the attacker rotates IPs.
|
|
if is_account_rate_limited(body.username):
|
|
raise HTTPException(429, "Trop de tentatives sur ce compte (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)
|
|
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)
|
|
|
|
|
|
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)
|
|
rl_record_account_success(username)
|
|
|
|
access_token = create_access_token(user)
|
|
refresh_token, refresh_jti = create_refresh_token(username, remember=remember_me)
|
|
|
|
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.
|
|
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")
|
|
|
|
import os
|
|
|
|
secure = os.environ.get("OBSIGATE_SECURE_COOKIES", "false").lower() == "true"
|
|
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,
|
|
"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 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"),
|
|
}
|
|
|
|
|
|
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,
|
|
response: Response,
|
|
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))
|
|
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 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"])
|
|
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(current_user=Depends(require_auth)):
|
|
"""Start WebAuthn key enrolment — returns publicKey creation options for the browser."""
|
|
from .webauthn_mfa import begin_registration
|
|
|
|
options = begin_registration(current_user["username"],
|
|
current_user.get("display_name", ""))
|
|
return {"options": options}
|
|
|
|
|
|
@router.post("/mfa/webauthn/register")
|
|
async def mfa_webauthn_register(
|
|
req: WebauthnRegisterRequest,
|
|
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
|
|
|
|
user = get_user(current_user["username"])
|
|
if user is None:
|
|
raise HTTPException(404, "Utilisateur introuvable")
|
|
try:
|
|
record = complete_registration(current_user["username"], req.credential,
|
|
label=req.label)
|
|
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(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
|
|
options = begin_authentication(username, creds)
|
|
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
|
|
|
|
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")
|
|
|
|
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)
|
|
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)
|
|
|
|
|
|
@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"),
|
|
"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)
|
|
|
|
|
|
@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)
|
|
|
|
|
|
# ── 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))
|