Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68d3dd85a9 | ||
|
|
cb80b08f77 |
+30
-1
@@ -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).
|
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
|
> **En cours de développement** : les changements à venir sont listés dans la section
|
||||||
> [Unreleased](#unreleased). La dernière version livrée est **2.70.1**.
|
> [Unreleased](#unreleased). La dernière version livrée est **2.72.0**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -14,6 +14,35 @@ 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é
|
||||||
|
|
||||||
|
- **Fusion ACC-3.1 (#216) — émettrice SSO `draco_session`** : ObsiGate
|
||||||
|
émet le cookie de session partagé de l'écosystème dracodev (JWS
|
||||||
|
HS256, claims minimaux, 12 h, domaine `.dracodev.net`) à chaque
|
||||||
|
connexion, le renouvelle de façon glissante (middleware dédié), le
|
||||||
|
révoque à la déconnexion (liste des `jti` exposée aux autres apps
|
||||||
|
sous clé machine) et offre `GET /sso/authorize` avec `return_to`
|
||||||
|
en liste blanche anti open-redirect. Inerte tant que
|
||||||
|
`DRACO_SESSION_SECRET` n'est pas configuré. Fiche :
|
||||||
|
`docs/features/fusion-acc31-sso-draco-session-216.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [2.70.1] — 2026-10-20
|
## [2.70.1] — 2026-10-20
|
||||||
|
|
||||||
### Corrigé
|
### Corrigé
|
||||||
|
|||||||
+3
-3
@@ -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.
|
**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.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -977,8 +977,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 Changelog
|
||||||
|
|
||||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.70.1).
|
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.72.0).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Projet : ObsiGate | Version : 2.70.1 | Dernière mise à jour : Septembre 2026*
|
*Projet : ObsiGate | Version : 2.72.0 | Dernière mise à jour : Septembre 2026*
|
||||||
|
|||||||
@@ -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.
|
**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.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -1152,8 +1152,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 Changelog
|
||||||
|
|
||||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.70.1).
|
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.72.0).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Project: ObsiGate | Version: 2.70.1 | Last updated: September 2026*
|
*Project: ObsiGate | Version: 2.72.0 | Last updated: September 2026*
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import sys
|
|||||||
from fastapi import Depends, HTTPException, Request
|
from fastapi import Depends, HTTPException, Request
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
|
from backend import draco_session
|
||||||
from backend.services.net import get_client_ip
|
from backend.services.net import get_client_ip
|
||||||
|
|
||||||
from .jwt_handler import decode_token, is_token_revoked, maybe_touch_api_token
|
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"]
|
token = request.cookies["access_token"]
|
||||||
|
|
||||||
if not token:
|
if not token:
|
||||||
return None
|
return _draco_fallback(request)
|
||||||
|
|
||||||
payload = decode_token(token)
|
payload = decode_token(token)
|
||||||
if not payload or payload.get("type") != "access":
|
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.
|
# 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")
|
jti = payload.get("jti")
|
||||||
if jti and is_token_revoked(jti):
|
if jti and is_token_revoked(jti):
|
||||||
return None
|
return _draco_fallback(request)
|
||||||
|
|
||||||
user = get_user(payload["sub"])
|
user = get_user(payload["sub"])
|
||||||
if not user or not user.get("active"):
|
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.
|
# BUG-028: a password change invalidates every token issued before it.
|
||||||
pca = user.get("password_changed_at")
|
pca = user.get("password_changed_at")
|
||||||
@@ -107,9 +110,9 @@ def get_current_user(
|
|||||||
if pca is not None and iat is not None:
|
if pca is not None and iat is not None:
|
||||||
try:
|
try:
|
||||||
if int(iat) < int(float(pca)):
|
if int(iat) < int(float(pca)):
|
||||||
return None
|
return _draco_fallback(request)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return None
|
return _draco_fallback(request)
|
||||||
|
|
||||||
# Attach vault permissions from the token (snapshot at login time)
|
# Attach vault permissions from the token (snapshot at login time)
|
||||||
user["_token_vaults"] = payload.get("vaults", [])
|
user["_token_vaults"] = payload.get("vaults", [])
|
||||||
@@ -127,6 +130,17 @@ def get_current_user(
|
|||||||
return 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)):
|
def require_auth(current_user=Depends(get_current_user)):
|
||||||
"""Dependency: require a valid authenticated user."""
|
"""Dependency: require a valid authenticated user."""
|
||||||
if not current_user:
|
if not current_user:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import re
|
|||||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response
|
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response
|
||||||
from pydantic import BaseModel, validator
|
from pydantic import BaseModel, validator
|
||||||
|
|
||||||
|
from backend import draco_session
|
||||||
from backend.ratelimit import is_account_rate_limited, is_rate_limited
|
from backend.ratelimit import is_account_rate_limited, is_rate_limited
|
||||||
from backend.ratelimit import record_account_failure as rl_record_account_failure
|
from backend.ratelimit import record_account_failure as rl_record_account_failure
|
||||||
from backend.ratelimit import record_account_success as rl_record_account_success
|
from backend.ratelimit import record_account_success as rl_record_account_success
|
||||||
@@ -280,6 +281,11 @@ def _issue_tokens(user: dict, username: str, remember_me: bool, response: Respon
|
|||||||
secure=secure,
|
secure=secure,
|
||||||
path="/",
|
path="/",
|
||||||
)
|
)
|
||||||
|
# Fusion ACC-3.1 (#216) : ObsiGate est l'émettrice SSO — chaque
|
||||||
|
# émission de jetons natifs s'accompagne du cookie partagé
|
||||||
|
# draco_session (inerte tant que DRACO_SESSION_SECRET n'est pas
|
||||||
|
# configuré).
|
||||||
|
draco_session.set_cookie(response, user, secure)
|
||||||
return {
|
return {
|
||||||
"access_token": access_token,
|
"access_token": access_token,
|
||||||
# OAuth2 token_type, pas un mot de passe (B105) :
|
# OAuth2 token_type, pas un mot de passe (B105) :
|
||||||
@@ -396,6 +402,19 @@ async def logout(
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Fusion ACC-3.1 (#216) : déconnexion globale — révoquer le jti du
|
||||||
|
# cookie partagé et effacer le cookie sur le domaine parent.
|
||||||
|
draco_tok = request.cookies.get(draco_session.COOKIE_NAME)
|
||||||
|
if draco_tok:
|
||||||
|
draco_claims = draco_session.decode_claims(draco_tok)
|
||||||
|
if draco_claims and draco_claims.get("jti"):
|
||||||
|
try:
|
||||||
|
draco_session.revoke(str(draco_claims["jti"]),
|
||||||
|
int(draco_claims.get("exp", 0)))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
draco_session.clear_cookie(response)
|
||||||
|
|
||||||
response.delete_cookie("refresh_token", path="/api/auth/refresh")
|
response.delete_cookie("refresh_token", path="/api/auth/refresh")
|
||||||
response.delete_cookie("access_token", path="/")
|
response.delete_cookie("access_token", path="/")
|
||||||
response.delete_cookie("access_token", path="/api") # just in case
|
response.delete_cookie("access_token", path="/api") # just in case
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
# backend/draco_session.py
|
||||||
|
# Fusion ACC-3.1 (#216) — Émettrice SSO « draco_session » (option 2a, C2).
|
||||||
|
#
|
||||||
|
# ObsiGate est l'unique émettrice du cookie de session partagé
|
||||||
|
# ``draco_session`` (Domain=.dracodev.net) que les autres applications
|
||||||
|
# de l'écosystème (Flowdeck, TikTak, Kanbante) valideront localement
|
||||||
|
# (ACC-3.2, leurs propres tickets). Design : architecture Fusion §5.2 —
|
||||||
|
# JWS compact HS256, claims minimaux (sub, name, iat, exp 12 h, jti),
|
||||||
|
# aucun rôle dans le jeton, renouvellement glissant par l'émettrice,
|
||||||
|
# révocation par jti exposée aux autres apps.
|
||||||
|
#
|
||||||
|
# Configuration (environnement) :
|
||||||
|
# DRACO_SESSION_SECRET secret de signature partagé (obligatoire
|
||||||
|
# pour activer la fonction ; sans lui,
|
||||||
|
# aucun cookie n'est émis et rien ne change)
|
||||||
|
# DRACO_SESSION_SECRET_PREV ancien secret accepté en vérification
|
||||||
|
# pendant la fenêtre de rotation (24 h)
|
||||||
|
# DRACO_SESSION_COOKIE_DOMAIN domaine du cookie (défaut .dracodev.net ;
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
|
||||||
|
logger = logging.getLogger("obsigate.draco_session")
|
||||||
|
|
||||||
|
COOKIE_NAME = "draco_session"
|
||||||
|
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")
|
||||||
|
ALLOWED_RETURN_SUFFIX = "dracodev.net"
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_revoked: dict[str, int] | None = None # jti -> exp (chargé paresseusement)
|
||||||
|
|
||||||
|
|
||||||
|
def _secret() -> str:
|
||||||
|
return os.environ.get("DRACO_SESSION_SECRET", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _secret_prev() -> str:
|
||||||
|
return os.environ.get("DRACO_SESSION_SECRET_PREV", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _cookie_domain() -> str:
|
||||||
|
return os.environ.get("DRACO_SESSION_COOKIE_DOMAIN", ".dracodev.net")
|
||||||
|
|
||||||
|
|
||||||
|
def enabled() -> bool:
|
||||||
|
"""La fonction est active seulement si le secret partagé est configuré."""
|
||||||
|
return bool(_secret())
|
||||||
|
|
||||||
|
|
||||||
|
# ── Révocation (jti) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _load_revoked() -> dict[str, int]:
|
||||||
|
global _revoked
|
||||||
|
if _revoked is None:
|
||||||
|
try:
|
||||||
|
data = json.loads(REVOKED_FILE.read_text(encoding="utf-8"))
|
||||||
|
_revoked = {str(k): int(v) for k, v in data.items()} \
|
||||||
|
if isinstance(data, dict) else {}
|
||||||
|
except (OSError, ValueError):
|
||||||
|
_revoked = {}
|
||||||
|
return _revoked
|
||||||
|
|
||||||
|
|
||||||
|
def _save_revoked() -> None:
|
||||||
|
REVOKED_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = REVOKED_FILE.with_suffix(".tmp")
|
||||||
|
tmp.write_text(json.dumps(_revoked or {}), encoding="utf-8")
|
||||||
|
tmp.replace(REVOKED_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
def revoke(jti: str, exp: int) -> None:
|
||||||
|
"""Révoque un jeton par son jti (déconnexion globale)."""
|
||||||
|
with _lock:
|
||||||
|
store = _load_revoked()
|
||||||
|
store[jti] = int(exp)
|
||||||
|
now = int(time.time())
|
||||||
|
for k in [k for k, v in store.items() if v <= now]:
|
||||||
|
del store[k] # purge des expirés
|
||||||
|
_save_revoked()
|
||||||
|
|
||||||
|
|
||||||
|
def is_revoked(jti: str) -> bool:
|
||||||
|
with _lock:
|
||||||
|
return jti in _load_revoked()
|
||||||
|
|
||||||
|
|
||||||
|
def revoked_list() -> list[dict]:
|
||||||
|
"""Liste des révocations actives (pour les autres apps, cache 60 s)."""
|
||||||
|
with _lock:
|
||||||
|
now = int(time.time())
|
||||||
|
return [{"jti": jti, "exp": exp}
|
||||||
|
for jti, exp in _load_revoked().items() if exp > now]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Émission / vérification ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def issue_token(user: dict) -> tuple[str, str, int]:
|
||||||
|
"""Émet un JWS pour l'utilisateur. Retourne (jeton, jti, exp)."""
|
||||||
|
now = int(time.time())
|
||||||
|
exp = now + TOKEN_TTL_SECONDS
|
||||||
|
jti = uuid.uuid4().hex
|
||||||
|
claims = {
|
||||||
|
"sub": str(user.get("id") or user.get("username") or ""),
|
||||||
|
"name": str(user.get("display_name") or user.get("username") or ""),
|
||||||
|
"iat": now,
|
||||||
|
"exp": exp,
|
||||||
|
"jti": jti,
|
||||||
|
}
|
||||||
|
return jwt.encode(claims, _secret(), algorithm=ALGORITHM), jti, exp
|
||||||
|
|
||||||
|
|
||||||
|
def decode_claims(token: str) -> dict | None:
|
||||||
|
"""Vérifie signature et expiration (pas la révocation)."""
|
||||||
|
if not token or not enabled():
|
||||||
|
return None
|
||||||
|
for secret in (_secret(), _secret_prev()):
|
||||||
|
if not secret:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
return jwt.decode(token, secret, algorithms=[ALGORITHM])
|
||||||
|
except JWTError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def verify_token(token: str) -> dict | None:
|
||||||
|
"""decode_claims + contrôle de révocation du jti."""
|
||||||
|
claims = decode_claims(token)
|
||||||
|
if claims is None:
|
||||||
|
return None
|
||||||
|
if claims.get("jti") and is_revoked(str(claims["jti"])):
|
||||||
|
return None
|
||||||
|
return claims
|
||||||
|
|
||||||
|
|
||||||
|
def needs_renewal(claims: dict) -> bool:
|
||||||
|
"""Vrai si le jeton entre dans la fenêtre de renouvellement (< 2 h)."""
|
||||||
|
try:
|
||||||
|
return int(claims["exp"]) - int(time.time()) < RENEWAL_WINDOW_SECONDS
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def set_cookie(response, user: dict, secure: bool) -> None:
|
||||||
|
"""Pose le cookie draco_session sur une réponse (émission initiale)."""
|
||||||
|
if not enabled():
|
||||||
|
return
|
||||||
|
token, _jti, _exp = issue_token(user)
|
||||||
|
domain = _cookie_domain()
|
||||||
|
extra = {"domain": domain} if domain else {}
|
||||||
|
response.set_cookie(COOKIE_NAME, token, max_age=TOKEN_TTL_SECONDS,
|
||||||
|
httponly=True, samesite="lax", secure=secure,
|
||||||
|
path="/", **extra)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_cookie(response) -> None:
|
||||||
|
domain = _cookie_domain()
|
||||||
|
extra = {"domain": domain} if domain else {}
|
||||||
|
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:
|
||||||
|
"""Réémet le cookie quand l'utilisateur est actif chez l'émettrice.
|
||||||
|
|
||||||
|
Middleware ASGI pur (pas de BaseHTTPMiddleware : la pile d'ObsiGate
|
||||||
|
streame des réponses SSE/WS que BaseHTTPMiddleware mettrait en
|
||||||
|
tampon). On enveloppe l'envoi de la réponse : si la requête porte
|
||||||
|
un cookie d'accès natif valide et que le cookie draco est absent,
|
||||||
|
invalide ou dans sa fenêtre de renouvellement (< 2 h), on ajoute
|
||||||
|
un Set-Cookie frais aux en-têtes de la réponse. La révocation
|
||||||
|
ciblée d'un jti draco est ainsi « guérie » par la prochaine
|
||||||
|
activité authentifiée locale — la déconnexion globale, elle,
|
||||||
|
révoque aussi les jetons natifs, donc plus rien n'est réémis.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, app):
|
||||||
|
self.app = app
|
||||||
|
|
||||||
|
async def __call__(self, scope, receive, send):
|
||||||
|
if scope.get("type") != "http" or not enabled():
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
return
|
||||||
|
cookie_header = ""
|
||||||
|
for name, value in scope.get("headers") or []:
|
||||||
|
if name == b"cookie":
|
||||||
|
cookie_header = value.decode("latin-1")
|
||||||
|
break
|
||||||
|
cookies = {}
|
||||||
|
for part in cookie_header.split(";"):
|
||||||
|
if "=" in part:
|
||||||
|
k, v = part.strip().split("=", 1)
|
||||||
|
cookies[k] = v
|
||||||
|
user = self._user_from_native(cookies.get("access_token", ""))
|
||||||
|
if user is None:
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
return
|
||||||
|
claims = verify_token(cookies.get(COOKIE_NAME, "")) \
|
||||||
|
if cookies.get(COOKIE_NAME) else None
|
||||||
|
if claims is not None and not needs_renewal(claims):
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
token, _jti, _exp = issue_token(user)
|
||||||
|
domain = _cookie_domain()
|
||||||
|
cookie = (f"{COOKIE_NAME}={token}; Max-Age={TOKEN_TTL_SECONDS}; "
|
||||||
|
f"Path=/; HttpOnly; SameSite=lax")
|
||||||
|
if domain:
|
||||||
|
cookie += f"; Domain={domain}"
|
||||||
|
if scope.get("scheme") == "https":
|
||||||
|
cookie += "; Secure"
|
||||||
|
|
||||||
|
async def send_with_cookie(message):
|
||||||
|
if message.get("type") == "http.response.start":
|
||||||
|
headers = list(message.get("headers") or [])
|
||||||
|
headers.append((b"set-cookie", cookie.encode("latin-1")))
|
||||||
|
message = {**message, "headers": headers}
|
||||||
|
await send(message)
|
||||||
|
|
||||||
|
await self.app(scope, receive, send_with_cookie)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _user_from_native(access_token: str):
|
||||||
|
"""Utilisateur local prouvé par le cookie d'accès natif, ou None."""
|
||||||
|
if not access_token:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from backend.auth.jwt_handler import decode_token
|
||||||
|
from backend.auth.user_store import get_user
|
||||||
|
payload = decode_token(access_token)
|
||||||
|
if not payload or payload.get("type") != "access":
|
||||||
|
return None
|
||||||
|
return get_user(str(payload.get("sub", "")))
|
||||||
|
except Exception: # le renouvellement ne casse jamais une requête
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── return_to (anti open-redirect) ──────────────────────────────────────
|
||||||
|
|
||||||
|
def validate_return_to(url: str) -> str | None:
|
||||||
|
"""Accepte uniquement https vers dracodev.net ou un sous-domaine."""
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
parsed = urlparse(url)
|
||||||
|
host = (parsed.hostname or "").lower()
|
||||||
|
if parsed.scheme != "https" or not host:
|
||||||
|
return None
|
||||||
|
if host == ALLOWED_RETURN_SUFFIX or host.endswith("." + ALLOWED_RETURN_SUFFIX):
|
||||||
|
return url
|
||||||
|
return None
|
||||||
@@ -493,6 +493,12 @@ from backend.auth.scope import TokenScopeMiddleware
|
|||||||
|
|
||||||
app.add_middleware(TokenScopeMiddleware)
|
app.add_middleware(TokenScopeMiddleware)
|
||||||
|
|
||||||
|
# Fusion ACC-3.1 (#216) : renouvellement glissant du cookie SSO
|
||||||
|
# draco_session (inerte tant que DRACO_SESSION_SECRET n'est pas défini).
|
||||||
|
from backend.draco_session import DracoRenewalMiddleware
|
||||||
|
|
||||||
|
app.add_middleware(DracoRenewalMiddleware)
|
||||||
|
|
||||||
# Explicit same-origin CORS policy (#87 T8 — finit BUG-034).
|
# Explicit same-origin CORS policy (#87 T8 — finit BUG-034).
|
||||||
# `allow_origins=[]` : le navigateur n'émet aucun `Access-Control-Allow-*`,
|
# `allow_origins=[]` : le navigateur n'émet aucun `Access-Control-Allow-*`,
|
||||||
# donc toute lecture cross-origin est refusée (défense explicite, plus
|
# donc toute lecture cross-origin est refusée (défense explicite, plus
|
||||||
@@ -534,6 +540,7 @@ from backend.routers.realtime import router as realtime_router
|
|||||||
from backend.routers.scheduler import router as scheduler_router
|
from backend.routers.scheduler import router as scheduler_router
|
||||||
from backend.routers.search import router as search_router
|
from backend.routers.search import router as search_router
|
||||||
from backend.routers.sharing import router as sharing_router
|
from backend.routers.sharing import router as sharing_router
|
||||||
|
from backend.routers.sso import router as sso_router
|
||||||
from backend.routers.vaults import router as vaults_router
|
from backend.routers.vaults import router as vaults_router
|
||||||
from backend.routers.webhooks import router as webhooks_router
|
from backend.routers.webhooks import router as webhooks_router
|
||||||
from backend.skills_routes import router as skills_router
|
from backend.skills_routes import router as skills_router
|
||||||
@@ -545,6 +552,7 @@ app.include_router(skills_router)
|
|||||||
app.include_router(health_router) # ROADMAP #85 T1 — System / health
|
app.include_router(health_router) # ROADMAP #85 T1 — System / health
|
||||||
app.include_router(history_router) # ROADMAP #85 T8 — History
|
app.include_router(history_router) # ROADMAP #85 T8 — History
|
||||||
app.include_router(external_refs_router) # Fusion #215 — Références croisées (ACC-1.3)
|
app.include_router(external_refs_router) # Fusion #215 — Références croisées (ACC-1.3)
|
||||||
|
app.include_router(sso_router) # Fusion #216 — Émettrice SSO draco_session (ACC-3.1)
|
||||||
app.include_router(realtime_router) # ROADMAP #85 T9 — SSE + collab WS
|
app.include_router(realtime_router) # ROADMAP #85 T9 — SSE + collab WS
|
||||||
app.include_router(search_router) # ROADMAP #85 T5 — Search
|
app.include_router(search_router) # ROADMAP #85 T5 — Search
|
||||||
app.include_router(backups_router) # ROADMAP #85 T4 — Backups
|
app.include_router(backups_router) # ROADMAP #85 T4 — Backups
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ TAGS_METADATA: list[dict[str, str]] = [
|
|||||||
{"name": "Search", "description": "Full-text and advanced search, tags, title suggestions and the link graph."},
|
{"name": "Search", "description": "Full-text and advanced search, tags, title suggestions and the link graph."},
|
||||||
{"name": "Bookmarks", "description": "Recently opened files, bookmarks and saved searches."},
|
{"name": "Bookmarks", "description": "Recently opened files, bookmarks and saved searches."},
|
||||||
{"name": "ExternalRefs", "description": "Cross-application references from a note to Flowdeck, TikTak, Kanbante or ObsiGate objects (Fusion program, ACC-1.3)."},
|
{"name": "ExternalRefs", "description": "Cross-application references from a note to Flowdeck, TikTak, Kanbante or ObsiGate objects (Fusion program, ACC-1.3)."},
|
||||||
|
{"name": "SSO", "description": "draco_session single sign-on emitter: authorize entry point and jti revocation list for the other dracodev apps (Fusion program, ACC-3.1)."},
|
||||||
{"name": "Backups", "description": "Automatic file backups, diffs, restore, compression and purge."},
|
{"name": "Backups", "description": "Automatic file backups, diffs, restore, compression and purge."},
|
||||||
{"name": "Export", "description": "Export notes or whole vaults to HTML, Markdown bundle or ePub."},
|
{"name": "Export", "description": "Export notes or whole vaults to HTML, Markdown bundle or ePub."},
|
||||||
{"name": "Guide", "description": "Download the in-app user guide as Markdown or PDF (mirrors the help modal, FR/EN)."},
|
{"name": "Guide", "description": "Download the in-app user guide as Markdown or PDF (mirrors the help modal, FR/EN)."},
|
||||||
@@ -116,6 +117,7 @@ _TAG_RULES: list[tuple[re.Pattern[str], str]] = [
|
|||||||
(re.compile(r"^/api/recent"), "Bookmarks"),
|
(re.compile(r"^/api/recent"), "Bookmarks"),
|
||||||
(re.compile(r"^/api/bookmarks"), "Bookmarks"),
|
(re.compile(r"^/api/bookmarks"), "Bookmarks"),
|
||||||
(re.compile(r"^/api/refs"), "ExternalRefs"),
|
(re.compile(r"^/api/refs"), "ExternalRefs"),
|
||||||
|
(re.compile(r"^/api/sso"), "SSO"),
|
||||||
(re.compile(r"^/api/saved-searches"), "Bookmarks"),
|
(re.compile(r"^/api/saved-searches"), "Bookmarks"),
|
||||||
(re.compile(r"^/api/vaults"), "Vaults"),
|
(re.compile(r"^/api/vaults"), "Vaults"),
|
||||||
(re.compile(r"^/api/vault/"), "Vaults"),
|
(re.compile(r"^/api/vault/"), "Vaults"),
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# backend/routers/sso.py
|
||||||
|
# Fusion ACC-3.1 (#216) — Points d'entrée SSO de l'émettrice.
|
||||||
|
#
|
||||||
|
# GET /sso/authorize?return_to=<url> : point d'entrée des autres
|
||||||
|
# applications — valide le return_to (liste blanche *.dracodev.net,
|
||||||
|
# anti open-redirect) puis redirige vers la page de connexion
|
||||||
|
# d'ObsiGate en conservant le paramètre ; après connexion, le
|
||||||
|
# frontend repart vers l'application demanderesse (le cookie
|
||||||
|
# draco_session posé à la connexion couvre tout le domaine parent).
|
||||||
|
#
|
||||||
|
# GET /api/sso/revocations : liste des jti révoqués, réservée aux
|
||||||
|
# autres applications (clé machine DRACO_SSO_MACHINE_KEY, en-tête
|
||||||
|
# X-Draco-Machine-Key) qui la mettent en cache 60 s (design §5.2).
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
|
||||||
|
from backend import draco_session
|
||||||
|
|
||||||
|
router = APIRouter(tags=["SSO"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sso/authorize")
|
||||||
|
async def sso_authorize(return_to: str = ""):
|
||||||
|
target = draco_session.validate_return_to(return_to)
|
||||||
|
if target:
|
||||||
|
return RedirectResponse(
|
||||||
|
url=f"/?return_to={quote(target, safe='')}", status_code=302)
|
||||||
|
return RedirectResponse(url="/", status_code=302)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/sso/revocations")
|
||||||
|
async def sso_revocations(request: Request):
|
||||||
|
machine_key = os.environ.get("DRACO_SSO_MACHINE_KEY", "")
|
||||||
|
if not machine_key:
|
||||||
|
raise HTTPException(404, "Non configuré")
|
||||||
|
if request.headers.get("X-Draco-Machine-Key") != machine_key:
|
||||||
|
raise HTTPException(401, "Clé machine invalide")
|
||||||
|
return {"revoked": draco_session.revoked_list()}
|
||||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.70.1"
|
version = "2.72.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.70.1"
|
version = "2.72.0"
|
||||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||||
authors = ["Bruno Charest"]
|
authors = ["Bruno Charest"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||||
"productName": "ObsiGate",
|
"productName": "ObsiGate",
|
||||||
"version": "2.70.1",
|
"version": "2.72.0",
|
||||||
"identifier": "com.obsigate.desktop",
|
"identifier": "com.obsigate.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../frontend",
|
"frontendDist": "../frontend",
|
||||||
|
|||||||
+40
-1
@@ -1,6 +1,6 @@
|
|||||||
# ObsiGate — Roadmap
|
# ObsiGate — Roadmap
|
||||||
|
|
||||||
> **Version :** 2.70.1 | **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
|
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||||
> vers les fonctionnalités livrées.
|
> vers les fonctionnalités livrées.
|
||||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
||||||
@@ -56,6 +56,45 @@
|
|||||||
bandeau « Tâche TikTak projetée » + « Ouvrir dans TikTak »
|
bandeau « Tâche TikTak projetée » + « Ouvrir dans TikTak »
|
||||||
- [x] i18n FR/EN (clés `draco.links.*`), tests `test_external_refs.py`
|
- [x] i18n FR/EN (clés `draco.links.*`), tests `test_external_refs.py`
|
||||||
|
|
||||||
|
### 216. Fusion ACC-3.1 — émettrice SSO `draco_session`
|
||||||
|
|
||||||
|
- **Effort :** ~1 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-21 | **Statut :** ✅ livré le 2026-10-21 (2.71.0)
|
||||||
|
- **Fiche :** [features/fusion-acc31-sso-draco-session-216.md](./features/fusion-acc31-sso-draco-session-216.md)
|
||||||
|
- **Description :** couche 2 (identité) du programme **Fusion** — décision C2
|
||||||
|
de Bruno : cookie de session partagé sous `.dracodev.net` (architecture
|
||||||
|
v2.0 §5.2, option 2a). ObsiGate est l'unique émettrice ; les autres apps
|
||||||
|
valideront localement (ACC-3.2). Issue Gitea ObsiGate #8 ; épopée
|
||||||
|
Projets/fusion-trois-apps#7.
|
||||||
|
- **Sous-tâches :**
|
||||||
|
- [x] Module `backend/draco_session.py` : JWS HS256, claims minimaux
|
||||||
|
(sub, name, iat, exp 12 h, jti — aucun rôle), rotation par
|
||||||
|
`DRACO_SESSION_SECRET_PREV`, inerte sans `DRACO_SESSION_SECRET`
|
||||||
|
- [x] Émission du cookie à chaque `_issue_tokens` (mot de passe ou MFA)
|
||||||
|
- [x] Renouvellement glissant (middleware ASGI `DracoRenewalMiddleware`,
|
||||||
|
fenêtre 2 h, conditionné au cookie d'accès natif valide)
|
||||||
|
- [x] Déconnexion globale : révocation du jti (`data/draco_revoked.json`)
|
||||||
|
+ effacement du cookie ; `GET /api/sso/revocations` sous clé machine
|
||||||
|
- [x] `GET /sso/authorize` : `return_to` en liste blanche
|
||||||
|
`*.dracodev.net` (anti open-redirect), retour post-connexion
|
||||||
|
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)
|
### 196. Partage dirigé entre utilisateurs (document → utilisateur)
|
||||||
|
|
||||||
- **Effort :** 0,5-1 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-10 | **Statut :** ✅ livré le 2026-10-10
|
- **Effort :** 0,5-1 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-10 | **Statut :** ✅ livré le 2026-10-10
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# #216 — Émettrice SSO `draco_session` (Fusion ACC-3.1)
|
||||||
|
|
||||||
|
Statut : ✅ livré le 2026-10-21 (2.71.0)
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
Programme Fusion (accès centralisé ObsiGate / Flowdeck / TikTak /
|
||||||
|
Kanbante), couche 2 — identité. Décision **C2** de Bruno (2026-10-20) :
|
||||||
|
cookie de session partagé `draco_session` sous `.dracodev.net`
|
||||||
|
(architecture v2.0 §5.2, option 2a). ObsiGate est l'**unique
|
||||||
|
émettrice** ; les autres applications valideront le jeton localement
|
||||||
|
(ACC-3.2, leurs tickets respectifs) et redirigeront leur connexion
|
||||||
|
vers `GET /sso/authorize`.
|
||||||
|
|
||||||
|
## Ce qui est livré
|
||||||
|
|
||||||
|
- **`backend/draco_session.py`** — émission et vérification du JWS
|
||||||
|
(HS256, jose) : claims minimaux `sub` (id utilisateur ObsiGate),
|
||||||
|
`name`, `iat`, `exp` (**12 h**), `jti` ; **aucun rôle** dans le
|
||||||
|
jeton. Rotation : `DRACO_SESSION_SECRET_PREV` accepté en
|
||||||
|
vérification pendant la fenêtre de bascule.
|
||||||
|
- **Émission à la connexion** : `_issue_tokens` (auth/router.py) pose
|
||||||
|
le cookie à chaque émission de jetons natifs (mot de passe ou MFA) —
|
||||||
|
attributs `Domain=.dracodev.net; Path=/; HttpOnly; Secure (si la
|
||||||
|
requête est en https, via `is_secure_cookies`); SameSite=Lax`.
|
||||||
|
- **Renouvellement glissant** : `DracoRenewalMiddleware` (ASGI pur,
|
||||||
|
aucun tamponnage des flux SSE/WS) — si la requête porte un cookie
|
||||||
|
d'accès natif valide et que le cookie draco est absent, invalide ou
|
||||||
|
à moins de 2 h de son expiration, un cookie frais est ajouté à la
|
||||||
|
réponse.
|
||||||
|
- **Déconnexion globale** : le logout révoque le `jti` draco
|
||||||
|
(en plus des jetons natifs) et efface le cookie du domaine parent.
|
||||||
|
Révocations persistées dans `data/draco_revoked.json`
|
||||||
|
(jti → exp, purge des expirés), exposées aux autres apps par
|
||||||
|
**`GET /api/sso/revocations`** protégé par la clé machine
|
||||||
|
`DRACO_SSO_MACHINE_KEY` (en-tête `X-Draco-Machine-Key`, 404 si la
|
||||||
|
clé n'est pas configurée) — cache 60 s côté consommatrices.
|
||||||
|
- **`GET /sso/authorize?return_to=`** : valide le `return_to`
|
||||||
|
(https + `dracodev.net` ou sous-domaine, liste blanche stricte
|
||||||
|
anti open-redirect) puis redirige vers la connexion ObsiGate ;
|
||||||
|
après connexion, `frontend/js/auth.js` repart vers l'application
|
||||||
|
demanderesse (re-validation du domaine côté client).
|
||||||
|
- **Inerte par défaut** : sans `DRACO_SESSION_SECRET`, aucun cookie
|
||||||
|
n'est émis et le comportement d'ObsiGate est inchangé.
|
||||||
|
|
||||||
|
## Configuration (déploiement)
|
||||||
|
|
||||||
|
| Variable | Rôle |
|
||||||
|
|---|---|
|
||||||
|
| `DRACO_SESSION_SECRET` | Secret partagé de signature — **le même** devra être configuré dans Flowdeck et TikTak pour leurs validateurs ACC-3.2. À générer fort (ex. 64 hex) et à ne jamais committer. |
|
||||||
|
| `DRACO_SESSION_SECRET_PREV` | Ancien secret pendant une rotation (24 h). |
|
||||||
|
| `DRACO_SESSION_COOKIE_DOMAIN` | Domaine du cookie (défaut `.dracodev.net` ; vide = hôte seul, dev local). |
|
||||||
|
| `DRACO_SSO_MACHINE_KEY` | Clé machine de lecture des révocations, à partager avec les apps consommatrices. |
|
||||||
|
|
||||||
|
⚠️ **Déploiement** : la fonction ne s'active qu'au redéploiement
|
||||||
|
d'ObsiGate ≥ 2.71.0 **avec** `DRACO_SESSION_SECRET` posé dans
|
||||||
|
l'environnement du conteneur. Le SSO complet exige ensuite les
|
||||||
|
validateurs ACC-3.2 (ObsiGate #9, Flowdeck #21, TikTak #213).
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
`tests/test_draco_session.py` — 22 tests : claims minimaux, durée de
|
||||||
|
vie, falsification, expiration, rotation, révocation, fenêtre de
|
||||||
|
renouvellement, liste blanche `return_to` (8 cas), cookie posé à la
|
||||||
|
connexion (attributs), inactivité sans secret, révocation au logout,
|
||||||
|
renouvellement glissant via middleware, endpoint de révocations
|
||||||
|
(404/401/200).
|
||||||
@@ -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é.
|
||||||
@@ -121,6 +121,25 @@ const AuthManager = {
|
|||||||
if (tokenData.user) {
|
if (tokenData.user) {
|
||||||
sessionStorage.setItem(this.USER_KEY, JSON.stringify(tokenData.user));
|
sessionStorage.setItem(this.USER_KEY, JSON.stringify(tokenData.user));
|
||||||
}
|
}
|
||||||
|
this._honorReturnTo();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Fusion ACC-3.1 (#216) : après connexion chez l'émettrice SSO,
|
||||||
|
// repartir vers l'application demanderesse. Le return_to est
|
||||||
|
// re-validé ici (https + domaine dracodev.net, jamais l'hôte
|
||||||
|
// courant) en plus de la validation serveur de /sso/authorize.
|
||||||
|
_honorReturnTo() {
|
||||||
|
try {
|
||||||
|
const rt = new URLSearchParams(window.location.search).get("return_to");
|
||||||
|
if (!rt) return;
|
||||||
|
const u = new URL(rt);
|
||||||
|
const okHost = u.hostname === "dracodev.net"
|
||||||
|
|| u.hostname.endsWith(".dracodev.net");
|
||||||
|
if (u.protocol === "https:" && okHost
|
||||||
|
&& u.hostname !== window.location.hostname) {
|
||||||
|
window.location.replace(rt);
|
||||||
|
}
|
||||||
|
} catch (e) { /* return_to illisible : on reste sur place */ }
|
||||||
},
|
},
|
||||||
|
|
||||||
getToken() {
|
getToken() {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "obsigate",
|
"name": "obsigate",
|
||||||
"version": "2.70.1",
|
"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.",
|
"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",
|
"main": "patch.js",
|
||||||
"directories": {
|
"directories": {
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
# tests/test_draco_session.py
|
||||||
|
# Fusion ACC-3.1 (#216) — émettrice SSO draco_session : émission à la
|
||||||
|
# connexion, vérification (signature, expiration, révocation, rotation),
|
||||||
|
# renouvellement glissant, return_to en liste blanche, révocations.
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from jose import jwt as jose_jwt
|
||||||
|
|
||||||
|
from backend import draco_session
|
||||||
|
|
||||||
|
USER = {"id": "admin-1", "username": "admin", "display_name": "Admin"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
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")
|
||||||
|
draco_session._revoked = None
|
||||||
|
yield
|
||||||
|
draco_session._revoked = None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Jeton : émission / vérification ─────────────────────────────────────
|
||||||
|
|
||||||
|
def test_roundtrip_claims_minimaux():
|
||||||
|
token, jti, exp = draco_session.issue_token(USER)
|
||||||
|
claims = draco_session.verify_token(token)
|
||||||
|
assert claims is not None
|
||||||
|
assert claims["sub"] == "admin-1"
|
||||||
|
assert claims["name"] == "Admin"
|
||||||
|
assert claims["jti"] == jti
|
||||||
|
assert exp - claims["iat"] == 12 * 3600 # durée de vie du design §5.2
|
||||||
|
assert "role" not in claims # aucun rôle dans le jeton
|
||||||
|
|
||||||
|
|
||||||
|
def test_jeton_falsifie_rejete():
|
||||||
|
token, _j, _e = draco_session.issue_token(USER)
|
||||||
|
assert draco_session.verify_token(token + "x") is None
|
||||||
|
autre = jose_jwt.encode({"sub": "admin-1"}, "mauvais-secret",
|
||||||
|
algorithm="HS256")
|
||||||
|
assert draco_session.verify_token(autre) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_jeton_expire_rejete():
|
||||||
|
now = int(time.time())
|
||||||
|
token = jose_jwt.encode(
|
||||||
|
{"sub": "admin-1", "name": "Admin", "iat": now - 7200,
|
||||||
|
"exp": now - 3600, "jti": "ancien"},
|
||||||
|
"secret-de-test", algorithm="HS256")
|
||||||
|
assert draco_session.verify_token(token) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_rotation_secret_precedent_accepte(monkeypatch):
|
||||||
|
token = jose_jwt.encode(
|
||||||
|
{"sub": "admin-1", "name": "Admin", "iat": int(time.time()),
|
||||||
|
"exp": int(time.time()) + 3600, "jti": "rotation"},
|
||||||
|
"ancien-secret", algorithm="HS256")
|
||||||
|
assert draco_session.verify_token(token) is None # pas encore configuré
|
||||||
|
monkeypatch.setenv("DRACO_SESSION_SECRET_PREV", "ancien-secret")
|
||||||
|
assert draco_session.verify_token(token)["jti"] == "rotation"
|
||||||
|
|
||||||
|
|
||||||
|
def test_revocation():
|
||||||
|
token, jti, exp = draco_session.issue_token(USER)
|
||||||
|
assert draco_session.verify_token(token) is not None
|
||||||
|
draco_session.revoke(jti, exp)
|
||||||
|
assert draco_session.verify_token(token) is None
|
||||||
|
assert {"jti": jti, "exp": exp} in draco_session.revoked_list()
|
||||||
|
|
||||||
|
|
||||||
|
def test_inerte_sans_secret(monkeypatch):
|
||||||
|
monkeypatch.delenv("DRACO_SESSION_SECRET")
|
||||||
|
assert draco_session.enabled() is False
|
||||||
|
assert draco_session.verify_token("peu-importe") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_fenetre_de_renouvellement():
|
||||||
|
assert draco_session.needs_renewal({"exp": int(time.time()) + 1800})
|
||||||
|
assert not draco_session.needs_renewal({"exp": int(time.time()) + 12 * 3600})
|
||||||
|
|
||||||
|
|
||||||
|
# ── return_to : liste blanche ────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("url,ok", [
|
||||||
|
("https://tiktak.dracodev.net/?task=3", True),
|
||||||
|
("https://og.dracodev.net/", True),
|
||||||
|
("https://dracodev.net/x", True),
|
||||||
|
("http://tiktak.dracodev.net/", False), # https seulement
|
||||||
|
("https://evil.com/", False),
|
||||||
|
("https://tiktak.dracodev.net.evil.com/", False),
|
||||||
|
("https://notdracodev.net/", False),
|
||||||
|
("", False),
|
||||||
|
])
|
||||||
|
def test_validate_return_to(url, ok):
|
||||||
|
assert (draco_session.validate_return_to(url) is not None) == ok
|
||||||
|
|
||||||
|
|
||||||
|
# ── Intégration : connexion / déconnexion / renouvellement ──────────────
|
||||||
|
|
||||||
|
def _login(client):
|
||||||
|
r = client.post("/api/auth/login",
|
||||||
|
json={"username": "admin", "password": "chab30"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def _draco_cookie(response):
|
||||||
|
for raw in response.headers.get_list("set-cookie"):
|
||||||
|
if raw.startswith("draco_session="):
|
||||||
|
return raw
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_pose_le_cookie(admin_client):
|
||||||
|
r = _login(admin_client)
|
||||||
|
cookie = _draco_cookie(r)
|
||||||
|
assert cookie is not None
|
||||||
|
assert "Domain=.dracodev.net" in cookie
|
||||||
|
assert "HttpOnly" in cookie and "SameSite=lax" in cookie
|
||||||
|
token = cookie.split(";")[0].split("=", 1)[1]
|
||||||
|
assert draco_session.verify_token(token)["sub"] == "admin-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_sans_secret_aucun_cookie(admin_client, monkeypatch):
|
||||||
|
monkeypatch.delenv("DRACO_SESSION_SECRET")
|
||||||
|
r = _login(admin_client)
|
||||||
|
assert _draco_cookie(r) is None
|
||||||
|
|
||||||
|
|
||||||
|
def _token_depuis_login(client):
|
||||||
|
# Le jar du TestClient refuse le cookie Domain=.dracodev.net (hôte
|
||||||
|
# de test différent) : on lit le jeton dans l'en-tête set-cookie.
|
||||||
|
cookie = _draco_cookie(_login(client))
|
||||||
|
assert cookie is not None
|
||||||
|
return cookie.split(";")[0].split("=", 1)[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_logout_revoque_le_jti(admin_client):
|
||||||
|
token = _token_depuis_login(admin_client)
|
||||||
|
assert draco_session.verify_token(token)
|
||||||
|
r = admin_client.post("/api/auth/logout",
|
||||||
|
headers={"Cookie": f"draco_session={token}"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert draco_session.verify_token(token) is None # révoqué
|
||||||
|
|
||||||
|
|
||||||
|
def test_renouvellement_glissant(admin_client):
|
||||||
|
_login(admin_client)
|
||||||
|
# Cookie draco presque expiré (dans la fenêtre des 2 h) :
|
||||||
|
now = int(time.time())
|
||||||
|
vieux = jose_jwt.encode(
|
||||||
|
{"sub": "admin-1", "name": "Admin", "iat": now - 11 * 3600,
|
||||||
|
"exp": now + 600, "jti": "vieux-jti"},
|
||||||
|
"secret-de-test", algorithm="HS256")
|
||||||
|
admin_client.cookies.set("draco_session", vieux)
|
||||||
|
r = admin_client.get("/api/auth/me")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
cookie = _draco_cookie(r)
|
||||||
|
assert cookie is not None # réémis
|
||||||
|
nouveau = cookie.split(";")[0].split("=", 1)[1]
|
||||||
|
assert draco_session.verify_token(nouveau)["jti"] != "vieux-jti"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pas_de_renouvellement_si_frais(admin_client):
|
||||||
|
token = _token_depuis_login(admin_client)
|
||||||
|
admin_client.cookies.set("draco_session", token) # cookie frais porté
|
||||||
|
r = admin_client.get("/api/auth/me")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert _draco_cookie(r) is None # cookie frais : rien
|
||||||
|
|
||||||
|
|
||||||
|
def test_sso_authorize_liste_blanche(client):
|
||||||
|
r = client.get("/sso/authorize",
|
||||||
|
params={"return_to": "https://tiktak.dracodev.net/?task=3"},
|
||||||
|
follow_redirects=False)
|
||||||
|
assert r.status_code == 302
|
||||||
|
assert "return_to=" in r.headers["location"]
|
||||||
|
r = client.get("/sso/authorize",
|
||||||
|
params={"return_to": "https://evil.com/phish"},
|
||||||
|
follow_redirects=False)
|
||||||
|
assert r.status_code == 302
|
||||||
|
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)
|
||||||
|
r = admin_client.get("/api/sso/revocations")
|
||||||
|
assert r.status_code == 404 # clé non configurée
|
||||||
|
monkeypatch.setenv("DRACO_SSO_MACHINE_KEY", "cle-machine")
|
||||||
|
r = admin_client.get("/api/sso/revocations")
|
||||||
|
assert r.status_code == 401 # en-tête absent
|
||||||
|
r = admin_client.get("/api/sso/revocations",
|
||||||
|
headers={"X-Draco-Machine-Key": "cle-machine"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert {"jti": jti, "exp": exp} in r.json()["revoked"]
|
||||||
Reference in New Issue
Block a user