425 lines
17 KiB
Python
425 lines
17 KiB
Python
# backend/routers/comptes_machine.py
|
|
# HUB-13 (fusion #127) — API d'administration des comptes, machine-à-machine.
|
|
#
|
|
# Le Hub (interface d'administration des utilisateurs) administre les
|
|
# comptes dont ObsiGate reste le magasin (décisions D1(a)/D2(a) du plan
|
|
# Hub v2) via ces routes, en lecture et en écriture : lister, lire le
|
|
# profil étendu, créer, modifier, désactiver/réactiver, supprimer,
|
|
# réinitialiser le mot de passe et le MFA.
|
|
#
|
|
# HUB-22 (#227, vague C du plan Hub v2.1) ajoute, sous la même garde et
|
|
# avec la même journalisation, les routes « sessions » : lister les
|
|
# sessions draco actives d'un compte, en révoquer une (par son `jti`,
|
|
# jamais celle d'un autre compte) ou toutes (déconnexion globale, sans
|
|
# désactiver le compte), via le registre des émissions de HUB-13.
|
|
#
|
|
# Emplacement : ``/api/admin/machine/comptes`` — dans l'espace
|
|
# d'administration existant (``/api/admin/…``), avec un segment
|
|
# ``machine`` explicite qui le distingue des routes d'administration par
|
|
# session (``/api/auth/admin/users``, garde ``require_admin``). Aucune
|
|
# session utilisateur — même administratrice — n'ouvre ces routes :
|
|
# seule la clé machine dédiée les protège.
|
|
#
|
|
# Authentification machine (même famille que ``GET /api/sso/revocations``) :
|
|
# DRACO_ADMIN_MACHINE_KEY clé dédiée à cette API (en-tête
|
|
# ``X-Draco-Machine-Key``). Distincte de
|
|
# ``DRACO_SSO_MACHINE_KEY`` : le périmètre
|
|
# (écriture de comptes) est plus sensible que
|
|
# la seule lecture des révocations (plan §3).
|
|
# Sans elle, l'API est inerte (404, comme la
|
|
# liste de révocations non configurée).
|
|
# X-Draco-Actor identité de service appelante, facultative
|
|
# (défaut ``service-machine``) — consignée
|
|
# comme acteur dans le journal d'audit.
|
|
#
|
|
# Chaque action est journalisée côté ObsiGate dans le journal d'audit
|
|
# existant (``data/audit.log``, voir backend.audit.log_compte_admin).
|
|
|
|
from __future__ import annotations
|
|
|
|
import hmac
|
|
import logging
|
|
import os
|
|
import re
|
|
import time
|
|
from datetime import UTC, datetime
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
from pydantic import BaseModel, validator
|
|
|
|
from backend import draco_session
|
|
from backend.audit import log_compte_admin
|
|
from backend.auth.password import validate_password_strength
|
|
from backend.auth.user_store import (
|
|
create_user,
|
|
delete_user,
|
|
get_all_users,
|
|
get_user,
|
|
update_user,
|
|
)
|
|
from backend.services.net import get_client_ip
|
|
|
|
logger = logging.getLogger("obsigate.comptes_machine")
|
|
|
|
router = APIRouter(prefix="/api/admin/machine/comptes", tags=["admin"])
|
|
|
|
#: Variable d'environnement portant la clé machine dédiée à cette API.
|
|
MACHINE_KEY_ENV = "DRACO_ADMIN_MACHINE_KEY"
|
|
#: En-tête de la clé machine (même nom que pour /api/sso/revocations).
|
|
MACHINE_KEY_HEADER = "X-Draco-Machine-Key"
|
|
#: En-tête facultatif : identité de service appelante (journal d'audit).
|
|
ACTOR_HEADER = "X-Draco-Actor"
|
|
#: Acteur consigné quand l'appelant ne se nomme pas.
|
|
DEFAULT_ACTOR = "service-machine"
|
|
|
|
|
|
# ── Garde machine ───────────────────────────────────────────────────────
|
|
|
|
def require_machine_admin(request: Request) -> str:
|
|
"""Exige la clé machine dédiée ; retourne l'identité de service.
|
|
|
|
Jamais de repli sur une session utilisateur : sans en-tête valide,
|
|
la réponse est 401 (ou 404 si l'API n'est pas configurée, patron de
|
|
``GET /api/sso/revocations``).
|
|
"""
|
|
machine_key = os.environ.get(MACHINE_KEY_ENV, "")
|
|
if not machine_key:
|
|
raise HTTPException(404, "Non configuré")
|
|
provided = request.headers.get(MACHINE_KEY_HEADER, "")
|
|
if not provided or not hmac.compare_digest(provided, machine_key):
|
|
raise HTTPException(401, "Clé machine invalide")
|
|
actor = (request.headers.get(ACTOR_HEADER, "") or "").strip()
|
|
return actor[:64] if actor else DEFAULT_ACTOR
|
|
|
|
|
|
# ── Profil étendu (liste blanche — jamais de secret) ────────────────────
|
|
|
|
def _profil(user: dict) -> dict:
|
|
"""Profil étendu d'un compte, sûr à exposer à un service.
|
|
|
|
Liste blanche stricte : ni ``password_hash``, ni secret TOTP
|
|
(``mfa_secret``/``mfa_secret_pending``), ni codes de récupération,
|
|
ni identifiants WebAuthn ne sortent — seul l'état MFA (oui/non)
|
|
est exposé. L'avatar suit le modèle existant : la valeur stockée
|
|
(data-URL ou ``None``, comme ``GET /api/auth/me``) plus le booléen
|
|
de présence ``avatar_present`` pour les listes légères.
|
|
"""
|
|
return {
|
|
"id": user.get("id"),
|
|
"username": user.get("username"),
|
|
"display_name": user.get("display_name"),
|
|
"language": user.get("language", "fr"),
|
|
"avatar": user.get("avatar"),
|
|
"avatar_present": bool(user.get("avatar")),
|
|
"role": user.get("role"),
|
|
"active": bool(user.get("active")),
|
|
"vaults": user.get("vaults", []),
|
|
"mfa_enabled": bool(user.get("mfa_enabled")),
|
|
"mfa_method": user.get("mfa_method"),
|
|
"must_change_password": bool(user.get("must_change_password")),
|
|
"created_at": user.get("created_at"),
|
|
"last_login": user.get("last_login"),
|
|
}
|
|
|
|
|
|
def _compte_ou_404(username: str) -> dict:
|
|
user = get_user(username)
|
|
if user is None:
|
|
raise HTTPException(404, "Compte introuvable")
|
|
return user
|
|
|
|
|
|
def _identifiant_sessions(user: dict) -> str:
|
|
"""Clé du registre des émissions draco pour un compte.
|
|
|
|
C'est le ``sub`` des jetons draco : l'identifiant du compte
|
|
(``id``), à défaut son nom d'utilisateur — exactement la clé
|
|
qu'utilise ``_revoquer_sessions`` depuis HUB-13.
|
|
"""
|
|
return str(user.get("id") or user.get("username") or "")
|
|
|
|
|
|
def _revoquer_sessions(user: dict) -> int:
|
|
"""Révoque les sessions d'un compte ; retourne le nombre de jti draco.
|
|
|
|
Les ``jti`` du cookie partagé émis pour ce compte sont révoqués via
|
|
le mécanisme existant (``draco_session.revoke``, exposé aux autres
|
|
applications par ``GET /api/sso/revocations``). Les jetons natifs
|
|
sont invalidés par le garde ``password_changed_at`` (BUG-028),
|
|
avancé par l'appelant (désactivation, réinitialisation du mot de
|
|
passe) ou par le drapeau ``active`` tant que le compte est inactif.
|
|
"""
|
|
return draco_session.revoke_all_for_user(_identifiant_sessions(user))
|
|
|
|
|
|
def _audit(request: Request, acteur: str, action: str, cible: str,
|
|
details: dict | None = None) -> None:
|
|
log_compte_admin(acteur, action, cible,
|
|
ip=get_client_ip(request), details=details)
|
|
|
|
|
|
# ── Modèles de requête ──────────────────────────────────────────────────
|
|
|
|
class CreerCompteRequest(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 ModifierCompteRequest(BaseModel):
|
|
display_name: str | None = None
|
|
role: str | None = None
|
|
language: str | None = None
|
|
vaults: list[str] | None = None
|
|
|
|
@validator("role")
|
|
def role_valid(cls, v):
|
|
if v is not None and v not in ("admin", "user"):
|
|
raise ValueError("Rôle invalide")
|
|
return v
|
|
|
|
@validator("language")
|
|
def language_valid(cls, v):
|
|
if v is not None and v not in ("fr", "en"):
|
|
raise ValueError("Langue invalide (fr | en)")
|
|
return v
|
|
|
|
|
|
class ReinitialiserMotDePasseRequest(BaseModel):
|
|
password: str
|
|
|
|
@validator("password")
|
|
def password_valid(cls, v):
|
|
return validate_password_strength(v)
|
|
|
|
|
|
# ── Lecture ─────────────────────────────────────────────────────────────
|
|
|
|
@router.get("")
|
|
async def lister_comptes(acteur: str = Depends(require_machine_admin)):
|
|
"""Liste tous les comptes (profils étendus, sans aucun secret)."""
|
|
comptes = [_profil(u) for u in get_all_users()]
|
|
return {"comptes": comptes, "total": len(comptes)}
|
|
|
|
|
|
@router.get("/{username}")
|
|
async def lire_compte(username: str,
|
|
acteur: str = Depends(require_machine_admin)):
|
|
"""Lit un compte et son profil étendu."""
|
|
return _profil(_compte_ou_404(username))
|
|
|
|
|
|
# ── Écriture ────────────────────────────────────────────────────────────
|
|
|
|
@router.post("", status_code=201)
|
|
async def creer_compte(req: CreerCompteRequest, request: Request,
|
|
acteur: str = Depends(require_machine_admin)):
|
|
"""Crée un compte. Le mot de passe initial est temporaire :
|
|
``must_change_password`` est posé (changement forcé signalé à la
|
|
première connexion, comme après une réinitialisation)."""
|
|
if get_user(req.username) is not None:
|
|
raise HTTPException(409, "Identifiant déjà utilisé")
|
|
try:
|
|
user = create_user(
|
|
req.username, req.password, req.role, req.vaults,
|
|
req.display_name, must_change_password=True,
|
|
)
|
|
except ValueError as e:
|
|
raise HTTPException(409, str(e)) from e
|
|
# #194 : dossier personnel — même provisionnement que la création
|
|
# par session administratrice (best effort).
|
|
from backend.user_home import ensure_user_home
|
|
|
|
await ensure_user_home(req.username)
|
|
fresh = get_user(req.username)
|
|
if fresh:
|
|
user = fresh
|
|
_audit(request, acteur, "compte_cree", req.username,
|
|
{"role": req.role})
|
|
return _profil(user)
|
|
|
|
|
|
@router.patch("/{username}")
|
|
async def modifier_compte(username: str, req: ModifierCompteRequest,
|
|
request: Request,
|
|
acteur: str = Depends(require_machine_admin)):
|
|
"""Modifie un compte (nom affiché, rôle global, langue, vaults)."""
|
|
_compte_ou_404(username)
|
|
updates = req.dict(exclude_none=True)
|
|
if not updates:
|
|
raise HTTPException(400, "Aucun champ à modifier")
|
|
updated = update_user(username, updates)
|
|
_audit(request, acteur, "compte_modifie", username,
|
|
{"champs": sorted(updates.keys())})
|
|
return _profil(updated)
|
|
|
|
|
|
@router.post("/{username}/desactiver")
|
|
async def desactiver_compte(username: str, request: Request,
|
|
acteur: str = Depends(require_machine_admin)):
|
|
"""Désactive un compte : plus aucun nouveau jeton n'est émis
|
|
(connexion refusée) et les ``jti`` draco actifs sont révoqués."""
|
|
user = _compte_ou_404(username)
|
|
updated = update_user(username, {
|
|
"active": False,
|
|
# BUG-028 : avancer le repère invalide aussi les jetons natifs
|
|
# déjà émis — ils ne ressuscitent pas à la réactivation.
|
|
"password_changed_at": time.time(),
|
|
})
|
|
nb_jti = _revoquer_sessions(user)
|
|
_audit(request, acteur, "compte_desactive", username,
|
|
{"jti_revoques": nb_jti})
|
|
return _profil(updated)
|
|
|
|
|
|
@router.post("/{username}/reactiver")
|
|
async def reactiver_compte(username: str, request: Request,
|
|
acteur: str = Depends(require_machine_admin)):
|
|
"""Réactive un compte désactivé (les anciennes sessions restent
|
|
révoquées : une nouvelle connexion est nécessaire)."""
|
|
_compte_ou_404(username)
|
|
updated = update_user(username, {"active": True})
|
|
_audit(request, acteur, "compte_reactive", username)
|
|
return _profil(updated)
|
|
|
|
|
|
@router.delete("/{username}")
|
|
async def supprimer_compte(username: str, request: Request,
|
|
acteur: str = Depends(require_machine_admin)):
|
|
"""Supprime un compte et révoque ses ``jti`` draco actifs."""
|
|
user = _compte_ou_404(username)
|
|
nb_jti = _revoquer_sessions(user)
|
|
try:
|
|
delete_user(username)
|
|
except ValueError as e:
|
|
raise HTTPException(404, str(e)) from e
|
|
# #194 : fermer le vault du dossier personnel (dossier conservé),
|
|
# comme la suppression par session administratrice.
|
|
from backend.user_home import release_user_home
|
|
|
|
await release_user_home(username)
|
|
_audit(request, acteur, "compte_supprime", username,
|
|
{"jti_revoques": nb_jti})
|
|
return {"message": f"Compte '{username}' supprimé"}
|
|
|
|
|
|
@router.post("/{username}/reinitialiser-mot-de-passe")
|
|
async def reinitialiser_mot_de_passe(
|
|
username: str, req: ReinitialiserMotDePasseRequest,
|
|
request: Request, acteur: str = Depends(require_machine_admin)):
|
|
"""Pose un nouveau mot de passe temporaire : changement forcé à la
|
|
première connexion (``must_change_password``, signalé par la
|
|
connexion et ``GET /api/auth/me``, effacé au changement effectif).
|
|
Les sessions existantes sont révoquées (repère BUG-028 + ``jti``
|
|
draco). Le mot de passe n'est jamais journalisé."""
|
|
user = _compte_ou_404(username)
|
|
updated = update_user(username, {
|
|
"password": req.password,
|
|
"must_change_password": True,
|
|
})
|
|
nb_jti = _revoquer_sessions(user)
|
|
_audit(request, acteur, "mot_de_passe_reinitialise", username,
|
|
{"jti_revoques": nb_jti})
|
|
return _profil(updated)
|
|
|
|
|
|
@router.post("/{username}/reinitialiser-mfa")
|
|
async def reinitialiser_mfa(username: str, request: Request,
|
|
acteur: str = Depends(require_machine_admin)):
|
|
"""Efface l'enrôlement TOTP d'un compte (utilisateur verrouillé
|
|
hors de son second facteur) : secret, secret en attente et codes
|
|
de récupération sont supprimés. Les clés WebAuthn éventuelles sont
|
|
conservées et gardent alors le MFA actif. Le secret n'est jamais
|
|
exposé — ni avant, ni après."""
|
|
user = _compte_ou_404(username)
|
|
webauthn_restantes = len(user.get("webauthn_credentials") or [])
|
|
updates: dict = {
|
|
"mfa_secret": None,
|
|
"mfa_secret_pending": None,
|
|
"mfa_recovery_codes": [],
|
|
}
|
|
if webauthn_restantes:
|
|
updates["mfa_method"] = "webauthn"
|
|
else:
|
|
updates["mfa_enabled"] = False
|
|
updates["mfa_method"] = None
|
|
updated = update_user(username, updates)
|
|
_audit(request, acteur, "mfa_reinitialise", username,
|
|
{"webauthn_conserves": webauthn_restantes})
|
|
return _profil(updated)
|
|
|
|
|
|
# ── Sessions draco (HUB-22) ─────────────────────────────────────────────
|
|
|
|
@router.get("/{username}/sessions")
|
|
async def lister_sessions(username: str,
|
|
acteur: str = Depends(require_machine_admin)):
|
|
"""Liste les sessions draco actives d'un compte.
|
|
|
|
Une session par ``jti`` émis et non expiré (registre des émissions
|
|
de HUB-13), triées par expiration croissante ; ``revoquee`` signale
|
|
un ``jti`` déjà présent dans la liste des révocations et
|
|
``expire_le`` est l'échéance ``exp`` en ISO 8601 UTC.
|
|
"""
|
|
user = _compte_ou_404(username)
|
|
sessions = [{
|
|
"jti": s["jti"],
|
|
"exp": s["exp"],
|
|
"expire_le": datetime.fromtimestamp(s["exp"], tz=UTC).isoformat(),
|
|
"revoquee": s["revoquee"],
|
|
} for s in draco_session.issued_sessions_for_user(
|
|
_identifiant_sessions(user))]
|
|
return {"username": username, "sessions": sessions}
|
|
|
|
|
|
@router.post("/{username}/sessions/{jti}/revoquer")
|
|
async def revoquer_session(username: str, jti: str, request: Request,
|
|
acteur: str = Depends(require_machine_admin)):
|
|
"""Révoque une seule session draco d'un compte, par son ``jti``.
|
|
|
|
404 si le ``jti`` ne figure pas dans l'ensemble actif de ce
|
|
compte : une session d'un autre utilisateur n'est jamais révoquée
|
|
par cette route (aucune révocation cross-utilisateur).
|
|
"""
|
|
user = _compte_ou_404(username)
|
|
if not draco_session.revoke_one_for_user(
|
|
_identifiant_sessions(user), jti):
|
|
raise HTTPException(404, "Session introuvable")
|
|
_audit(request, acteur, "session_revoquee", username, {"jti": jti})
|
|
return {"jti": jti, "revoquee": True}
|
|
|
|
|
|
@router.post("/{username}/sessions/revoquer-toutes")
|
|
async def revoquer_toutes_sessions(
|
|
username: str, request: Request,
|
|
acteur: str = Depends(require_machine_admin)):
|
|
"""Révoque toutes les sessions draco actives d'un compte.
|
|
|
|
C'est la « déconnexion globale » : contrairement à la
|
|
désactivation, le compte reste actif — seule l'émission d'une
|
|
nouvelle session (nouvelle connexion) rouvre l'accès.
|
|
"""
|
|
user = _compte_ou_404(username)
|
|
nb_jti = _revoquer_sessions(user)
|
|
_audit(request, acteur, "sessions_revoquees", username,
|
|
{"jti_revoques": nb_jti})
|
|
return {"revoquees": nb_jti}
|