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:
47
app/main.py
47
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: `<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:
|
||||
"""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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user