Files
flowdeck/app/services/draco.py
T
Spark 1ce9f691a6
FlowDeck CI / lint (push) Failing after 2m8s
FlowDeck CI / test (push) Failing after 20m43s
FlowDeck CI / docker (push) Skipped
feat: Fusion ACC-3.2 — pont SSO draco_session (/auth/draco), liens sub → compte (migration 51) (#21)
2026-10-20 23:56:24 -04:00

218 lines
7.0 KiB
Python

"""Validation locale du cookie SSO ``draco_session`` — Fusion ACC-3.2.
Flowdeck valide localement le jeton émis par ObsiGate (l'émettrice,
ACC-3.1) : JWS HS256 vérifié avec le secret partagé
(``DRACO_SESSION_SECRET``, ``_PREV`` accepté en rotation), expiration,
et liste de révocation de l'émettrice (cache 60 s, interrogée
seulement si un cookie draco est présenté). Vérification faite avec
hmac/hashlib, algorithme épinglé — mêmes sémantiques que le
validateur de TikTak.
Le pont ``GET /auth/draco`` (routers/auth.py) convertit un cookie
valide en session Flowdeck native. Correspondance ``sub`` → compte
local : table ``draco_links`` (migration 51), créée automatiquement
à la première visite où la session native et un cookie draco valide
coexistent (association 1:1, dissociation = pierre tombale) ; à
défaut de lien, une instance à un seul utilisateur actif associe ce
sub à cet unique compte. Sans secret configuré, module inerte.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import logging
import os
import time
from app.db import get_conn
logger = logging.getLogger(__name__)
COOKIE_NAME = "draco_session"
_REVOCATIONS_TTL = 60 # secondes de cache, comme le prévoit le design
_revoked_cache: dict[str, int] = {}
_revoked_fetched_at: float = 0.0
def _secret() -> str:
return os.environ.get("DRACO_SESSION_SECRET", "")
def _secret_prev() -> str:
return os.environ.get("DRACO_SESSION_SECRET_PREV", "")
def enabled() -> bool:
return bool(_secret())
def _b64decode(segment: str) -> bytes:
return base64.urlsafe_b64decode(segment + "=" * (-len(segment) % 4))
def verify_token(token: str) -> dict | None:
"""Vérifie un JWS HS256 (signature, alg épinglé, expiration)."""
if not token or not enabled():
return None
parts = token.split(".")
if len(parts) != 3:
return None
try:
header = json.loads(_b64decode(parts[0]))
if not isinstance(header, dict) or header.get("alg") != "HS256":
return None
signing_input = f"{parts[0]}.{parts[1]}".encode("ascii")
signature = _b64decode(parts[2])
claims = json.loads(_b64decode(parts[1]))
except Exception:
return None
if not isinstance(claims, dict):
return None
for secret in (_secret(), _secret_prev()):
if not secret:
continue
attendu = hmac.new(secret.encode("utf-8"), signing_input,
hashlib.sha256).digest()
if hmac.compare_digest(attendu, signature):
break
else:
return None
try:
if int(claims.get("exp", 0)) <= int(time.time()):
return None
except (TypeError, ValueError):
return None
return claims
# ── Révocations (cache 60 s alimenté par l'émettrice) ───────────────────
def _refresh_revocations() -> None:
global _revoked_fetched_at
now = time.monotonic()
if now - _revoked_fetched_at < _REVOCATIONS_TTL:
return
_revoked_fetched_at = now
base = os.environ.get("DRACO_SSO_URL", "https://og.dracodev.net")
try:
import httpx
headers = {}
machine_key = os.environ.get("DRACO_SSO_MACHINE_KEY", "")
if machine_key:
headers["X-Draco-Machine-Key"] = machine_key
r = httpx.get(f"{base}/api/sso/revocations", headers=headers,
timeout=2.0)
if r.status_code == 200:
_revoked_cache.clear()
for item in r.json().get("revoked", []):
_revoked_cache[str(item["jti"])] = int(item["exp"])
except Exception as exc: # l'émettrice injoignable ne bloque pas le SSO
logger.debug("Révocations draco indisponibles: %s", exc)
def is_revoked(jti: str) -> bool:
_refresh_revocations()
return jti in _revoked_cache
# ── Résolution sub → utilisateur local ──────────────────────────────────
def _linked_user(conn, sub: str) -> dict | None:
row = conn.execute(
"SELECT u.* FROM draco_links l JOIN users u ON u.id = l.user_id "
"WHERE l.sub = ? AND l.unlinked_at IS NULL",
(sub,),
).fetchone()
if row is not None:
return dict(row)
users = conn.execute("SELECT * FROM users WHERE is_active = 1").fetchall()
if len(users) == 1:
return dict(users[0])
return None
def user_for_draco(request) -> dict | None:
"""Utilisateur Flowdeck prouvé par le cookie draco_session, ou None."""
if not enabled():
return None
claims = verify_token(request.cookies.get(COOKIE_NAME, ""))
if not claims:
return None
jti = str(claims.get("jti", ""))
if jti and is_revoked(jti):
return None
sub = str(claims.get("sub", ""))
if not sub:
return None
with get_conn() as conn:
user = _linked_user(conn, sub)
if user is not None and not user.get("is_active"):
return None
return user
def maybe_link(request, user: dict | None) -> None:
"""Associe sub ↔ utilisateur quand les deux sessions coexistent (1:1)."""
if not enabled() or not user:
return
claims = verify_token(request.cookies.get(COOKIE_NAME, ""))
if not claims:
return
sub = str(claims.get("sub", ""))
if not sub:
return
try:
with get_conn() as conn:
conn.execute(
"INSERT OR IGNORE INTO draco_links (sub, user_id) VALUES (?, ?)",
(sub, user["id"]),
)
conn.commit()
except Exception as exc: # l'association ne casse jamais la requête
logger.debug("Lien draco non créé: %s", exc)
def current_link(user_id: int) -> str | None:
with get_conn() as conn:
row = conn.execute(
"SELECT sub FROM draco_links WHERE user_id = ? AND unlinked_at IS NULL",
(user_id,),
).fetchone()
return row["sub"] if row else None
def unlink(user_id: int) -> None:
"""Dissocie : pierre tombale (empêche la re-création automatique)."""
with get_conn() as conn:
conn.execute(
"UPDATE draco_links SET unlinked_at = datetime('now') WHERE user_id = ?",
(user_id,),
)
conn.commit()
def relink(request, user: dict) -> str | None:
"""(Ré)associe le sub du cookie draco de la requête au compte.
Retourne le sub associé, ou None si le cookie est invalide ou si
le sub est déjà lié à un autre compte.
"""
claims = verify_token(request.cookies.get(COOKIE_NAME, ""))
if not claims or not claims.get("sub"):
return None
sub = str(claims["sub"])
with get_conn() as conn:
conn.execute(
"INSERT INTO draco_links (sub, user_id, unlinked_at) VALUES (?, ?, NULL) "
"ON CONFLICT(sub) DO UPDATE SET unlinked_at = NULL "
"WHERE draco_links.user_id = excluded.user_id",
(sub, user["id"]),
)
conn.commit()
return sub if current_link(user["id"]) == sub else None