CI / lint (push) Successful in 1m1s
CI / security (push) Successful in 40s
CI / test (push) Successful in 1m17s
CI / build (push) Successful in 38s
CI / e2e (push) Successful in 10m55s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
BUG-003: annotations de types, gardes None sur get_user(), PdfReader: Any et import PROVIDERS manquant (bug latent main.py:4523). Etape mypy du CI rendue bloquante (etait advisory). BUG-004: lien README.md -> docs/CONTRIBUTING.md corrige (+ DELIVERY_WORKFLOW.md), arbre projet mis a jour, parite README.fr.md. Verifie: mypy 0 erreur, ruff OK, pytest 728 passed / 5 skipped, frontend OK, liens md OK.
763 lines
25 KiB
Python
763 lines
25 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_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 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)
|
|
|
|
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"])
|
|
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
|
|
|
|
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:
|
|
raise HTTPException(401, str(e))
|
|
except Exception as e:
|
|
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})
|
|
|
|
client_ip = request.client.host if request.client else "unknown"
|
|
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
|
|
|
|
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))
|