FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (push) Failing after 12s
FlowDeck CI / test (pull_request) Failing after 2s
FlowDeck CI / docker (pull_request) Skipped
FlowDeck CI / lint (pull_request) Failing after 14s
336 lines
12 KiB
Python
336 lines
12 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.
|
|
|
|
HUB-15 (plan Hub v2.1, §2.2) — provisionnement JIT : si aucun compte
|
|
local ne correspond au ``sub`` (ni lien, ni repli mono-utilisateur),
|
|
le pendant local est **créé automatiquement** à la première
|
|
connexion draco, depuis les claims (``sub``, ``name``), avec le rôle
|
|
par défaut sûr : utilisateur standard (``is_admin = 0``, jamais
|
|
admin), aucun mot de passe local (``password_hash`` NULL,
|
|
``auth_method = 'draco'`` — l'authentification passe par draco, la
|
|
connexion locale par mot de passe reste impossible pour ce compte
|
|
et inchangée pour les autres). Une pierre tombale (dissociation
|
|
explicite) interdit toute re-création automatique pour ce ``sub``.
|
|
|
|
HUB-19 (registre des rôles du Hub) : le pont d'ouverture de session
|
|
peut demander le refus d'un ``sub`` au rôle connu ``aucun``
|
|
(``user_for_draco(..., refuser_aucun=True)`` →
|
|
``RoleAucunRefuse``), levé avant la résolution de compte et le
|
|
provisionnement JIT — voir ``app.services.hub_roles``.
|
|
"""
|
|
|
|
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
|
|
|
|
|
|
class RoleAucunRefuse(Exception):
|
|
"""HUB-19 : le registre des rôles du Hub attribue le niveau
|
|
``aucun`` à ce ``sub`` pour Flowdeck — l'ouverture de session
|
|
draco est refusée (403), avant tout provisionnement JIT."""
|
|
|
|
def __init__(self, sub: str):
|
|
super().__init__(sub)
|
|
self.sub = sub
|
|
|
|
_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
|
|
from app.services import draco_urls
|
|
|
|
base = draco_urls.obsigate_url()
|
|
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 _jit_login(sub: str) -> str:
|
|
"""Login local déterministe dérivé du ``sub`` draco.
|
|
|
|
Préfixe ``draco-`` (aucune collision possible avec un login
|
|
choisi par un humain ou par l'OAuth, préfixés par fournisseur),
|
|
caractères restreints à ``[a-z0-9._-]``.
|
|
"""
|
|
nettoye = "".join(
|
|
c if (c.isascii() and (c.isalnum() or c in "._-")) else "-"
|
|
for c in sub.strip().lower()
|
|
).strip("-")
|
|
return f"draco-{nettoye}" if nettoye else "draco-compte"
|
|
|
|
|
|
def _provision_jit(conn, claims: dict) -> dict | None:
|
|
"""Crée le pendant local d'un ``sub`` draco inconnu (HUB-15).
|
|
|
|
Création + liaison dans la même transaction. Retourne le nouvel
|
|
utilisateur, ou None si la création ne doit pas avoir lieu :
|
|
une ligne ``draco_links`` existe déjà pour ce ``sub`` (lien actif
|
|
— déjà résolu par ``_linked_user`` —, compte lié inactif, ou
|
|
pierre tombale d'une dissociation explicite : on ne recrée
|
|
jamais automatiquement derrière une dissociation).
|
|
"""
|
|
sub = str(claims.get("sub", ""))
|
|
if not sub:
|
|
return None
|
|
deja = conn.execute(
|
|
"SELECT user_id FROM draco_links WHERE sub = ?", (sub,)
|
|
).fetchone()
|
|
if deja is not None:
|
|
return None
|
|
nom = str(claims.get("name", "") or "").strip()
|
|
login = _jit_login(sub)
|
|
# Collision résiduelle : un login identique pris par un autre
|
|
# compte (non lié) — on suffixe par une empreinte courte du sub
|
|
# plutôt que de lier ou d'écraser quoi que ce soit.
|
|
if conn.execute(
|
|
"SELECT id FROM users WHERE login = ?", (login,)
|
|
).fetchone() is not None:
|
|
suffixe = hashlib.sha256(sub.encode("utf-8")).hexdigest()[:8]
|
|
login = f"{login}-{suffixe}"
|
|
try:
|
|
cur = conn.execute(
|
|
"INSERT INTO users (login, full_name, email, password_hash, "
|
|
"is_admin, is_active, auth_method) "
|
|
"VALUES (?, ?, '', NULL, 0, 1, 'draco')",
|
|
(login, nom or login),
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO draco_links (sub, user_id) VALUES (?, ?)",
|
|
(sub, cur.lastrowid),
|
|
)
|
|
conn.commit()
|
|
except Exception as exc: # la création ne casse jamais la requête
|
|
logger.debug("Provisionnement JIT draco refusé: %s", exc)
|
|
conn.rollback()
|
|
return None
|
|
row = conn.execute(
|
|
"SELECT * FROM users WHERE id = ?", (cur.lastrowid,)
|
|
).fetchone()
|
|
logger.info("Compte local créé en JIT pour le sub draco %s (login %s)",
|
|
sub, login)
|
|
return dict(row) if row is not None else None
|
|
|
|
|
|
def user_for_draco(request, refuser_aucun: bool = False) -> dict | None:
|
|
"""Utilisateur Flowdeck prouvé par le cookie draco_session, ou None.
|
|
|
|
Résolution : lien ``draco_links`` existant, sinon repli
|
|
mono-utilisateur, sinon provisionnement JIT (HUB-15) — le
|
|
pendant local est créé depuis les claims et lié au ``sub``.
|
|
|
|
HUB-19 (registre des rôles du Hub, contrat D3(a)) : avec
|
|
``refuser_aucun=True`` — le pont d'ouverture de session
|
|
``GET /auth/draco``, seul appelant qui crée une session locale —
|
|
un rôle **connu** ``aucun`` dans le registre du Hub lève
|
|
``RoleAucunRefuse`` **avant** toute résolution de compte et donc
|
|
avant tout provisionnement JIT : aucun compte n'est créé pour un
|
|
``sub`` refusé. Rôle inconnu (Hub injoignable/erreur) : repli,
|
|
comportement actuel inchangé. Les autres appelants (recherche
|
|
fédérée) gardent le défaut ``False`` : aucun refus nouveau.
|
|
"""
|
|
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
|
|
if refuser_aucun:
|
|
from app.services import hub_roles
|
|
|
|
if hub_roles.role_pour(
|
|
request.headers.get("cookie", ""), sub) == "aucun":
|
|
raise RoleAucunRefuse(sub)
|
|
with get_conn() as conn:
|
|
user = _linked_user(conn, sub)
|
|
if user is None:
|
|
user = _provision_jit(conn, claims)
|
|
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
|