diff --git a/backend/routers/comptes_machine.py b/backend/routers/comptes_machine.py index 2fabb82..0655532 100644 --- a/backend/routers/comptes_machine.py +++ b/backend/routers/comptes_machine.py @@ -7,6 +7,12 @@ # 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 @@ -36,6 +42,7 @@ 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 @@ -122,6 +129,16 @@ def _compte_ou_404(username: str) -> dict: 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. @@ -132,8 +149,7 @@ def _revoquer_sessions(user: dict) -> int: 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) + return draco_session.revoke_all_for_user(_identifiant_sessions(user)) def _audit(request: Request, acteur: str, action: str, cible: str, @@ -349,3 +365,60 @@ async def reinitialiser_mfa(username: str, request: Request, _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}