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:
Spark
2026-11-06 00:58:16 +00:00
parent b83de53447
commit 65d01f91b8
10 changed files with 983 additions and 5 deletions
+29
View File
@@ -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.
+9 -1
View File
@@ -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:
+10 -1
View File
@@ -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(),
+74 -1
View File
@@ -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:
+2
View File
@@ -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
+351
View File
@@ -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)