Flowdeck 7.85.7 — cohérence d'identité au changement de compte #59
@@ -1,3 +1,19 @@
|
||||
## v7.85.8 (2026-11-08) — Entrée SSO automatique sans session locale (chantier « entrée SSO de bout en bout », empilée sur la 7.85.7)
|
||||
|
||||
Écart constaté par Bruno en test réel le 2026-11-08 (compte « testeur » créé au Hub) : arrivé chez Flowdeck avec une session centrale valide mais aucune session locale, l'application servait sa page publique déconnectée (« Log in », « Créer un compte dans le Hub ») comme si le cookie draco n'existait pas. Chaîne prouvée en navigateur réel dans une pile locale aux noms publics.
|
||||
|
||||
- **Entrée automatique par le pont à la racine** (`app/routers/dashboard/workspace.py`) : sans session locale, si l'instance est peuplée et qu'un cookie `draco_session` **valide** (`app.services.draco.claims_valides`) est présenté, la racine `/` renvoie au pont `/auth/draco?next=/workspaces` (JIT si besoin) au lieu de servir la page publique. **Gardes** : instance vierge (aucun compte) → page publique d'installation inchangée ; cookie absent ou invalide → page publique inchangée ; le pont tranche ensuite (rôle « aucun » → sa page 403 ; échec de résolution → retour au formulaire local `/auth/login?provider=local` — le chemin prévu, celui de tous les liens d'entrée internes — qui n'est jamais intercepté : pas de boucle). Symétrique du contrôle de cohérence de la 7.85.7 (middleware), qui couvre le cas inverse (session locale d'un AUTRE compte).
|
||||
- **Tests** : nouveau `tests/test_entree_sso.py` (5) — racine renvoyée au pont, chaîne complète jusqu'à la session ouverte, page publique inchangée sans cookie et avec cookie invalide, formulaire local jamais intercepté. Suites entrée + cohérence : 12/12.
|
||||
|
||||
## v7.85.7 (2026-11-07) — Cohérence d'identité au changement de compte
|
||||
|
||||
Cas réel (capture) : connecté « fred » chez ObsiGate, l'utilisateur ouvre Flowdeck et y trouve son propre compte (BC) — la session locale préexistante gouvernait sans reconsulter l'identité centrale, le pont `/auth/draco` ne s'exécutant qu'à l'entrée SSO sans session locale.
|
||||
|
||||
- **Nouveau middleware `CoherenceDracoMiddleware`** (`app/middleware/coherence_draco.py`, le plus externe) : sur les **pages** (GET hors `/api/`, `/auth/`, `/static/`, `/s/`, `/f/`, `/scim/`), quand la requête porte une session locale valide ET un cookie `draco_session` **valide** (nouveau helper public `app.services.draco.claims_valides` : signature, expiration, révocation, `sub` présent) dont le `sub` diffère de celui que la session sert, la session locale est **révoquée** (`user_sessions`), le cookie est supprimé de la réponse et le navigateur est renvoyé au pont `/auth/draco?next=…` (JIT si besoin). Base de comparaison : le `draco_sub` inscrit dans le payload signé à la création par le pont — sinon le lien actif du compte (`draco.current_link`). Le sub inscrit neutralise le repli mono-utilisateur de la résolution (en mono-utilisateur, un sub étranger résout vers l'unique compte et ne doit pas détruire sa session en boucle).
|
||||
- **Gardes** : aucun cookie ou cookie invalide/expiré/révoqué → rien (une panne de l'émettrice ne déconnecte personne) ; cookie que le pont ne peut pas servir (rôle « aucun » — `RoleAucunRefuse` —, dissociation, compte inactif) ou qui résout vers le même compte → session gardée, pas d'exclusion en boucle ; **compte local sans aucun lien** → l'identité centrale présente gagne (choix documenté ; en mode porte centrale, ce cas n'existe pas hors secours). **API non traversée** : un appel en vol n'est ni interrompu ni rejoué sous une autre identité ; dès la navigation suivante, la page sert l'identité du cookie et la session alignée gouverne l'API.
|
||||
- **Marqueurs de session** (`app/auth/session.py`) : `create_session(..., draco_sub=…, secours=…)` ; le pont pose `draco_sub`, la connexion de la porte centrale pose `secours=True` (sessions exemptées du contrôle) ; `refresh_session` préserve les marqueurs ; nouveau `decode_payload` (payload complet, sans touch) sur lequel `decode_session` s'appuie.
|
||||
- **Tests** : nouveau `tests/test_coherence_identite.py` (7) — le cas de la capture (session révoquée en base, pont servant ensuite le compte du cookie, identité lue par `/auth/user`), même sub / sans cookie / cookie invalide intacts, secours exemptée, compte sans lien aligné, marqueur inscrit par le pont et alignement au changement de cookie.
|
||||
|
||||
## v7.85.6 (2026-11-06) — Porte centrale unique (phase 2 du programme « compte partout »), désactivée par défaut
|
||||
|
||||
> Branche basée sur la 7.85.5 (phase 1, #146/#144) — PR distincte, à fusionner après elle.
|
||||
|
||||
+43
-5
@@ -36,16 +36,28 @@ class SessionManager:
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def create_session(user_data: dict, request=None) -> str:
|
||||
def create_session(user_data: dict, request=None, draco_sub: str | None = None,
|
||||
secours: bool = False) -> str:
|
||||
"""Create a signed session cookie value.
|
||||
|
||||
``request`` is optional — when provided the session is recorded in the
|
||||
``user_sessions`` table (ip + user agent) and becomes revocable.
|
||||
|
||||
Cohérence d'identité (7.85.7) : ``draco_sub`` inscrit dans le
|
||||
payload signé le sub draco que la session sert (posé par le
|
||||
pont) — c'est la base de comparaison du contrôle de
|
||||
cohérence, insensible au repli mono-utilisateur de la
|
||||
résolution ; ``secours=True`` marque une session de la porte
|
||||
de secours, exemptée de ce contrôle.
|
||||
"""
|
||||
payload = {
|
||||
"user": public_user(user_data),
|
||||
"created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
|
||||
}
|
||||
if draco_sub:
|
||||
payload["draco_sub"] = str(draco_sub)
|
||||
if secours:
|
||||
payload["secours"] = True
|
||||
user_id = user_data.get("id")
|
||||
if user_id:
|
||||
sid = str(uuid4())
|
||||
@@ -54,17 +66,31 @@ class SessionManager:
|
||||
return _serializer.dumps(payload)
|
||||
|
||||
@staticmethod
|
||||
def decode_session(cookie: str) -> dict | None:
|
||||
"""Decode and validate a session cookie. Returns user data or None."""
|
||||
def decode_payload(cookie: str) -> dict | None:
|
||||
"""Decode the full session payload (user, sid, markers), or None.
|
||||
|
||||
Same validity checks as ``decode_session`` (signature,
|
||||
expiration, server-side revocation) but no ``last_seen``
|
||||
touch and no unwrapping — the coherence middleware reads
|
||||
the identity markers from here.
|
||||
"""
|
||||
try:
|
||||
payload = _serializer.loads(cookie, max_age=86400 * 7) # 7 days
|
||||
except (BadSignature, SignatureExpired):
|
||||
return None
|
||||
|
||||
sid = payload.get("sid") or ""
|
||||
if sid and not _session_active(sid):
|
||||
# Revoked or deleted session → treat as logged out.
|
||||
return None
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def decode_session(cookie: str) -> dict | None:
|
||||
"""Decode and validate a session cookie. Returns user data or None."""
|
||||
payload = SessionManager.decode_payload(cookie)
|
||||
if payload is None:
|
||||
return None
|
||||
sid = payload.get("sid") or ""
|
||||
if sid:
|
||||
_touch_session(sid)
|
||||
return payload.get("user")
|
||||
@@ -103,12 +129,24 @@ class SessionManager:
|
||||
|
||||
@staticmethod
|
||||
def refresh_session(cookie: str, user_data: dict, request=None) -> str:
|
||||
"""Re-sign a cookie keeping its session id (used after profile edits)."""
|
||||
"""Re-sign a cookie keeping its session id (used after profile edits).
|
||||
|
||||
Les marqueurs de cohérence (``draco_sub``, ``secours`` —
|
||||
7.85.7) de l'ancien payload sont préservés : un rafraîchi
|
||||
de profil ne doit ni perdre l'identité servie ni l'exemption
|
||||
de secours.
|
||||
"""
|
||||
sid = SessionManager.session_id(cookie) if cookie else None
|
||||
payload = {
|
||||
"user": public_user(user_data),
|
||||
"created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
|
||||
}
|
||||
if cookie:
|
||||
ancien = SessionManager.decode_payload(cookie)
|
||||
if ancien:
|
||||
for marqueur in ("draco_sub", "secours"):
|
||||
if ancien.get(marqueur):
|
||||
payload[marqueur] = ancien[marqueur]
|
||||
user_id = user_data.get("id")
|
||||
if user_id:
|
||||
if sid is None:
|
||||
|
||||
@@ -280,6 +280,14 @@ app.add_middleware(
|
||||
from app.middleware.recherche_cors import RechercheCorsMiddleware
|
||||
|
||||
app.add_middleware(RechercheCorsMiddleware)
|
||||
|
||||
# Cohérence d'identité (7.85.7) : ajoutée en dernier, donc la plus
|
||||
# externe — une page dont la session locale contredit le cookie
|
||||
# draco valide présenté est renvoyée au pont avant tout service.
|
||||
# Le middleware ne traverse que les GET de pages (voir son module).
|
||||
from app.middleware.coherence_draco import CoherenceDracoMiddleware
|
||||
|
||||
app.add_middleware(CoherenceDracoMiddleware)
|
||||
app.include_router(auth.router)
|
||||
app.include_router(sso_router)
|
||||
app.include_router(dashboard.router)
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""FlowDeck — cohérence d'identité au changement de compte (7.85.7).
|
||||
|
||||
Cas réel : connecté « fred » chez ObsiGate, l'utilisateur ouvre
|
||||
Flowdeck et y trouve son propre compte (BC) — la session locale
|
||||
préexistante gouvernait sans jamais reconsulter l'identité
|
||||
centrale, le pont ``/auth/draco`` ne s'exécutant qu'à l'entrée
|
||||
SSO sans session locale.
|
||||
|
||||
Ce middleware aligne la session sur l'identité centrale
|
||||
**présentée** : quand une requête de page (GET hors ``/api/``,
|
||||
``/auth/``, ``/static/``, ``/s/``, ``/f/``, ``/scim/``) porte à la
|
||||
fois une session locale valide et un cookie ``draco_session``
|
||||
**valide** (``app.services.draco.claims_valides`` : signature,
|
||||
expiration, révocation) dont le ``sub`` diffère de celui que la
|
||||
session sert, la session locale est révoquée (ligne
|
||||
``user_sessions``), le cookie est supprimé de la réponse, et le
|
||||
navigateur est renvoyé au pont ``/auth/draco?next=…`` (JIT si
|
||||
besoin). Base de comparaison : le ``draco_sub`` inscrit dans le
|
||||
payload signé de la session à sa création par le pont — sinon le
|
||||
lien actif du compte (``draco.current_link``). Le sub inscrit
|
||||
neutralise le repli mono-utilisateur de la résolution : en
|
||||
mono-utilisateur, un sub étranger résout vers l'unique compte et
|
||||
ne doit pas détruire sa session en boucle.
|
||||
|
||||
Gardes :
|
||||
|
||||
- aucun cookie draco, ou cookie invalide/expiré/révoqué → rien :
|
||||
une panne ou une absence de l'émettrice ne déconnecte personne ;
|
||||
- session de la **porte de secours** (marqueur ``secours`` du
|
||||
payload) → exemptée : elle vit hors circuit central ;
|
||||
- cookie que le pont ne peut pas servir (rôle « aucun » au
|
||||
registre — ``user_for_draco`` lève ``RoleAucunRefuse`` —,
|
||||
dissociation, compte inactif) ou qui résout vers le **même**
|
||||
compte → session gardée : la détruire exclurait son titulaire
|
||||
en boucle, le cookie refusé restant présent ;
|
||||
- compte local **sans aucun lien** draco → l'identité centrale
|
||||
présente gagne (le pont s'applique ; choix documenté). En mode
|
||||
porte centrale, ce cas n'existe pas hors secours.
|
||||
|
||||
Périmètre : les **pages**. L'API n'est pas traversée par ce
|
||||
middleware : un appel en vol n'est ni interrompu ni rejoué sous
|
||||
une autre identité ; dès la navigation suivante, la page sert
|
||||
l'identité du cookie et la session alignée gouverne l'API.
|
||||
Après alignement, le ``draco_sub`` de la nouvelle session égale
|
||||
celui du cookie : pas de boucle.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from urllib.parse import quote
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_EXEMPT_PREFIXES = ("/api/", "/auth/", "/static/", "/s/", "/f/", "/scim/")
|
||||
|
||||
|
||||
class CoherenceDracoMiddleware(BaseHTTPMiddleware):
|
||||
"""Aligne la session locale sur le cookie draco valide présenté."""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
if request.method != "GET":
|
||||
return await call_next(request)
|
||||
path = request.url.path
|
||||
if any(path.startswith(p) for p in _EXEMPT_PREFIXES):
|
||||
return await call_next(request)
|
||||
cookie = request.cookies.get("flowdeck_session", "")
|
||||
if not cookie:
|
||||
return await call_next(request)
|
||||
payload = SessionManager.decode_payload(cookie)
|
||||
if payload is None:
|
||||
return await call_next(request)
|
||||
if payload.get("secours"):
|
||||
return await call_next(request)
|
||||
user = payload.get("user") or {}
|
||||
user_id = user.get("id")
|
||||
if not user_id:
|
||||
return await call_next(request)
|
||||
from app.services import draco
|
||||
|
||||
claims = draco.claims_valides(request)
|
||||
if claims is None:
|
||||
return await call_next(request)
|
||||
sub_cookie = str(claims.get("sub", ""))
|
||||
base = payload.get("draco_sub") or draco.current_link(user_id)
|
||||
if base == sub_cookie:
|
||||
return await call_next(request)
|
||||
# Servabilité : le pont doit pouvoir servir un AUTRE compte,
|
||||
# sinon la session est gardée (pas d'exclusion en boucle).
|
||||
try:
|
||||
candidat = draco.user_for_draco(request)
|
||||
except Exception:
|
||||
candidat = None
|
||||
if candidat is None or candidat.get("id") == user_id:
|
||||
return await call_next(request)
|
||||
sid = payload.get("sid")
|
||||
if sid:
|
||||
SessionManager.revoke_session(sid)
|
||||
cible = path
|
||||
if request.url.query:
|
||||
cible = f"{path}?{request.url.query}"
|
||||
response = RedirectResponse(
|
||||
url=f"/auth/draco?next={quote(cible, safe='')}", status_code=302)
|
||||
response.delete_cookie("flowdeck_session", path="/")
|
||||
return response
|
||||
+13
-2
@@ -484,7 +484,11 @@ def local_login(request: Request, body: dict = Body(default={})):
|
||||
{"error": "Porte de secours : identifiants invalides."},
|
||||
status_code=401,
|
||||
)
|
||||
session = SessionManager.create_session(compte_secours, request)
|
||||
# Session marquée « secours » (7.85.7) : exemptée du
|
||||
# contrôle de cohérence d'identité — elle vit hors circuit
|
||||
# central par construction.
|
||||
session = SessionManager.create_session(
|
||||
compte_secours, request, secours=True)
|
||||
_log_login(compte_secours["id"], request)
|
||||
response = JSONResponse({"status": "ok", "user": {
|
||||
"login": compte_secours["login"],
|
||||
@@ -701,7 +705,14 @@ de l'instance reste possible par la page de connexion.</p>
|
||||
)
|
||||
if user is None:
|
||||
return RedirectResponse(url="/auth/login?provider=local", status_code=302)
|
||||
session = SessionManager.create_session(user, request)
|
||||
# Cohérence d'identité (7.85.7) : la session créée par le pont
|
||||
# inscrit le sub qu'elle sert dans son payload signé — base de
|
||||
# comparaison du contrôle de cohérence, insensible au repli
|
||||
# mono-utilisateur de la résolution.
|
||||
_claims = draco.claims_valides(request)
|
||||
session = SessionManager.create_session(
|
||||
user, request,
|
||||
draco_sub=str(_claims.get("sub", "")) if _claims else None)
|
||||
_log_login(user["id"], request)
|
||||
cible = next if next.startswith("/") and not next.startswith("//") else "/workspaces"
|
||||
response = RedirectResponse(url=cible, status_code=302)
|
||||
|
||||
@@ -55,16 +55,38 @@ async def dashboard(
|
||||
# ── Not authenticated → show landing page ──
|
||||
if not user:
|
||||
# Allow through if DB is empty (fresh install)
|
||||
_peuplee = False
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
if count == 0:
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("landing.html")
|
||||
return template.render(**_contexte_accueil())
|
||||
_peuplee = conn.execute(
|
||||
"SELECT COUNT(*) FROM users").fetchone()[0] > 0
|
||||
except Exception:
|
||||
logger.exception("dashboard")
|
||||
if _peuplee:
|
||||
# Entrée automatique par le pont (7.85.8, chantier
|
||||
# « entrée SSO de bout en bout ») : un cookie draco
|
||||
# VALIDE présenté sans session locale ne doit pas
|
||||
# aboutir à la page publique comme si la session de
|
||||
# la suite n'existait pas — le navigateur est renvoyé
|
||||
# au pont /auth/draco (JIT si besoin), qui revient à
|
||||
# /workspaces. Gardes : instance vierge (aucun compte)
|
||||
# → page publique d'installation, inchangée ; cookie
|
||||
# absent ou invalide → page publique, inchangée ; le
|
||||
# pont tranche ensuite (rôle « aucun » → sa page 403 ;
|
||||
# échec de résolution → retour au formulaire local
|
||||
# /auth/login?provider=local, le chemin prévu, qui
|
||||
# n'est jamais intercepté : pas de boucle). Symétrique
|
||||
# du contrôle de cohérence de la 7.85.7 (middleware),
|
||||
# qui couvre le cas inverse (session locale d'un
|
||||
# AUTRE compte).
|
||||
try:
|
||||
from app.services import draco as _draco_entree
|
||||
|
||||
if _draco_entree.claims_valides(request) is not None:
|
||||
return RedirectResponse(
|
||||
url="/auth/draco?next=/workspaces", status_code=302)
|
||||
except Exception:
|
||||
logger.exception("dashboard — entrée automatique par le pont")
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("landing.html")
|
||||
|
||||
@@ -149,6 +149,30 @@ def is_revoked(jti: str) -> bool:
|
||||
return jti in _revoked_cache
|
||||
|
||||
|
||||
def claims_valides(request) -> dict | None:
|
||||
"""Claims du cookie draco de la requête, validité complète, ou None.
|
||||
|
||||
Signature et expiration (``verify_token``), révocation du
|
||||
``jti`` selon le cache alimenté par l'émettrice, ``sub``
|
||||
présent — la même validité que celle qu'exige
|
||||
``user_for_draco`` avant de résoudre. Consommé par le contrôle
|
||||
de cohérence d'identité (7.85.7) : un cookie absent, invalide,
|
||||
expiré ou révoqué vaut « aucune identité centrale présentée »
|
||||
et ne doit rien déclencher.
|
||||
"""
|
||||
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
|
||||
if not str(claims.get("sub", "")):
|
||||
return None
|
||||
return claims
|
||||
|
||||
|
||||
# ── Résolution sub → utilisateur local ──────────────────────────────────
|
||||
|
||||
def _linked_user(conn, sub: str) -> dict | None:
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Cohérence d'identité au changement de compte (7.85.7).
|
||||
|
||||
Cas réel (capture de Bruno) : connecté « fred » chez ObsiGate, il
|
||||
ouvre Flowdeck et y trouve son propre compte — la session locale
|
||||
préexistante gouvernait sans reconsulter l'identité centrale, le
|
||||
pont ne s'exécutant qu'à l'entrée SSO sans session locale.
|
||||
|
||||
Le middleware ``CoherenceDracoMiddleware`` aligne la session sur
|
||||
le cookie draco valide présenté : révocation de la session locale
|
||||
+ renvoi des pages au pont. Gardes couvertes ici : même sub
|
||||
(inchangée), aucun cookie / cookie invalide (intacte), session de
|
||||
secours marquée (exemptée), compte local sans aucun lien
|
||||
(l'identité centrale présente gagne), marqueur ``draco_sub``
|
||||
inscrit par le pont dans le payload de session.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.password_utils import hash_password
|
||||
from app.services import draco
|
||||
|
||||
from tests.conftest import anon
|
||||
|
||||
SECRET = "coherence-secret"
|
||||
SUB_PROPRIO = "coh-sub-proprio"
|
||||
SUB_FRED = "coh-sub-fred"
|
||||
MDP = "mot-de-passe-coh"
|
||||
|
||||
|
||||
def _jeton(sub, secret=SECRET, exp=None):
|
||||
def b64(d: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(d).rstrip(b"=").decode()
|
||||
|
||||
header = b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
|
||||
payload = b64(json.dumps({
|
||||
"sub": sub, "name": "Testeur", "iat": int(time.time()),
|
||||
"exp": exp if exp is not None else int(time.time()) + 3600,
|
||||
"jti": f"jti-{sub}",
|
||||
}).encode())
|
||||
sig = hmac.new(secret.encode(), f"{header}.{payload}".encode(),
|
||||
hashlib.sha256).digest()
|
||||
return f"{header}.{payload}.{b64(sig)}"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _env(monkeypatch):
|
||||
monkeypatch.setenv("DRACO_SESSION_SECRET", SECRET)
|
||||
monkeypatch.setattr(draco, "_refresh_revocations", lambda: None)
|
||||
draco._revoked_cache.clear()
|
||||
yield
|
||||
draco._revoked_cache.clear()
|
||||
|
||||
|
||||
def _creer(login, sub=None):
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO users (login, email, password_hash, full_name, is_active) "
|
||||
"VALUES (?, ?, ?, ?, 1)",
|
||||
(login, f"{login}@ex.ca", hash_password(MDP), login.title()))
|
||||
uid = cur.lastrowid
|
||||
if sub:
|
||||
conn.execute(
|
||||
"INSERT INTO draco_links (sub, user_id) VALUES (?, ?)",
|
||||
(sub, uid))
|
||||
conn.commit()
|
||||
return uid
|
||||
|
||||
|
||||
def _connecter(client, login):
|
||||
r = client.post("/auth/local-login",
|
||||
json={"email": login, "password": MDP})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def deux_comptes(client):
|
||||
proprio_id = _creer("cohproprio", SUB_PROPRIO)
|
||||
fred_id = _creer("cohfred", SUB_FRED)
|
||||
return proprio_id, fred_id
|
||||
|
||||
|
||||
# ── (iii) le cas de Bruno : session d'un autre compte ────────────
|
||||
|
||||
def test_session_autre_compte_alignee_sur_le_cookie(client, deux_comptes):
|
||||
proprio_id, _ = deux_comptes
|
||||
anon(client)
|
||||
_connecter(client, "cohproprio")
|
||||
client.cookies.set("draco_session", _jeton(SUB_FRED))
|
||||
r = client.get("/workspaces", follow_redirects=False)
|
||||
assert r.status_code == 302
|
||||
assert r.headers["location"].startswith("/auth/draco")
|
||||
assert "Max-Age=0" in r.headers.get("set-cookie", "")
|
||||
# La session locale du proprio est révoquée côté serveur.
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT revoked FROM user_sessions WHERE user_id = ?",
|
||||
(proprio_id,)).fetchall()
|
||||
assert rows and all(row["revoked"] == 1 for row in rows)
|
||||
# Le pont sert ensuite le compte du cookie.
|
||||
r = client.get("/auth/draco", follow_redirects=False)
|
||||
assert r.status_code == 302
|
||||
assert r.headers["location"] == "/workspaces"
|
||||
me = client.get("/auth/user")
|
||||
assert me.json()["authenticated"] is True
|
||||
assert me.json()["user"]["login"] == "cohfred"
|
||||
|
||||
|
||||
# ── (ii) même sub, (iv) pas de cookie, cookie invalide ───────────
|
||||
|
||||
def test_meme_sub_session_inchangee(client, deux_comptes):
|
||||
anon(client)
|
||||
_connecter(client, "cohproprio")
|
||||
client.cookies.set("draco_session", _jeton(SUB_PROPRIO))
|
||||
r = client.get("/workspaces", follow_redirects=False)
|
||||
assert r.status_code == 200
|
||||
me = client.get("/auth/user")
|
||||
assert me.json()["user"]["login"] == "cohproprio"
|
||||
|
||||
|
||||
def test_sans_cookie_session_intacte(client, deux_comptes):
|
||||
anon(client)
|
||||
_connecter(client, "cohproprio")
|
||||
r = client.get("/workspaces", follow_redirects=False)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_cookie_invalide_session_intacte(client, deux_comptes):
|
||||
anon(client)
|
||||
_connecter(client, "cohproprio")
|
||||
client.cookies.set(
|
||||
"draco_session", _jeton(SUB_FRED, secret="mauvais-secret"))
|
||||
r = client.get("/workspaces", follow_redirects=False)
|
||||
assert r.status_code == 200
|
||||
me = client.get("/auth/user")
|
||||
assert me.json()["user"]["login"] == "cohproprio"
|
||||
|
||||
|
||||
# ── (v) session de secours exemptée ──────────────────────────────
|
||||
|
||||
def test_session_secours_exemptee(client, deux_comptes):
|
||||
proprio_id, _ = deux_comptes
|
||||
anon(client)
|
||||
session = SessionManager.create_session(
|
||||
{"id": proprio_id, "login": "cohproprio",
|
||||
"full_name": "Cohproprio", "is_admin": 0},
|
||||
None, secours=True)
|
||||
client.cookies.set("flowdeck_session", session)
|
||||
client.cookies.set("draco_session", _jeton(SUB_FRED))
|
||||
r = client.get("/workspaces", follow_redirects=False)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
# ── Garde 4 : compte sans lien, l'identité centrale gagne ────────
|
||||
|
||||
def test_compte_sans_lien_aligne(client):
|
||||
_creer("cohproprio") # aucun lien draco
|
||||
_creer("cohfred", SUB_FRED)
|
||||
anon(client)
|
||||
_connecter(client, "cohproprio")
|
||||
client.cookies.set("draco_session", _jeton(SUB_FRED))
|
||||
r = client.get("/workspaces", follow_redirects=False)
|
||||
assert r.status_code == 302
|
||||
assert r.headers["location"].startswith("/auth/draco")
|
||||
|
||||
|
||||
# ── Marqueur du pont ─────────────────────────────────────────────
|
||||
|
||||
def test_pont_inscrit_le_sub_servi_dans_la_session(client, deux_comptes):
|
||||
anon(client)
|
||||
client.cookies.set("draco_session", _jeton(SUB_FRED))
|
||||
r = client.get("/auth/draco", follow_redirects=False)
|
||||
assert r.status_code == 302
|
||||
brut = client.cookies.get("flowdeck_session")
|
||||
payload = SessionManager.decode_payload(brut)
|
||||
assert payload is not None
|
||||
assert payload.get("draco_sub") == SUB_FRED
|
||||
# Changement de compte central : le cookie du proprio aligne
|
||||
# la session (base = sub inscrit, pas le lien courant).
|
||||
client.cookies.set("draco_session", _jeton(SUB_PROPRIO))
|
||||
r = client.get("/workspaces", follow_redirects=False)
|
||||
assert r.status_code == 302
|
||||
assert r.headers["location"].startswith("/auth/draco")
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Entrée SSO automatique sans session locale (7.85.8).
|
||||
|
||||
Chantier « entrée SSO de bout en bout » — écart constaté par
|
||||
Bruno en test réel le 2026-11-08 : arrivé chez Flowdeck avec une
|
||||
session centrale valide (cookie ``draco_session``) mais aucune
|
||||
session locale, l'application servait sa page publique
|
||||
déconnectée (« Log in », « Notion-style ») comme
|
||||
si le cookie n'existait pas. Attendu : à l'arrivée sur la racine,
|
||||
passage automatique par le pont ``/auth/draco`` et service de
|
||||
l'application connectée au compte désigné par le cookie.
|
||||
|
||||
Couvert : redirection de la racine vers le pont, chaîne complète
|
||||
jusqu'à la session ouverte, page publique inchangée sans cookie
|
||||
ou avec un cookie invalide, formulaire local jamais intercepté
|
||||
(le chemin prévu ``?provider=local``), pont qui tranche en cas de
|
||||
rôle « aucun » (403, pas de boucle).
|
||||
|
||||
Montage repris de tests/test_coherence_identite.py.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db import get_conn
|
||||
from app.password_utils import hash_password
|
||||
from app.services import draco
|
||||
|
||||
from tests.conftest import anon
|
||||
|
||||
SECRET = "entree-secret"
|
||||
SUB_TESTEUR = "ent-sub-testeur"
|
||||
MDP = "mot-de-passe-ent"
|
||||
|
||||
|
||||
def _jeton(sub, secret=SECRET):
|
||||
def b64(d: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(d).rstrip(b"=").decode()
|
||||
|
||||
header = b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
|
||||
payload = b64(json.dumps({
|
||||
"sub": sub, "name": "Testeur", "iat": int(time.time()),
|
||||
"exp": int(time.time()) + 3600, "jti": f"jti-{sub}",
|
||||
}).encode())
|
||||
sig = hmac.new(secret.encode(), f"{header}.{payload}".encode(),
|
||||
hashlib.sha256).digest()
|
||||
return f"{header}.{payload}.{b64(sig)}"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _env(monkeypatch):
|
||||
monkeypatch.setenv("DRACO_SESSION_SECRET", SECRET)
|
||||
monkeypatch.setattr(draco, "_refresh_revocations", lambda: None)
|
||||
draco._revoked_cache.clear()
|
||||
yield
|
||||
draco._revoked_cache.clear()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def compte_lie(client):
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO users (login, email, password_hash, full_name, is_active) "
|
||||
"VALUES (?, ?, ?, ?, 1)",
|
||||
("enttesteur", "[email protected]", hash_password(MDP),
|
||||
"Testeur Test"))
|
||||
uid = cur.lastrowid
|
||||
conn.execute(
|
||||
"INSERT INTO draco_links (sub, user_id) VALUES (?, ?)",
|
||||
(SUB_TESTEUR, uid))
|
||||
conn.commit()
|
||||
return uid
|
||||
|
||||
|
||||
def test_racine_sans_session_cookie_valide_renvoyee_au_pont(client, compte_lie):
|
||||
anon(client)
|
||||
client.cookies.set("draco_session", _jeton(SUB_TESTEUR))
|
||||
r = client.get("/", follow_redirects=False)
|
||||
assert r.status_code == 302
|
||||
assert r.headers["location"] == "/auth/draco?next=/workspaces"
|
||||
|
||||
|
||||
def test_chaine_complete_session_ouverte(client, compte_lie):
|
||||
anon(client)
|
||||
client.cookies.set("draco_session", _jeton(SUB_TESTEUR))
|
||||
r = client.get("/auth/draco", follow_redirects=False)
|
||||
assert r.status_code == 302
|
||||
assert r.headers["location"] == "/workspaces"
|
||||
assert "flowdeck_session" in r.headers.get("set-cookie", "")
|
||||
# La racine ne sert plus la page publique.
|
||||
r2 = client.get("/", follow_redirects=False)
|
||||
assert "Notion-style" not in r2.text
|
||||
|
||||
|
||||
def test_racine_sans_cookie_page_publique_inchangee(client, compte_lie):
|
||||
anon(client)
|
||||
r = client.get("/", follow_redirects=False)
|
||||
assert r.status_code == 200
|
||||
assert "Notion-style" in r.text
|
||||
|
||||
|
||||
def test_racine_cookie_invalide_page_publique_inchangee(client, compte_lie):
|
||||
anon(client)
|
||||
client.cookies.set("draco_session", _jeton(SUB_TESTEUR, secret="mauvais"))
|
||||
r = client.get("/", follow_redirects=False)
|
||||
assert r.status_code == 200
|
||||
assert "Notion-style" in r.text
|
||||
|
||||
|
||||
def test_formulaire_local_jamais_intercepte(client, compte_lie):
|
||||
anon(client)
|
||||
client.cookies.set("draco_session", _jeton(SUB_TESTEUR))
|
||||
r = client.get("/auth/login?provider=local", follow_redirects=False)
|
||||
assert r.status_code == 200
|
||||
Reference in New Issue
Block a user