Files
ObsiGate/backend/draco_session.py
T
Spark 68d3dd85a9
CI / lint (push) Successful in 3m34s
CI / security (push) Successful in 2m17s
CI / test (push) Successful in 5m38s
CI / build (push) Successful in 2m15s
CI / e2e (push) Successful in 18m31s
feat: validation locale du jeton draco_session — repli SSO dans get_current_user (#217)
2026-10-20 23:47:45 -04:00

308 lines
11 KiB
Python

# 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