diff --git a/CHANGELOG.md b/CHANGELOG.md index a370fb3..62993a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). > **En cours de développement** : les changements à venir sont listés dans la section -> [Unreleased](#unreleased). La dernière version livrée est **2.84.1**. +> [Unreleased](#unreleased). La dernière version livrée est **2.85.0**. --- @@ -14,6 +14,12 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.85.0] — 2026-11-06 + +- **HUB-22 — API machine des sessions draco (vague C du plan Hub v2.1, #227)** : trois nouvelles routes sous la garde machine existante (`DRACO_ADMIN_MACHINE_KEY`, en-têtes `X-Draco-Machine-Key` / `X-Draco-Actor` ; 401 sans clé valide, 404 si l'API n'est pas configurée ou le compte inconnu) — `GET /api/admin/machine/comptes/{username}/sessions` liste les sessions actives du compte (`jti`, `exp`, `expire_le` en ISO 8601 UTC, `revoquee`), `POST …/sessions/{jti}/revoquer` révoque une session (404 si le `jti` n'est pas dans l'ensemble actif de ce compte — jamais de révocation cross-utilisateur) et `POST …/sessions/revoquer-toutes` révoque toutes les sessions (déconnexion globale, **sans désactiver le compte**). Backend : `draco_session.issued_sessions_for_user()` (sessions actives triées par expiration, purge des expirés au passage) et `revoke_one_for_user()` (appartenance au `sub` vérifiée avant révocation), sur le registre des émissions de HUB-13 et la même clé d'identité (`id`, à défaut `username`). Écritures journalisées dans `data/audit.log` (`session_revoquee`, `sessions_revoquees`). Fiche : `docs/features/hub-sessions-machine-227.md`. Tests : `tests/test_comptes_machine.py` (`TestSessions`), `tests/test_draco_session.py`. + +--- + ## [2.84.1] — 2026-11-06 - **HUB-18 — Thème commun (fusion #132)** : barre commune re-vendangée **verbatim** en **v0.5.0** depuis dracodev-commons (branche `hub18-theme-commun`, PR commons #12) — `frontend/draco-barre.js` seul change côté barre (CSS et fragment inchangés). La barre apprend la préférence de thème du profil du Hub (`GET /api/profil/theme`, cookie draco ; `` = URL résolue de la barre, jamais figée) et l'applique à l'ouverture : cache `localStorage` `draco-theme-hub` (TTL 12 h) appliqué synchrone, puis fetch asynchrone non bloquant ; `sombre`/`clair` posent `data-theme`, `systeme` le retire (`prefers-color-scheme` décide). **Repli intégral** si le Hub ne répond pas, répond non-OK ou `theme: null` : le comportement actuel (choix local, puis système) reste seul maître. Initialisation propre à ObsiGate dans `frontend/app.js` : `initTheme()` écoute l'événement `draco:theme` de la barre et route le mode effectif par `applyTheme()` — icône et libellé du bouton de thème et feuilles highlight.js restent synchronisés, le mode appliqué devient le choix local mémorisé (`obsigate-theme`), et le bouton de bascule reste fonctionnel pour la session. Garde ajoutée à `tests/test_draco_barre.py`. diff --git a/README.fr.md b/README.fr.md index 36571c9..3924b74 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. -[![Version](https://img.shields.io/badge/Version-2.84.1-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.85.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -977,8 +977,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l ## 📝 Changelog -Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.84.1). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.85.0). --- -*Projet : ObsiGate | Version : 2.84.1 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.85.0 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index fc24394..b506289 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. -[![Version](https://img.shields.io/badge/Version-2.84.1-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.85.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -1152,8 +1152,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) ## 📝 Changelog -See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.84.1). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.85.0). --- -*Project: ObsiGate | Version: 2.84.1 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.85.0 | Last updated: September 2026* diff --git a/VERSION b/VERSION index 0bdeb73..af87173 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.84.1 +2.85.0 diff --git a/backend/draco_session.py b/backend/draco_session.py index 6b0e335..465ba6d 100644 --- a/backend/draco_session.py +++ b/backend/draco_session.py @@ -25,6 +25,10 @@ # 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(). +# HUB-22 (#227) ajoute la lecture de ce registre +# (issued_sessions_for_user) et la révocation unitaire contrôlée par +# appartenance (revoke_one_for_user), exposées par l'API machine des +# sessions (backend/routers/comptes_machine.py). from __future__ import annotations @@ -179,6 +183,52 @@ def revoke_all_for_user(sub: str) -> int: return len(actifs) +def issued_sessions_for_user(sub: str) -> list[dict]: + """Sessions draco actives émises pour ``sub`` (HUB-22, #227). + + Retourne ``[{jti, exp, revoquee}]`` triée par ``exp`` croissant, + où ``revoquee`` indique si le ``jti`` figure déjà dans la liste des + révocations. Les entrées expirées de cet utilisateur sont purgées + du registre au passage (même patron que ``_register_issued``). + """ + if not sub: + return [] + with _lock: + store = _load_issued() + now = int(time.time()) + tokens = store.get(str(sub), {}) + actifs = {j: e for j, e in tokens.items() if e > now} + if len(actifs) != len(tokens): + if actifs: + store[str(sub)] = actifs + else: + store.pop(str(sub), None) + _save_issued() + revoques = _load_revoked() + entrees = sorted(actifs.items(), key=lambda kv: kv[1]) + return [{"jti": jti, "exp": exp, "revoquee": jti in revoques} + for jti, exp in entrees] + + +def revoke_one_for_user(sub: str, jti: str) -> bool: + """Révoque un seul ``jti`` draco de ``sub`` (HUB-22, #227). + + Ne révoque (via ``revoke``) que si le ``jti`` appartient bien à + l'ensemble émis de ce ``sub`` et n'est pas expiré — jamais de + révocation d'une session d'un autre utilisateur. Retourne ``True`` + si la révocation a eu lieu, ``False`` sinon. + """ + if not sub or not jti: + return False + with _lock: + store = _load_issued() + exp = store.get(str(sub), {}).get(jti) + if exp is None or exp <= int(time.time()): + return False + revoke(jti, exp) + return True + + # ── Émission / vérification ───────────────────────────────────────────── def issue_token(user: dict) -> tuple[str, str, int]: 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} diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 19f1087..31b988c 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.84.1" +version = "2.85.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 90afab7..24be9fa 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.84.1" +version = "2.85.0" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index 3e2aafe..00e691e 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.84.1", + "version": "2.85.0", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 341348e..4d577ac 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.84.1 | **Dernière mise à jour :** 2026-11-06 +> **Version :** 2.85.0 | **Dernière mise à jour :** 2026-11-06 > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > vers les fonctionnalités livrées. > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** @@ -30,6 +30,24 @@ ## 🔵 En cours +### 227. Hub HUB-22 — API machine des sessions draco (liste et révocation) + +- **Effort :** ~0,5 jour | **Impact :** 🟡 | **Ouvert :** 2026-11-06 | **Statut :** ✅ livré en 2.85.0 (PR en attente de fusion — voir CHANGELOG ; redéploiement Docker requis) +- **Fiche :** [features/hub-sessions-machine-227.md](./features/hub-sessions-machine-227.md) +- **Description :** vague C du plan Hub v2.1 (HUB-22) : le Hub doit lister + les sessions actives d'un utilisateur et les révoquer une à une ou + toutes, via le canal `jti` livré en HUB-13 (#226). Trois routes sous + la même garde machine (`require_machine_admin`) et la même + journalisation d'audit que les routes comptes existantes : + `GET /api/admin/machine/comptes/{username}/sessions`, + `POST …/sessions/{jti}/revoquer` (jamais de révocation + cross-utilisateur) et `POST …/sessions/revoquer-toutes` + (déconnexion globale, sans désactiver le compte). +- **Sous-tâches :** + - [x] `issued_sessions_for_user` + `revoke_one_for_user` (`backend/draco_session.py`) + - [x] Trois routes sessions (`backend/routers/comptes_machine.py`) + - [x] Tests étendus (`tests/test_comptes_machine.py`, `tests/test_draco_session.py`) + ### 226. Hub HUB-13 — API d'administration des comptes (machine-à-machine) - **Effort :** ~0,5 jour | **Impact :** 🟡 | **Ouvert :** 2026-11-05 | **Statut :** ✅ livré en 2.84.0 (PR en attente de fusion — voir CHANGELOG ; redéploiement Docker requis) diff --git a/docs/features/hub-sessions-machine-227.md b/docs/features/hub-sessions-machine-227.md new file mode 100644 index 0000000..df7c293 --- /dev/null +++ b/docs/features/hub-sessions-machine-227.md @@ -0,0 +1,85 @@ +# #227 — HUB-22 : API machine des sessions draco (liste et révocation) + +**Carte :** vague C du plan Hub v2.1 (HUB-22) — le Hub consomme ces +routes en 0.17.0 pour l'écran des sessions d'un utilisateur. +**Version :** 2.85.0. **Prérequis :** HUB-13 (#226) — registre des +émissions `jti` et garde machine. + +## Objet + +Le Hub doit lister les sessions actives d'un utilisateur et les +révoquer une à une ou toutes, via le canal `jti` livré en HUB-13. +Aucune route machine « sessions » n'existait : cette livraison la crée, +sous la même garde (`require_machine_admin`, clé +`DRACO_ADMIN_MACHINE_KEY`, en-têtes `X-Draco-Machine-Key` / +`X-Draco-Actor`) et avec la même journalisation d'audit que les routes +comptes existantes. Sans la clé configurée, l'API reste inerte (404) ; +sans clé valide, 401. + +## Routes — `/api/admin/machine/comptes/{username}/sessions` + +| Méthode | Chemin | Effet | +|---|---|---| +| GET | `/api/admin/machine/comptes/{username}/sessions` | Sessions actives du compte | +| POST | `/api/admin/machine/comptes/{username}/sessions/{jti}/revoquer` | Révocation d'une session | +| POST | `/api/admin/machine/comptes/{username}/sessions/revoquer-toutes` | Révocation de toutes les sessions | + +Codes : 401 sans clé machine valide, 404 compte inconnu (ou API non +configurée), 404 `jti` absent de l'ensemble actif du compte. + +### Liste — `GET …/sessions` + +`200 {"username": …, "sessions": [{"jti", "exp", "expire_le", +"revoquee"}]}` — une entrée par `jti` émis et non expiré, triée par +`exp` croissant ; `expire_le` est l'échéance en ISO 8601 UTC et +`revoquee` signale un `jti` déjà présent dans la liste des +révocations. + +### Révocation unitaire — `POST …/sessions/{jti}/revoquer` + +`200 {"jti", "revoquee": true}`. Le `jti` n'est révoqué que s'il +appartient à l'ensemble émis du compte visé et n'est pas expiré : +**404 sinon, jamais de révocation cross-utilisateur**. + +### Tout révoquer — `POST …/sessions/revoquer-toutes` + +`200 {"revoquees": n}` — réutilise `revoke_all_for_user` de HUB-13. +C'est la « déconnexion globale », **distincte de la désactivation** : +le compte reste actif, une nouvelle connexion émet une nouvelle +session. + +## Backend — `backend/draco_session.py` + +- `issued_sessions_for_user(sub)` : sessions actives + (`exp > maintenant`) du `sub`, `[{jti, exp, revoquee}]` triées par + `exp` croissant ; les entrées expirées du `sub` sont purgées du + registre au passage. +- `revoke_one_for_user(sub, jti)` : révoque via le `revoke()` + existant uniquement si le `jti` appartient à l'ensemble émis de ce + `sub` et n'est pas expiré ; retourne un booléen. + +La clé du registre est le même `sub` que celui de HUB-13 +(`_identifiant_sessions` dans `comptes_machine.py` : `id` du compte, +à défaut `username`) — factorisée depuis `_revoquer_sessions`, qui +l'utilise aussi désormais. + +## Journalisation + +Les deux écritures sont consignées dans `data/audit.log` via +`log_compte_admin` : actions `session_revoquee` (détail : le `jti`) et +`sessions_revoquees` (détail : nombre de `jti` révoqués), acteur = +identité de service `X-Draco-Actor`. La lecture suit le patron des +lectures existantes (non journalisée). + +## Tests + +- `tests/test_draco_session.py` : liste triée et marquage `revoquee`, + purge des expirés, révocation unitaire, refus cross-utilisateur, + refus d'un `jti` expiré ; le registre des émissions est isolé en + fichier temporaire dans la fixture. +- `tests/test_comptes_machine.py` (`TestSessions`) : liste (contenu, + `expire_le` ISO 8601 UTC), liste vide, compte inconnu 404, + révocation unitaire (succès + effet sur `verify_token`), + cross-utilisateur 404 sans effet, tout-révoquer (compte toujours + actif, autres comptes intacts, seconde passe à 0), garde (401 sans + clé, 404 API non configurée), journalisation. diff --git a/package.json b/package.json index b99792c..ffc068e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.84.1", + "version": "2.85.0", "description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.", "main": "patch.js", "directories": { diff --git a/tests/test_comptes_machine.py b/tests/test_comptes_machine.py index f49332d..9607b49 100644 --- a/tests/test_comptes_machine.py +++ b/tests/test_comptes_machine.py @@ -354,6 +354,163 @@ class TestSuppression: assert resp.status_code == 404 +# ── Sessions draco (HUB-22) ───────────────────────────────────────────── + +def _emettre(client, username, nombre=1): + """Émet ``nombre`` jetons draco pour un compte ; retourne + [(jeton, jti, exp), …].""" + from backend import draco_session + + profil = client.get(f"{BASE}/{username}", + headers=MACHINE_HEADERS).json() + return [draco_session.issue_token( + {"id": profil["id"], "username": username, + "display_name": profil["display_name"]}) + for _ in range(nombre)] + + +class TestSessions: + def test_liste_sessions(self, machine_client): + from datetime import datetime + + assert _creer(machine_client).status_code == 201 + emis = _emettre(machine_client, "nouveau", 2) + + resp = machine_client.get(f"{BASE}/nouveau/sessions", + headers=MACHINE_HEADERS) + assert resp.status_code == 200 + data = resp.json() + assert data["username"] == "nouveau" + assert len(data["sessions"]) == 2 + jtis = [s["jti"] for s in data["sessions"]] + assert jtis == [j for _t, j, _e in emis] # exp croissant + for session, (_t, _j, exp) in zip(data["sessions"], emis): + assert session["exp"] == exp + assert session["revoquee"] is False + echeance = datetime.fromisoformat(session["expire_le"]) + assert echeance.tzinfo is not None + assert int(echeance.timestamp()) == exp + + def test_liste_sessions_vide_et_compte_inconnu(self, machine_client): + resp = machine_client.get(f"{BASE}/mfauser/sessions", + headers=MACHINE_HEADERS) + assert resp.status_code == 200 + assert resp.json() == {"username": "mfauser", "sessions": []} + resp = machine_client.get(f"{BASE}/personne/sessions", + headers=MACHINE_HEADERS) + assert resp.status_code == 404 + + def test_revocation_unitaire(self, machine_client): + from backend import draco_session + + assert _creer(machine_client).status_code == 201 + (t1, jti1, _e1), (t2, _jti2, _e2) = _emettre( + machine_client, "nouveau", 2) + + resp = machine_client.post( + f"{BASE}/nouveau/sessions/{jti1}/revoquer", + headers=MACHINE_HEADERS) + assert resp.status_code == 200 + assert resp.json() == {"jti": jti1, "revoquee": True} + + # La session révoquée est morte, l'autre vit ; la liste la + # signale comme révoquée. + assert draco_session.verify_token(t1) is None + assert draco_session.verify_token(t2) is not None + sessions = machine_client.get( + f"{BASE}/nouveau/sessions", headers=MACHINE_HEADERS).json() + etats = {s["jti"]: s["revoquee"] for s in sessions["sessions"]} + assert etats[jti1] is True + + def test_revocation_cross_utilisateur_404(self, machine_client): + from backend import draco_session + + assert _creer(machine_client).status_code == 201 + (token, jti, _e), = _emettre(machine_client, "nouveau", 1) + + # Le jti de « nouveau » demandé sous « mfauser » : 404, et la + # session de « nouveau » reste valide (jamais de révocation + # cross-utilisateur). + resp = machine_client.post( + f"{BASE}/mfauser/sessions/{jti}/revoquer", + headers=MACHINE_HEADERS) + assert resp.status_code == 404 + assert draco_session.verify_token(token) is not None + + # Jti inconnu et compte inconnu : 404 également. + resp = machine_client.post( + f"{BASE}/nouveau/sessions/jti-inconnu/revoquer", + headers=MACHINE_HEADERS) + assert resp.status_code == 404 + resp = machine_client.post( + f"{BASE}/personne/sessions/{jti}/revoquer", + headers=MACHINE_HEADERS) + assert resp.status_code == 404 + + def test_revoquer_toutes_sans_desactiver(self, machine_client): + from backend import draco_session + + assert _creer(machine_client).status_code == 201 + emis = _emettre(machine_client, "nouveau", 2) + (autre_token, _j, _e), = _emettre(machine_client, "mfauser", 1) + + resp = machine_client.post( + f"{BASE}/nouveau/sessions/revoquer-toutes", + headers=MACHINE_HEADERS) + assert resp.status_code == 200 + assert resp.json() == {"revoquees": 2} + + for token, _jti, _exp in emis: + assert draco_session.verify_token(token) is None + # Les sessions des autres comptes ne sont pas touchées. + assert draco_session.verify_token(autre_token) is not None + + # Plus rien à révoquer : le registre a été vidé pour ce compte + # (avant toute nouvelle connexion, qui émettrait une session). + resp = machine_client.post( + f"{BASE}/nouveau/sessions/revoquer-toutes", + headers=MACHINE_HEADERS) + assert resp.json() == {"revoquees": 0} + + # Déconnexion globale ≠ désactivation : le compte reste actif + # et une nouvelle connexion fonctionne. + profil = machine_client.get(f"{BASE}/nouveau", + headers=MACHINE_HEADERS).json() + assert profil["active"] is True + assert _login(machine_client, "nouveau", + "motdepasse-initial").status_code == 200 + + def test_sessions_protegees_par_cle_machine(self, machine_client, + monkeypatch): + assert machine_client.get( + f"{BASE}/nouveau/sessions").status_code == 401 + assert machine_client.post( + f"{BASE}/nouveau/sessions/un-jti/revoquer").status_code == 401 + assert machine_client.post( + f"{BASE}/nouveau/sessions/revoquer-toutes").status_code == 401 + monkeypatch.delenv("DRACO_ADMIN_MACHINE_KEY") + assert machine_client.get( + f"{BASE}/nouveau/sessions", + headers=MACHINE_HEADERS).status_code == 404 + + def test_sessions_journalisees(self, machine_client): + assert _creer(machine_client).status_code == 201 + (_t, jti, _e), = _emettre(machine_client, "nouveau", 1) + machine_client.post(f"{BASE}/nouveau/sessions/{jti}/revoquer", + headers=MACHINE_HEADERS) + machine_client.post(f"{BASE}/nouveau/sessions/revoquer-toutes", + headers=MACHINE_HEADERS) + + audit_file = Path("data/audit.log") + entrees = [json.loads(ligne) + for ligne in audit_file.read_text( + encoding="utf-8").splitlines() if ligne.strip()] + actions = {(e["action"], e["cible"]) for e in entrees + if e.get("cible") == "nouveau"} + assert ("session_revoquee", "nouveau") in actions + assert ("sessions_revoquees", "nouveau") in actions + + # ── Journalisation ────────────────────────────────────────────────────── class TestJournalisation: diff --git a/tests/test_draco_session.py b/tests/test_draco_session.py index a2949c3..7eeca91 100644 --- a/tests/test_draco_session.py +++ b/tests/test_draco_session.py @@ -18,9 +18,13 @@ def _draco_env(monkeypatch, tmp_path): monkeypatch.setenv("DRACO_SESSION_SECRET", "secret-de-test") monkeypatch.setattr(draco_session, "REVOKED_FILE", tmp_path / "draco_revoked.json") + monkeypatch.setattr(draco_session, "ISSUED_FILE", + tmp_path / "draco_issued.json") draco_session._revoked = None + draco_session._issued = None yield draco_session._revoked = None + draco_session._issued = None # ── Jeton : émission / vérification ───────────────────────────────────── @@ -232,3 +236,58 @@ def test_revocations_protegees_par_cle_machine(admin_client, monkeypatch): headers={"X-Draco-Machine-Key": "cle-machine"}) assert r.status_code == 200 assert {"jti": jti, "exp": exp} in r.json()["revoked"] + + +# ── Registre des sessions (HUB-22, #227) ──────────────────────────────── + +AUTRE = {"id": "user-2", "username": "autre", "display_name": "Autre"} + + +def test_sessions_emises_listees_triees_et_marquees(): + _t1, jti1, exp1 = draco_session.issue_token(USER) + t2, jti2, exp2 = draco_session.issue_token(USER) + draco_session.issue_token(AUTRE) # autre sub : exclu + draco_session.revoke(jti2, exp2) + + sessions = draco_session.issued_sessions_for_user("admin-1") + assert [s["jti"] for s in sessions] == [jti1, jti2] # exp croissant + assert sessions[0] == {"jti": jti1, "exp": exp1, "revoquee": False} + assert sessions[1] == {"jti": jti2, "exp": exp2, "revoquee": True} + assert draco_session.verify_token(t2) is None # cohérent + + +def test_sessions_emises_expirees_purgees(): + now = int(time.time()) + with draco_session._lock: + store = draco_session._load_issued() + store["admin-1"] = {"ancien": now - 10, "frais": now + 600} + assert [s["jti"] for s in + draco_session.issued_sessions_for_user("admin-1")] == ["frais"] + with draco_session._lock: + assert "ancien" not in draco_session._load_issued()["admin-1"] + assert draco_session.issued_sessions_for_user("personne") == [] + assert draco_session.issued_sessions_for_user("") == [] + + +def test_revoquer_une_session_appartenante(): + token, jti, _exp = draco_session.issue_token(USER) + assert draco_session.revoke_one_for_user("admin-1", jti) is True + assert draco_session.verify_token(token) is None + assert draco_session.is_revoked(jti) + + +def test_revoquer_une_session_cross_utilisateur_refusee(): + token, jti, _exp = draco_session.issue_token(USER) + # Le jti existe, mais pas dans l'ensemble émis de cet autre sub. + assert draco_session.revoke_one_for_user("user-2", jti) is False + assert draco_session.verify_token(token) is not None + assert draco_session.revoke_one_for_user("admin-1", "inconnu") is False + assert draco_session.revoke_one_for_user("", jti) is False + + +def test_revoquer_une_session_expiree_refusee(): + now = int(time.time()) + with draco_session._lock: + draco_session._load_issued()["admin-1"] = {"perime": now - 10} + assert draco_session.revoke_one_for_user("admin-1", "perime") is False + assert not draco_session.is_revoked("perime")