235 lines
8.9 KiB
Python
235 lines
8.9 KiB
Python
# tests/test_draco_session.py
|
|
# Fusion ACC-3.1 (#216) — émettrice SSO draco_session : émission à la
|
|
# connexion, vérification (signature, expiration, révocation, rotation),
|
|
# renouvellement glissant, return_to en liste blanche, révocations.
|
|
|
|
import time
|
|
|
|
import pytest
|
|
from jose import jwt as jose_jwt
|
|
|
|
from backend import draco_session
|
|
|
|
USER = {"id": "admin-1", "username": "admin", "display_name": "Admin"}
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _draco_env(monkeypatch, tmp_path):
|
|
monkeypatch.setenv("DRACO_SESSION_SECRET", "secret-de-test")
|
|
monkeypatch.setattr(draco_session, "REVOKED_FILE",
|
|
tmp_path / "draco_revoked.json")
|
|
draco_session._revoked = None
|
|
yield
|
|
draco_session._revoked = None
|
|
|
|
|
|
# ── Jeton : émission / vérification ─────────────────────────────────────
|
|
|
|
def test_roundtrip_claims_minimaux():
|
|
token, jti, exp = draco_session.issue_token(USER)
|
|
claims = draco_session.verify_token(token)
|
|
assert claims is not None
|
|
assert claims["sub"] == "admin-1"
|
|
assert claims["name"] == "Admin"
|
|
assert claims["jti"] == jti
|
|
assert exp - claims["iat"] == 12 * 3600 # durée de vie du design §5.2
|
|
assert "role" not in claims # aucun rôle dans le jeton
|
|
|
|
|
|
def test_jeton_falsifie_rejete():
|
|
token, _j, _e = draco_session.issue_token(USER)
|
|
assert draco_session.verify_token(token + "x") is None
|
|
autre = jose_jwt.encode({"sub": "admin-1"}, "mauvais-secret",
|
|
algorithm="HS256")
|
|
assert draco_session.verify_token(autre) is None
|
|
|
|
|
|
def test_jeton_expire_rejete():
|
|
now = int(time.time())
|
|
token = jose_jwt.encode(
|
|
{"sub": "admin-1", "name": "Admin", "iat": now - 7200,
|
|
"exp": now - 3600, "jti": "ancien"},
|
|
"secret-de-test", algorithm="HS256")
|
|
assert draco_session.verify_token(token) is None
|
|
|
|
|
|
def test_rotation_secret_precedent_accepte(monkeypatch):
|
|
token = jose_jwt.encode(
|
|
{"sub": "admin-1", "name": "Admin", "iat": int(time.time()),
|
|
"exp": int(time.time()) + 3600, "jti": "rotation"},
|
|
"ancien-secret", algorithm="HS256")
|
|
assert draco_session.verify_token(token) is None # pas encore configuré
|
|
monkeypatch.setenv("DRACO_SESSION_SECRET_PREV", "ancien-secret")
|
|
assert draco_session.verify_token(token)["jti"] == "rotation"
|
|
|
|
|
|
def test_revocation():
|
|
token, jti, exp = draco_session.issue_token(USER)
|
|
assert draco_session.verify_token(token) is not None
|
|
draco_session.revoke(jti, exp)
|
|
assert draco_session.verify_token(token) is None
|
|
assert {"jti": jti, "exp": exp} in draco_session.revoked_list()
|
|
|
|
|
|
def test_inerte_sans_secret(monkeypatch):
|
|
monkeypatch.delenv("DRACO_SESSION_SECRET")
|
|
assert draco_session.enabled() is False
|
|
assert draco_session.verify_token("peu-importe") is None
|
|
|
|
|
|
def test_fenetre_de_renouvellement():
|
|
assert draco_session.needs_renewal({"exp": int(time.time()) + 1800})
|
|
assert not draco_session.needs_renewal({"exp": int(time.time()) + 12 * 3600})
|
|
|
|
|
|
# ── return_to : liste blanche ────────────────────────────────────────────
|
|
|
|
@pytest.mark.parametrize("url,ok", [
|
|
("https://tiktak.dracodev.net/?task=3", True),
|
|
("https://og.dracodev.net/", True),
|
|
("https://dracodev.net/x", True),
|
|
("http://tiktak.dracodev.net/", False), # https seulement
|
|
("https://evil.com/", False),
|
|
("https://tiktak.dracodev.net.evil.com/", False),
|
|
("https://notdracodev.net/", False),
|
|
("", False),
|
|
])
|
|
def test_validate_return_to(url, ok):
|
|
assert (draco_session.validate_return_to(url) is not None) == ok
|
|
|
|
|
|
# ── Intégration : connexion / déconnexion / renouvellement ──────────────
|
|
|
|
def _login(client):
|
|
r = client.post("/api/auth/login",
|
|
json={"username": "admin", "password": "chab30"})
|
|
assert r.status_code == 200, r.text
|
|
return r
|
|
|
|
|
|
def _draco_cookie(response):
|
|
for raw in response.headers.get_list("set-cookie"):
|
|
if raw.startswith("draco_session="):
|
|
return raw
|
|
return None
|
|
|
|
|
|
def test_login_pose_le_cookie(admin_client):
|
|
r = _login(admin_client)
|
|
cookie = _draco_cookie(r)
|
|
assert cookie is not None
|
|
assert "Domain=.dracodev.net" in cookie
|
|
assert "HttpOnly" in cookie and "SameSite=lax" in cookie
|
|
token = cookie.split(";")[0].split("=", 1)[1]
|
|
assert draco_session.verify_token(token)["sub"] == "admin-1"
|
|
|
|
|
|
def test_login_sans_secret_aucun_cookie(admin_client, monkeypatch):
|
|
monkeypatch.delenv("DRACO_SESSION_SECRET")
|
|
r = _login(admin_client)
|
|
assert _draco_cookie(r) is None
|
|
|
|
|
|
def _token_depuis_login(client):
|
|
# Le jar du TestClient refuse le cookie Domain=.dracodev.net (hôte
|
|
# de test différent) : on lit le jeton dans l'en-tête set-cookie.
|
|
cookie = _draco_cookie(_login(client))
|
|
assert cookie is not None
|
|
return cookie.split(";")[0].split("=", 1)[1]
|
|
|
|
|
|
def test_logout_revoque_le_jti(admin_client):
|
|
token = _token_depuis_login(admin_client)
|
|
assert draco_session.verify_token(token)
|
|
r = admin_client.post("/api/auth/logout",
|
|
headers={"Cookie": f"draco_session={token}"})
|
|
assert r.status_code == 200, r.text
|
|
assert draco_session.verify_token(token) is None # révoqué
|
|
|
|
|
|
def test_renouvellement_glissant(admin_client):
|
|
_login(admin_client)
|
|
# Cookie draco presque expiré (dans la fenêtre des 2 h) :
|
|
now = int(time.time())
|
|
vieux = jose_jwt.encode(
|
|
{"sub": "admin-1", "name": "Admin", "iat": now - 11 * 3600,
|
|
"exp": now + 600, "jti": "vieux-jti"},
|
|
"secret-de-test", algorithm="HS256")
|
|
admin_client.cookies.set("draco_session", vieux)
|
|
r = admin_client.get("/api/auth/me")
|
|
assert r.status_code == 200, r.text
|
|
cookie = _draco_cookie(r)
|
|
assert cookie is not None # réémis
|
|
nouveau = cookie.split(";")[0].split("=", 1)[1]
|
|
assert draco_session.verify_token(nouveau)["jti"] != "vieux-jti"
|
|
|
|
|
|
def test_pas_de_renouvellement_si_frais(admin_client):
|
|
token = _token_depuis_login(admin_client)
|
|
admin_client.cookies.set("draco_session", token) # cookie frais porté
|
|
r = admin_client.get("/api/auth/me")
|
|
assert r.status_code == 200
|
|
assert _draco_cookie(r) is None # cookie frais : rien
|
|
|
|
|
|
def test_sso_authorize_liste_blanche(client):
|
|
r = client.get("/sso/authorize",
|
|
params={"return_to": "https://tiktak.dracodev.net/?task=3"},
|
|
follow_redirects=False)
|
|
assert r.status_code == 302
|
|
assert "return_to=" in r.headers["location"]
|
|
r = client.get("/sso/authorize",
|
|
params={"return_to": "https://evil.com/phish"},
|
|
follow_redirects=False)
|
|
assert r.status_code == 302
|
|
assert r.headers["location"] == "/" # refusé, pas de relais
|
|
|
|
|
|
# ── Validation locale (ACC-3.2, #9) ──────────────────────────────────────
|
|
|
|
def test_me_avec_cookie_draco_seul(admin_client):
|
|
# Aucune session native : le cookie partagé suffit (sub = id).
|
|
token, _jti, _exp = draco_session.issue_token(USER)
|
|
admin_client.cookies.set("draco_session", token)
|
|
r = admin_client.get("/api/auth/me")
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["username"] == "admin"
|
|
|
|
|
|
def test_me_draco_revoque_refuse(admin_client):
|
|
token, jti, exp = draco_session.issue_token(USER)
|
|
draco_session.revoke(jti, exp)
|
|
admin_client.cookies.set("draco_session", token)
|
|
r = admin_client.get("/api/auth/me")
|
|
assert r.status_code == 401
|
|
|
|
|
|
def test_me_draco_sub_inconnu_refuse(admin_client):
|
|
token, _j, _e = draco_session.issue_token(
|
|
{"id": "personne-99", "username": "x", "display_name": "X"})
|
|
admin_client.cookies.set("draco_session", token)
|
|
r = admin_client.get("/api/auth/me")
|
|
assert r.status_code == 401
|
|
|
|
|
|
def test_me_draco_inerte_sans_secret(admin_client, monkeypatch):
|
|
token, _j, _e = draco_session.issue_token(USER)
|
|
monkeypatch.delenv("DRACO_SESSION_SECRET")
|
|
admin_client.cookies.set("draco_session", token)
|
|
r = admin_client.get("/api/auth/me")
|
|
assert r.status_code == 401
|
|
|
|
|
|
def test_revocations_protegees_par_cle_machine(admin_client, monkeypatch):
|
|
token, jti, exp = draco_session.issue_token(USER)
|
|
draco_session.revoke(jti, exp)
|
|
r = admin_client.get("/api/sso/revocations")
|
|
assert r.status_code == 404 # clé non configurée
|
|
monkeypatch.setenv("DRACO_SSO_MACHINE_KEY", "cle-machine")
|
|
r = admin_client.get("/api/sso/revocations")
|
|
assert r.status_code == 401 # en-tête absent
|
|
r = admin_client.get("/api/sso/revocations",
|
|
headers={"X-Draco-Machine-Key": "cle-machine"})
|
|
assert r.status_code == 200
|
|
assert {"jti": jti, "exp": exp} in r.json()["revoked"]
|