diff --git a/CHANGELOG.md b/CHANGELOG.md index 9891d51..6919c57 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.71.0**. +> [Unreleased](#unreleased). La dernière version livrée est **2.72.0**. --- @@ -14,6 +14,19 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.72.0] — 2026-10-20 + +### Ajouté + +- **Fusion ACC-3.2 (#217) — validation locale du jeton + `draco_session`** : `get_current_user` accepte le cookie partagé à + défaut de jeton natif valide ; le `sub` étant l'identifiant + ObsiGate, la correspondance vers le compte local est directe + (compte actif et garde-fou « changement de mot de passe » exigés). + Fiche : `docs/features/fusion-acc32-validation-locale-217.md`. + +--- + ## [2.71.0] — 2026-10-20 ### Ajouté diff --git a/README.fr.md b/README.fr.md index 53e4366..2103ce1 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.71.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.72.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.71.0). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.72.0). --- -*Projet : ObsiGate | Version : 2.71.0 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.72.0 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index ced0e50..aa55a3f 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.71.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.72.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.71.0). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.72.0). --- -*Project: ObsiGate | Version: 2.71.0 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.72.0 | Last updated: September 2026* diff --git a/VERSION b/VERSION index 6ab6ec3..9e0b850 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.71.0 +2.72.0 diff --git a/backend/auth/middleware.py b/backend/auth/middleware.py index 2aabd9c..cc94b81 100644 --- a/backend/auth/middleware.py +++ b/backend/auth/middleware.py @@ -9,6 +9,7 @@ import sys from fastapi import Depends, HTTPException, Request from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from backend import draco_session from backend.services.net import get_client_ip from .jwt_handler import decode_token, is_token_revoked, maybe_touch_api_token @@ -86,20 +87,22 @@ def get_current_user( token = request.cookies["access_token"] if not token: - return None + return _draco_fallback(request) payload = decode_token(token) if not payload or payload.get("type") != "access": - return None + return _draco_fallback(request) # BUG-027: access tokens revoked at logout must be rejected immediately. + # (Le repli draco reste sûr : la déconnexion globale révoque aussi + # le jti draco et efface son cookie.) jti = payload.get("jti") if jti and is_token_revoked(jti): - return None + return _draco_fallback(request) user = get_user(payload["sub"]) if not user or not user.get("active"): - return None + return _draco_fallback(request) # BUG-028: a password change invalidates every token issued before it. pca = user.get("password_changed_at") @@ -107,9 +110,9 @@ def get_current_user( if pca is not None and iat is not None: try: if int(iat) < int(float(pca)): - return None + return _draco_fallback(request) except (TypeError, ValueError): - return None + return _draco_fallback(request) # Attach vault permissions from the token (snapshot at login time) user["_token_vaults"] = payload.get("vaults", []) @@ -127,6 +130,17 @@ def get_current_user( return user +def _draco_fallback(request: Request) -> dict | None: + """Fusion ACC-3.2 (#9) : à défaut de jeton natif valide, le cookie + partagé draco_session authentifie l'utilisateur (sub = id + ObsiGate, correspondance directe — voir draco_session.user_for_draco). + """ + user = draco_session.user_for_draco(request) + if user is not None: + user["_request_ip"] = get_client_ip(request) + return user + + def require_auth(current_user=Depends(get_current_user)): """Dependency: require a valid authenticated user.""" if not current_user: diff --git a/backend/draco_session.py b/backend/draco_session.py index 788d30f..33d3148 100644 --- a/backend/draco_session.py +++ b/backend/draco_session.py @@ -175,6 +175,46 @@ def clear_cookie(response) -> None: response.delete_cookie(COOKIE_NAME, path="/", **extra) +# ── Validation locale (ACC-3.2, #9) ───────────────────────────────────── + +def user_for_draco(request) -> dict | None: + """Utilisateur ObsiGate prouvé par le cookie draco_session. + + ObsiGate est à la fois émettrice et consommatrice : le `sub` du + jeton EST son identifiant d'utilisateur — la correspondance est + directe, par id. Le garde-fou « jeton antérieur au dernier + changement de mot de passe » (BUG-028 côté natif) est appliqué + à l'identique via le claim `iat`. + """ + claims = verify_token(request.cookies.get(COOKIE_NAME, "")) + if not claims: + return None + sub = str(claims.get("sub", "")) + if not sub: + return None + from backend.auth.user_store import get_all_users + + for candidate in get_all_users(): + if str(candidate.get("id")) != sub: + continue + if not candidate.get("active"): + return None + pca = candidate.get("password_changed_at") + iat = claims.get("iat") + if pca is not None and iat is not None: + try: + if int(iat) < int(float(pca)): + return None + except (TypeError, ValueError): + return None + user = dict(candidate) + user["_token_vaults"] = candidate.get("vaults", []) + user["_token_scope"] = "full" + user["_draco_sub"] = sub + return user + return None + + # ── Renouvellement glissant (middleware) ──────────────────────────────── class DracoRenewalMiddleware: diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 1d5c5ba..36ff7dd 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.71.0" +version = "2.72.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 60180e8..5a1fa50 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.71.0" +version = "2.72.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 e9d611f..2e828a9 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.71.0", + "version": "2.72.0", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a6fe804..c5ea297 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.71.0 | **Dernière mise à jour :** 2026-10-20 +> **Version :** 2.72.0 | **Dernière mise à jour :** 2026-10-20 > **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)** @@ -79,6 +79,22 @@ re-validé côté frontend (`auth.js`) - [x] Tag OpenAPI « SSO », tests `test_draco_session.py` (22) +### 217. Fusion ACC-3.2 — validation locale du jeton `draco_session` + +- **Effort :** ~0,5 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-21 | **Statut :** ✅ livré le 2026-10-21 (2.72.0) +- **Fiche :** [features/fusion-acc32-validation-locale-217.md](./features/fusion-acc32-validation-locale-217.md) +- **Description :** pendant consommateur de #216 — `get_current_user` + accepte le cookie partagé à défaut de jeton natif valide ; le `sub` + étant l'identifiant ObsiGate, la correspondance vers le compte local + est directe (compte actif exigé, garde-fou BUG-028 appliqué). + Issue Gitea ObsiGate #9 ; épopée Projets/fusion-trois-apps#7. +- **Sous-tâches :** + - [x] `draco_session.user_for_draco()` : résolution par id, `_draco_sub` + - [x] Repli `_draco_fallback()` sur toutes les sorties « non + authentifié » du chemin natif (`backend/auth/middleware.py`) + - [x] Tests `test_draco_session.py` (+4 : cookie seul, révoqué, + sub inconnu, inerte sans secret) + ### 196. Partage dirigé entre utilisateurs (document → utilisateur) - **Effort :** 0,5-1 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-10 | **Statut :** ✅ livré le 2026-10-10 diff --git a/docs/features/fusion-acc32-validation-locale-217.md b/docs/features/fusion-acc32-validation-locale-217.md new file mode 100644 index 0000000..9c4f22d --- /dev/null +++ b/docs/features/fusion-acc32-validation-locale-217.md @@ -0,0 +1,51 @@ +# #217 — Validation locale du jeton draco_session (Fusion ACC-3.2) + +Statut : ✅ livré le 2026-10-21 (2.72.0) + +## Contexte + +Suite de l'émettrice SSO (#216, ACC-3.1). Ici, ObsiGate joue son rôle +de **consommatrice** : `get_current_user` accepte le cookie partagé +`draco_session` à défaut de jeton natif valide. C'est la brique qui +servira aussi à l'API de l'agent commun (ACC-4) pour authentifier les +requêtes SSO. Les validateurs des autres applications sont leurs +tickets respectifs (Flowdeck #21, TikTak #213 — TikTak 0.52.0). + +## Correspondance sub → compte local + +ObsiGate est émettrice ET consommatrice : le `sub` du jeton **est** +son identifiant d'utilisateur — la résolution est directe, par `id`, +dans `draco_session.user_for_draco()` : + +- signature/expiration/révocation vérifiées par `verify_token` (#216) ; +- compte **actif** exigé ; +- garde-fou BUG-028 appliqué à l'identique : un jeton draco émis + avant le dernier changement de mot de passe (`password_changed_at`) + est refusé ; +- le dict utilisateur retourné porte `_token_vaults` (droits du + compte), `_token_scope = "full"` et `_draco_sub` (traçabilité). + +## Branchement + +Dans `backend/auth/middleware.py`, chaque sortie « non authentifié » +du chemin natif (aucun jeton, jeton illisible, jti natif révoqué, +compte introuvable/inactif, jeton antérieur au changement de mot de +passe) passe par `_draco_fallback()`. Sûreté du repli après +déconnexion globale : le logout révoque aussi le `jti` draco et +efface le cookie (#216), donc un ancien cookie rejoué est refusé par +`verify_token`. + +## Limite connue + +Le repli authentifie les **appels d'API**. Le bootstrap d'une +session complète du SPA (jetons natifs émis à partir du seul cookie +draco) n'est pas couvert : pour ObsiGate, émettrice, la connexion +native EST la connexion SSO — le cas d'usage du repli est la +continuité d'API quand le jeton natif a expiré avant le cookie +partagé, et les consommateurs machine de l'API. + +## Tests + +`tests/test_draco_session.py` (+4) : `/api/auth/me` avec le seul +cookie draco (200, bon utilisateur), jti révoqué refusé, sub inconnu +refusé, inactivité sans secret configuré. diff --git a/package.json b/package.json index 6195c3d..6e74823 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.71.0", + "version": "2.72.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_draco_session.py b/tests/test_draco_session.py index 79047f3..a2949c3 100644 --- a/tests/test_draco_session.py +++ b/tests/test_draco_session.py @@ -185,6 +185,41 @@ def test_sso_authorize_liste_blanche(client): assert r.headers["location"] == "/" # refusé, pas de relais +# ── Validation locale (ACC-3.2, #9) ────────────────────────────────────── + +def test_me_avec_cookie_draco_seul(admin_client): + # Aucune session native : le cookie partagé suffit (sub = id). + token, _jti, _exp = draco_session.issue_token(USER) + admin_client.cookies.set("draco_session", token) + r = admin_client.get("/api/auth/me") + assert r.status_code == 200, r.text + assert r.json()["username"] == "admin" + + +def test_me_draco_revoque_refuse(admin_client): + token, jti, exp = draco_session.issue_token(USER) + draco_session.revoke(jti, exp) + admin_client.cookies.set("draco_session", token) + r = admin_client.get("/api/auth/me") + assert r.status_code == 401 + + +def test_me_draco_sub_inconnu_refuse(admin_client): + token, _j, _e = draco_session.issue_token( + {"id": "personne-99", "username": "x", "display_name": "X"}) + admin_client.cookies.set("draco_session", token) + r = admin_client.get("/api/auth/me") + assert r.status_code == 401 + + +def test_me_draco_inerte_sans_secret(admin_client, monkeypatch): + token, _j, _e = draco_session.issue_token(USER) + monkeypatch.delenv("DRACO_SESSION_SECRET") + admin_client.cookies.set("draco_session", token) + r = admin_client.get("/api/auth/me") + assert r.status_code == 401 + + def test_revocations_protegees_par_cle_machine(admin_client, monkeypatch): token, jti, exp = draco_session.issue_token(USER) draco_session.revoke(jti, exp)