ObsiGate 2.84.0 : API d'administration des comptes machine-à-machine (HUB-13, fusion #127, #226) — routes /api/admin/machine/comptes protégées par DRACO_ADMIN_MACHINE_KEY, révocation des jti draco à la désactivation, must_change_password, journalisation d'audit
This commit is contained in:
@@ -154,6 +154,35 @@ def log_coffre_event(
|
||||
_write_entry(entry)
|
||||
|
||||
|
||||
def log_compte_admin(
|
||||
acteur: str,
|
||||
action: str,
|
||||
cible: str,
|
||||
ip: str | None = None,
|
||||
details: dict | None = None,
|
||||
):
|
||||
"""Journal d'une action d'administration de compte (HUB-13, fusion #127).
|
||||
|
||||
``acteur`` est l'identité de service appelante (API machine des
|
||||
comptes, en-tête ``X-Draco-Actor``), ``cible`` le compte visé.
|
||||
Règle absolue : ``details`` ne contient jamais de mot de passe, de
|
||||
secret TOTP ni de jeton — seulement des métadonnées (champs
|
||||
modifiés, nombre de jti révoqués…). Le champ ``username`` reprend
|
||||
l'acteur pour rester filtrable par l'écran d'audit existant.
|
||||
"""
|
||||
entry: dict = {
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"action": action,
|
||||
"username": acteur,
|
||||
"acteur": acteur,
|
||||
"cible": cible,
|
||||
"ip": ip or "unknown",
|
||||
}
|
||||
if details:
|
||||
entry["details"] = details
|
||||
_write_entry(entry)
|
||||
|
||||
|
||||
def get_recent_entries(limit: int = 100, action: str | None = None) -> list:
|
||||
"""Read the most recent audit log entries.
|
||||
|
||||
|
||||
@@ -298,6 +298,8 @@ def _issue_tokens(user: dict, username: str, remember_me: bool, response: Respon
|
||||
"role": user["role"],
|
||||
"vaults": user["vaults"],
|
||||
"avatar": user.get("avatar"),
|
||||
# HUB-13 : mot de passe temporaire — changement forcé signalé.
|
||||
"must_change_password": bool(user.get("must_change_password")),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -433,6 +435,8 @@ async def get_me(current_user=Depends(require_auth)):
|
||||
"language": current_user.get("language", "fr"),
|
||||
"last_login": current_user.get("last_login"),
|
||||
"avatar": current_user.get("avatar"),
|
||||
# HUB-13 : mot de passe temporaire — changement forcé signalé.
|
||||
"must_change_password": bool(current_user.get("must_change_password")),
|
||||
}
|
||||
|
||||
|
||||
@@ -487,7 +491,11 @@ async def change_password(
|
||||
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})
|
||||
# HUB-13 : le changement effectif efface le marqueur de changement forcé.
|
||||
update_user(current_user["username"], {
|
||||
"password": req.new_password,
|
||||
"must_change_password": False,
|
||||
})
|
||||
updated = get_user(current_user["username"])
|
||||
result: dict = {"message": "Mot de passe mis à jour"}
|
||||
if updated is not None:
|
||||
|
||||
@@ -80,8 +80,16 @@ def create_user(
|
||||
role: str = "user",
|
||||
vaults: list[str] | None = None,
|
||||
display_name: str | None = None,
|
||||
must_change_password: bool = False,
|
||||
) -> dict:
|
||||
"""Create a new user. Raises ValueError if username already taken."""
|
||||
"""Create a new user. Raises ValueError if username already taken.
|
||||
|
||||
``must_change_password`` (HUB-13) marque un mot de passe temporaire
|
||||
(création/réinitialisation par l'administration machine) : le
|
||||
changement est forcé à la première connexion. Les comptes créés
|
||||
avant ce champ n'en portent pas — ils valent ``False`` partout où
|
||||
le champ est lu via ``.get(..., False)``.
|
||||
"""
|
||||
with _users_lock:
|
||||
data = _read()
|
||||
if username in data["users"]:
|
||||
@@ -95,6 +103,7 @@ def create_user(
|
||||
"role": role,
|
||||
"vaults": vaults or [],
|
||||
"active": True,
|
||||
"must_change_password": bool(must_change_password), # HUB-13
|
||||
"language": "fr", # default UI language
|
||||
"avatar": None, # profile picture data-URL (#113)
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
|
||||
@@ -19,6 +19,12 @@
|
||||
# vide = cookie lié à l'hôte, utile en dev)
|
||||
# DRACO_SSO_MACHINE_KEY clé machine protégeant la lecture de la
|
||||
# liste de révocation (GET /api/sso/revocations)
|
||||
#
|
||||
# Registre des émissions (HUB-13, fusion #127) : chaque jti émis est
|
||||
# consigné par `sub` dans data/draco_issued.json (jti -> exp, purgé à
|
||||
# l'expiration) pour que l'administration des comptes puisse révoquer
|
||||
# toutes les sessions draco d'un utilisateur (désactivation,
|
||||
# suppression, réinitialisation du mot de passe) via revoke().
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -40,10 +46,12 @@ ALGORITHM = "HS256"
|
||||
TOKEN_TTL_SECONDS = 12 * 3600 # durée de vie du jeton (design §5.2)
|
||||
RENEWAL_WINDOW_SECONDS = 2 * 3600 # réémettre si exp - now < 2 h
|
||||
REVOKED_FILE = Path("data/draco_revoked.json")
|
||||
ISSUED_FILE = Path("data/draco_issued.json")
|
||||
ALLOWED_RETURN_SUFFIX = "dracodev.net"
|
||||
|
||||
_lock = threading.Lock()
|
||||
_revoked: dict[str, int] | None = None # jti -> exp (chargé paresseusement)
|
||||
_issued: dict[str, dict[str, int]] | None = None # sub -> {jti -> exp}
|
||||
|
||||
|
||||
def _secret() -> str:
|
||||
@@ -108,6 +116,69 @@ def revoked_list() -> list[dict]:
|
||||
for jti, exp in _load_revoked().items() if exp > now]
|
||||
|
||||
|
||||
# ── Registre des émissions (par utilisateur) ────────────────────────────
|
||||
|
||||
def _load_issued() -> dict[str, dict[str, int]]:
|
||||
global _issued
|
||||
if _issued is None:
|
||||
try:
|
||||
data = json.loads(ISSUED_FILE.read_text(encoding="utf-8"))
|
||||
_issued = {
|
||||
str(sub): {str(j): int(e) for j, e in tokens.items()}
|
||||
for sub, tokens in data.items()
|
||||
if isinstance(tokens, dict)
|
||||
} if isinstance(data, dict) else {}
|
||||
except (OSError, ValueError):
|
||||
_issued = {}
|
||||
return _issued
|
||||
|
||||
|
||||
def _save_issued() -> None:
|
||||
ISSUED_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = ISSUED_FILE.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(_issued or {}), encoding="utf-8")
|
||||
tmp.replace(ISSUED_FILE)
|
||||
|
||||
|
||||
def _register_issued(sub: str, jti: str, exp: int) -> None:
|
||||
"""Consigne un jti émis pour ``sub`` (purge des expirés au passage)."""
|
||||
if not sub:
|
||||
return
|
||||
try:
|
||||
with _lock:
|
||||
store = _load_issued()
|
||||
now = int(time.time())
|
||||
for key in list(store.keys()):
|
||||
store[key] = {j: e for j, e in store[key].items() if e > now}
|
||||
if not store[key]:
|
||||
del store[key]
|
||||
store.setdefault(sub, {})[jti] = int(exp)
|
||||
_save_issued()
|
||||
except Exception: # le registre ne casse jamais une émission
|
||||
logger.warning("Registre des émissions draco non écrit", exc_info=True)
|
||||
|
||||
|
||||
def revoke_all_for_user(sub: str) -> int:
|
||||
"""Révoque tous les jti draco actifs émis pour ``sub``.
|
||||
|
||||
Retourne le nombre de jti révoqués. Utilisé par l'administration
|
||||
des comptes (HUB-13) : désactivation, suppression, réinitialisation
|
||||
du mot de passe.
|
||||
"""
|
||||
if not sub:
|
||||
return 0
|
||||
with _lock:
|
||||
store = _load_issued()
|
||||
tokens = store.pop(str(sub), {})
|
||||
now = int(time.time())
|
||||
actifs = {j: e for j, e in tokens.items() if e > now}
|
||||
if tokens:
|
||||
_save_issued()
|
||||
for jti, exp in actifs.items():
|
||||
revoke(jti, exp)
|
||||
return len(actifs)
|
||||
|
||||
|
||||
# ── Émission / vérification ─────────────────────────────────────────────
|
||||
|
||||
def issue_token(user: dict) -> tuple[str, str, int]:
|
||||
@@ -122,7 +193,9 @@ def issue_token(user: dict) -> tuple[str, str, int]:
|
||||
"exp": exp,
|
||||
"jti": jti,
|
||||
}
|
||||
return jwt.encode(claims, _secret(), algorithm=ALGORITHM), jti, exp
|
||||
token = jwt.encode(claims, _secret(), algorithm=ALGORITHM)
|
||||
_register_issued(str(claims["sub"]), jti, exp)
|
||||
return token, jti, exp
|
||||
|
||||
|
||||
def decode_claims(token: str) -> dict | None:
|
||||
|
||||
@@ -550,6 +550,7 @@ from backend.auth.router import router as auth_router
|
||||
from backend.bookslm_routes import router as bookslm_router
|
||||
from backend.routers.backups import router as backups_router
|
||||
from backend.routers.coffres import router as coffres_router
|
||||
from backend.routers.comptes_machine import router as comptes_machine_router
|
||||
from backend.routers.config import _load_config
|
||||
from backend.routers.config import router as config_router
|
||||
from backend.routers.conflicts import router as conflicts_router
|
||||
@@ -585,6 +586,7 @@ app.include_router(backups_router) # ROADMAP #85 T4 — Backups
|
||||
app.include_router(conflicts_router) # ROADMAP #85 T8 — Conflicts
|
||||
app.include_router(config_router) # ROADMAP #85 T7 — Config
|
||||
app.include_router(coffres_router) # Coffre Secrets Phase 1b — JAMAIS dans vaults.py
|
||||
app.include_router(comptes_machine_router) # HUB-13 (fusion #127) — Admin des comptes, machine-à-machine
|
||||
app.include_router(file_chat_router) # ROADMAP #169 — Chat par fichier
|
||||
app.include_router(files_read_router) # ROADMAP #85 T6a — Files read
|
||||
app.include_router(files_media_router) # ROADMAP #85 T6c — Media/export
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
# 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.
|
||||
#
|
||||
# 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 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 _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.
|
||||
"""
|
||||
sub = str(user.get("id") or user.get("username") or "")
|
||||
return draco_session.revoke_all_for_user(sub)
|
||||
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user