diff --git a/app/main.py b/app/main.py index df11f87..c9e6cb6 100644 --- a/app/main.py +++ b/app/main.py @@ -1,4 +1,6 @@ import asyncio +import hashlib +import hmac import json import logging import re @@ -20,7 +22,7 @@ from models import ( regenerate_presence_token, load_auth, save_auth, find_user_by_reset_token, load_plages, save_plages, heures_jour_min, - append_log, load_logs, + append_log, load_logs, load_or_create_secret, ) from calcul import compute_week, minutes_to_hhmm, hhmm_to_minutes, heures_dues from stats import compute_all_stats @@ -64,10 +66,43 @@ _HHMM_RE = re.compile(r"^([01]\d|2[0-3]):[0-5]\d$") # ── Auth helpers ────────────────────────────────────────────────────────────── +def _sign_user_id(user_id: str) -> str: + """Return the signed session cookie value: `.`. + + The signature authenticates the user_id without storing server-side session + state. The HMAC key lives in /data/secret.key (see load_or_create_secret). + """ + secret = load_or_create_secret() + sig = hmac.new(secret, user_id.encode(), hashlib.sha256).hexdigest() + return f"{user_id}.{sig}" + + +def _verify_signed_cookie(raw: str) -> str | None: + """Return the user_id if the cookie carries a valid signature, else None.""" + if not raw or "." not in raw: + return None + user_id, _, sig = raw.rpartition(".") + if not user_id or not sig or not _USERNAME_RE.match(user_id): + return None + secret = load_or_create_secret() + expected = hmac.new(secret, user_id.encode(), hashlib.sha256).hexdigest() + # Constant-time comparison to avoid leaking signature bytes via timing. + if hmac.compare_digest(sig, expected): + return user_id + return None + + def _get_user(request: Request) -> str | None: - """Extract current user from cookie.""" - uid = request.cookies.get("user_id", "").strip() - return uid if _USERNAME_RE.match(uid) else None + """Extract current user from signed cookie.""" + return _verify_signed_cookie(request.cookies.get("user_id", "")) + + +def _set_auth_cookie(resp: Response, user_id: str) -> None: + """Set the signed, HttpOnly, SameSite session cookie.""" + resp.set_cookie( + "user_id", _sign_user_id(user_id), + httponly=True, samesite="lax", max_age=60 * 60 * 24 * 30, + ) def allowed_email_domain() -> str: @@ -331,7 +366,7 @@ def login_password(request: Request, email: str = Form(""), password: str = Form logger.info("connexion réussie: user_id=%r", user_id) resp = RedirectResponse("/", status_code=302) - resp.set_cookie("user_id", user_id, httponly=True, samesite="lax", max_age=60 * 60 * 24 * 30) + _set_auth_cookie(resp, user_id) return resp @@ -373,7 +408,7 @@ def set_password_submit(request: Request, token: str, password: str = Form(""), logger.info("mot de passe défini pour %r", user_id) resp = RedirectResponse("/", status_code=302) - resp.set_cookie("user_id", user_id, httponly=True, samesite="lax", max_age=60 * 60 * 24 * 30) + _set_auth_cookie(resp, user_id) return resp diff --git a/app/models.py b/app/models.py index 983471e..2672c0e 100644 --- a/app/models.py +++ b/app/models.py @@ -69,6 +69,24 @@ def save_config(cfg: dict): CONFIG_FILE.write_text(json.dumps(cfg, indent=2, ensure_ascii=False)) +def load_or_create_secret() -> bytes: + """Charge ou crée la clé secrète utilisée pour signer les cookies de session. + + Persistée hors config pour ne pas être réinitialisée à chaque modification des + réglages et rester stable entre les redémarrages. + """ + secret_file = DATA_DIR / "secret.key" + secret_file.parent.mkdir(parents=True, exist_ok=True) + if secret_file.exists(): + try: + return bytes.fromhex(secret_file.read_text().strip()) + except ValueError: + pass + secret = secrets.token_bytes(32) + secret_file.write_text(secret.hex()) + return secret + + def heures_jour_min() -> int: return int(float(load_config().get("heures_jour", 7.8)) * 60) diff --git a/app/tests/conftest.py b/app/tests/conftest.py index a371149..bbaab9d 100644 --- a/app/tests/conftest.py +++ b/app/tests/conftest.py @@ -1,9 +1,23 @@ -"""Freezes calcul.py's notion of "today" for deterministic tests.""" +"""Fixtures partagées pour les tests. + +Freeze "today" pour des calculs déterministes ET installe un DATA_DIR +temporaire unique pour tout le process de test, afin que tous les modules +de tests (test_logs, test_security, ...) partagent le même stockage. +""" import datetime as dt_module +import tempfile +from pathlib import Path import pytest import calcul +import models + +# ── DATA_DIR unique pour tout le run ───────────────────────────────────────── +_TMP = Path(tempfile.mkdtemp()) +models.DATA_DIR = _TMP +models.CONFIG_FILE = models.DATA_DIR / "config.json" +models.CONFIG_FILE.write_text('{"heures_jour": 7.8, "allowed_email_domain": "x.fr"}') class FrozenDatetime(dt_module.datetime): diff --git a/app/tests/test_logs.py b/app/tests/test_logs.py index e065035..2853b1b 100644 --- a/app/tests/test_logs.py +++ b/app/tests/test_logs.py @@ -1,16 +1,9 @@ """Smoke tests pour les nouvelles routes /logs et /aide + journalisation présence.""" import sys -import tempfile -from pathlib import Path -# Prépare un DATA_DIR temporaire AVANT l'import de models -_TMP = Path(tempfile.mkdtemp()) -import models -models.DATA_DIR = _TMP -models.CONFIG_FILE = models.DATA_DIR / "config.json" -models.CONFIG_FILE.write_text('{"heures_jour": 7.8, "allowed_email_domain": "x.fr"}') +# DATA_DIR temporaire est positionné par conftest.py avant l'import de models. +import models # noqa: F401 (réexport pratique pour les tests ci-dessous) -# Recharge main dans un état propre sys.path.insert(0, ".") import main from fastapi.testclient import TestClient @@ -19,14 +12,15 @@ client = TestClient(main.app) def _login_as(uid: str): - """Crée un user avec mdp puis se connecte (renvoie les cookies).""" + """Crée un user avec mdp puis se connecte (renvoie les cookies signés).""" import bcrypt models.save_auth(uid, email=f"{uid}@x.fr", password_hash=bcrypt.hashpw(b"x" * 12, bcrypt.gensalt()).decode()) r = client.post("/login/password", data={"email": f"{uid}@x.fr", "password": "x" * 12}, follow_redirects=False) assert r.status_code == 302, r.text - return {"user_id": uid} + # Récupère le cookie user_id réellement signé par le serveur + return {"user_id": r.cookies.get("user_id")} COOKIES = _login_as("alice") diff --git a/app/tests/test_security.py b/app/tests/test_security.py new file mode 100644 index 0000000..4f1db31 --- /dev/null +++ b/app/tests/test_security.py @@ -0,0 +1,92 @@ +"""Tests de sécurité : anti-forgery du cookie de session. + +Avant le correctif, n'importe qui pouvait forger le cookie `user_id=admin` et +accéder aux données d'un autre utilisateur. La signature HMAC doit rendre toute +altération détectable. +""" +import sys + +# DATA_DIR temporaire est positionné par conftest.py avant l'import de models. +import models # noqa: F401 + +sys.path.insert(0, ".") +import main +from fastapi.testclient import TestClient + +client = TestClient(main.app) + + +def _seed_user(uid: str): + import bcrypt + models.save_auth(uid, email=f"{uid}@x.fr", + password_hash=bcrypt.hashpw(b"x" * 12, bcrypt.gensalt()).decode()) + + +def _login(uid: str) -> dict: + _seed_user(uid) + r = client.post("/login/password", + data={"email": f"{uid}@x.fr", "password": "x" * 12}, + follow_redirects=False) + assert r.status_code == 302, r.text + return {"user_id": r.cookies.get("user_id")} + + +# ── Forgery du cookie ───────────────────────────────────────────────────────── + +def test_unsigned_cookie_rejected(): + """Un cookie `user_id=admin` forgé à la main doit être rejeté.""" + _seed_user("alice") + r = client.get("/semaine/2026/29", cookies={"user_id": "alice"}, follow_redirects=False) + assert r.status_code == 302 + assert "/login" in r.headers["location"] + + +def test_tampered_signature_rejected(): + """Un cookie valide dont on modifie la signature doit être rejeté.""" + cookies = _login("alice") + raw = cookies["user_id"] + user_id, _, sig = raw.rpartition(".") + # Inverse un caractère de la signature + bad_sig = ("0" if sig[0] != "0" else "1") + sig[1:] + tampered = f"{user_id}.{bad_sig}" + r = client.get("/semaine/2026/29", cookies={"user_id": tampered}, follow_redirects=False) + assert r.status_code == 302 + assert "/login" in r.headers["location"] + + +def test_valid_signed_cookie_accepted(): + """Le cookie signé délivré par /login/password doit ouvrir la session.""" + cookies = _login("alice") + r = client.get("/semaine/2026/29", cookies=cookies, follow_redirects=False) + assert r.status_code == 200 + + +def test_signed_cookie_cannot_be_replayed_for_other_user(): + """La signature d'alice ne doit pas valider pour bob (user_id modifié).""" + cookies = _login("alice") + raw = cookies["user_id"] + _, _, sig = raw.rpartition(".") + # Remplace alice par bob en conservant la signature d'alice + forged = f"bob.{sig}" + _seed_user("bob") + r = client.get("/semaine/2026/29", cookies={"user_id": forged}, follow_redirects=False) + assert r.status_code == 302 + assert "/login" in r.headers["location"] + + +# ── Helpers de signature ────────────────────────────────────────────────────── + +def test_sign_and_verify_roundtrip(): + sig = main._sign_user_id("alice") + assert main._verify_signed_cookie(sig) == "alice" + + +def test_verify_rejects_malformed_inputs(): + for bad in ("", "alice", "alice.", ".", "alice.deadbeef", "inva/lid.xxx"): + assert main._verify_signed_cookie(bad) is None + + +def test_secret_is_stable_across_calls(): + s1 = models.load_or_create_secret() + s2 = models.load_or_create_secret() + assert s1 == s2