Sécurise le cookie de session par signature HMAC

Le cookie user_id était lu tel quel depuis la requête, ce qui permettait à
n'importe qui de forger `user_id=victime` dans DevTools/curl et d'accéder à
toutes les routes "authentifiées" (lecture/écriture des pointages, export,
régénération des tokens Tasker, etc.). Le mot de passe bcrypt ne servait
donc à rien.

Le cookie est maintenant au format `<user_id>.<hmac_sha256_hex>` signé avec
une clé persistée dans /data/secret.key (auto-générée, stable entre
redémarrages). La vérification utilise hmac.compare_digest pour éviter les
fuites par timing. Toutes les routes appellent _get_user qui valide la
signature à chaque requête.

Les tests de test_logs.py récupèrent désormais le cookie réellement signé
par le serveur, et un nouveau module test_security.py couvre le rejet des
cookies forgés/tamponnés/rejoués.

Le DATA_DIR temporaire des tests est centralisé dans conftest.py pour éviter
les collisions entre modules de test.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
Jacquin Antoine
2026-07-20 00:20:50 +02:00
parent 55efac9cdd
commit 8dc2a3fa4c
5 changed files with 171 additions and 18 deletions

View File

@ -1,4 +1,6 @@
import asyncio import asyncio
import hashlib
import hmac
import json import json
import logging import logging
import re import re
@ -20,7 +22,7 @@ from models import (
regenerate_presence_token, regenerate_presence_token,
load_auth, save_auth, find_user_by_reset_token, load_auth, save_auth, find_user_by_reset_token,
load_plages, save_plages, heures_jour_min, 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 calcul import compute_week, minutes_to_hhmm, hhmm_to_minutes, heures_dues
from stats import compute_all_stats 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 ────────────────────────────────────────────────────────────── # ── Auth helpers ──────────────────────────────────────────────────────────────
def _sign_user_id(user_id: str) -> str:
"""Return the signed session cookie value: `<user_id>.<hmac_hex>`.
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: def _get_user(request: Request) -> str | None:
"""Extract current user from cookie.""" """Extract current user from signed cookie."""
uid = request.cookies.get("user_id", "").strip() return _verify_signed_cookie(request.cookies.get("user_id", ""))
return uid if _USERNAME_RE.match(uid) else None
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: 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) logger.info("connexion réussie: user_id=%r", user_id)
resp = RedirectResponse("/", status_code=302) 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 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) logger.info("mot de passe défini pour %r", user_id)
resp = RedirectResponse("/", status_code=302) 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 return resp

View File

@ -69,6 +69,24 @@ def save_config(cfg: dict):
CONFIG_FILE.write_text(json.dumps(cfg, indent=2, ensure_ascii=False)) 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: def heures_jour_min() -> int:
return int(float(load_config().get("heures_jour", 7.8)) * 60) return int(float(load_config().get("heures_jour", 7.8)) * 60)

View File

@ -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 datetime as dt_module
import tempfile
from pathlib import Path
import pytest import pytest
import calcul 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): class FrozenDatetime(dt_module.datetime):

View File

@ -1,16 +1,9 @@
"""Smoke tests pour les nouvelles routes /logs et /aide + journalisation présence.""" """Smoke tests pour les nouvelles routes /logs et /aide + journalisation présence."""
import sys import sys
import tempfile
from pathlib import Path
# Prépare un DATA_DIR temporaire AVANT l'import de models # DATA_DIR temporaire est positionné par conftest.py avant l'import de models.
_TMP = Path(tempfile.mkdtemp()) import models # noqa: F401 (réexport pratique pour les tests ci-dessous)
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"}')
# Recharge main dans un état propre
sys.path.insert(0, ".") sys.path.insert(0, ".")
import main import main
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@ -19,14 +12,15 @@ client = TestClient(main.app)
def _login_as(uid: str): 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 import bcrypt
models.save_auth(uid, email=f"{uid}@x.fr", models.save_auth(uid, email=f"{uid}@x.fr",
password_hash=bcrypt.hashpw(b"x" * 12, bcrypt.gensalt()).decode()) password_hash=bcrypt.hashpw(b"x" * 12, bcrypt.gensalt()).decode())
r = client.post("/login/password", data={"email": f"{uid}@x.fr", "password": "x" * 12}, r = client.post("/login/password", data={"email": f"{uid}@x.fr", "password": "x" * 12},
follow_redirects=False) follow_redirects=False)
assert r.status_code == 302, r.text 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") COOKIES = _login_as("alice")

View File

@ -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