feat: émettrice SSO draco_session — cookie partagé .dracodev.net, renouvellement et révocation (#216)
This commit is contained in:
@@ -11,6 +11,7 @@ import re
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response
|
||||
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 record_account_failure as rl_record_account_failure
|
||||
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,
|
||||
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 {
|
||||
"access_token": access_token,
|
||||
# OAuth2 token_type, pas un mot de passe (B105) :
|
||||
@@ -396,6 +402,19 @@ async def logout(
|
||||
except Exception:
|
||||
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("access_token", path="/")
|
||||
response.delete_cookie("access_token", path="/api") # just in case
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
# 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)
|
||||
|
||||
|
||||
# ── 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)
|
||||
|
||||
# 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).
|
||||
# `allow_origins=[]` : le navigateur n'émet aucun `Access-Control-Allow-*`,
|
||||
# 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.search import router as search_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.webhooks import router as webhooks_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(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(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(search_router) # ROADMAP #85 T5 — Search
|
||||
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": "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": "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": "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)."},
|
||||
@@ -116,6 +117,7 @@ _TAG_RULES: list[tuple[re.Pattern[str], str]] = [
|
||||
(re.compile(r"^/api/recent"), "Bookmarks"),
|
||||
(re.compile(r"^/api/bookmarks"), "Bookmarks"),
|
||||
(re.compile(r"^/api/refs"), "ExternalRefs"),
|
||||
(re.compile(r"^/api/sso"), "SSO"),
|
||||
(re.compile(r"^/api/saved-searches"), "Bookmarks"),
|
||||
(re.compile(r"^/api/vaults"), "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()}
|
||||
Reference in New Issue
Block a user