feat(backend): pose les primitives de sécurité

Couche pure, sans FastAPI ni session : rôles ordonnés, `Principal`,
encodage et décodage des jetons d'accès, empreinte des jetons de
rafraîchissement, et hachage Argon2id poussé dans un fil borné.

Aucun de ces modules ne lit `get_settings()`, mis en cache par
`lru_cache` et donc contaminé entre tests : les paramètres arrivent par
`TokenPolicy` et par `build_hasher()`.

Argon2id est calibré à m=19456 KiB, t=2, p=1, soit 17 ms mesurés sur un
poste de développement.
This commit is contained in:
Johan LEROY
2026-09-15 14:25:41 +02:00
parent 008cf581a7
commit 53af7a76d8
9 changed files with 627 additions and 1 deletions
+63
View File
@@ -0,0 +1,63 @@
# Piège : `PasswordHasher.verify()` bloque 17 ms. Appelé tel quel dans un `async def`, il fige
# la boucle d'événements et gèle toutes les requêtes en cours, pas seulement la connexion.
# `Argon2Hasher` le pousse donc dans un fil, sous un `CapacityLimiter` : le pool par défaut
# d'anyio accepte 40 fils, soit 40 x 19 Mio dans le pire cas sur une machine qui héberge aussi
# PostgreSQL, Prometheus et Grafana.
# Piège : `verify_dummy()` doit être appelé quand l'utilisateur est introuvable. Sans lui,
# l'écart entre 2 ms et 17 ms est un oracle d'existence de compte, mesurable à distance.
import secrets
import anyio
import anyio.to_thread
from argon2 import PasswordHasher
from argon2.exceptions import Argon2Error, InvalidHashError, VerificationError
_ERREURS_DE_VERIFICATION = (VerificationError, InvalidHashError, Argon2Error)
class Argon2Hasher:
def __init__(self, hasher: PasswordHasher, *, max_concurrency: int) -> None:
self._hasher = hasher
self._limiter = anyio.CapacityLimiter(max_concurrency)
self._leurre = hasher.hash(secrets.token_urlsafe(32))
async def hash(self, password: str) -> str:
return await anyio.to_thread.run_sync(self._hasher.hash, password, limiter=self._limiter)
async def verify(self, stored: str, password: str) -> bool:
return await anyio.to_thread.run_sync(self._verify, stored, password, limiter=self._limiter)
async def verify_dummy(self) -> None:
await self.verify(self._leurre, "")
def needs_rehash(self, stored: str) -> bool:
try:
return self._hasher.check_needs_rehash(stored)
except _ERREURS_DE_VERIFICATION:
return True
def _verify(self, stored: str, password: str) -> bool:
try:
return self._hasher.verify(stored, password)
except _ERREURS_DE_VERIFICATION:
return False
def build_hasher(
*,
time_cost: int,
memory_cost_kib: int,
parallelism: int,
max_concurrency: int,
) -> Argon2Hasher:
return Argon2Hasher(
PasswordHasher(
time_cost=time_cost,
memory_cost=memory_cost_kib,
parallelism=parallelism,
hash_len=32,
salt_len=16,
),
max_concurrency=max_concurrency,
)
+18
View File
@@ -0,0 +1,18 @@
# Pourquoi : tout le code métier dépend de `Principal` et jamais du modèle ORM ni des claims
# du jeton. C'est ce qui garde la bascule vers un fournisseur OIDC locale à
# `get_current_principal()` et à `AuthService.authenticate()`, au lieu de la répandre dans
# chaque endpoint.
from dataclasses import dataclass
from uuid import UUID
from app.core.roles import AccountKind, Role
@dataclass(frozen=True, slots=True)
class Principal:
id: UUID
email: str
role: Role
kind: AccountKind
must_change_password: bool
+26
View File
@@ -0,0 +1,26 @@
from enum import StrEnum
from typing import Final
class Role(StrEnum):
# Contrainte : ces valeurs voyagent en base, en JSON et dans les jetons. Elles restent
# en ASCII, contrairement au libellé « opérateur » affiché à l'utilisateur.
LECTEUR = "lecteur"
OPERATEUR = "operateur"
ADMIN = "admin"
class AccountKind(StrEnum):
HUMAIN = "human"
SERVICE = "service"
ROLE_RANK: Final[dict[Role, int]] = {
Role.LECTEUR: 0,
Role.OPERATEUR: 1,
Role.ADMIN: 2,
}
def has_at_least(actual: Role, required: Role) -> bool:
return ROLE_RANK[actual] >= ROLE_RANK[required]
+117
View File
@@ -0,0 +1,117 @@
# Piège : `decode_access_token()` porte trois barrières indépendantes, et retirer l'une
# d'elles ne casse aucun test évident. L'algorithme est épinglé, sinon un jeton forgé en
# `alg: none` passerait. L'audience et l'émetteur sont vérifiés, sinon un jeton émis pour
# un autre service serait accepté. Le claim `typ` est comparé, sinon un jeton de
# rafraîchissement servirait de jeton d'accès, ce qui transformerait une fenêtre de
# 15 minutes en fenêtre de 7 jours.
# Contrainte : ce module ne lit jamais `get_settings()`, qui est mis en cache par
# `lru_cache` et se contaminerait entre tests. Tout paramètre arrive par `TokenPolicy`.
import hashlib
import secrets
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Final
from uuid import UUID, uuid4
import jwt
ACCESS_TOKEN_TYPE: Final = "access" # noqa: S105
REFRESH_SECRET_BYTES: Final = 32
_ALGORITHME: Final = "HS256"
_CLAIMS_REQUIS: Final = ["iss", "aud", "sub", "iat", "exp", "jti", "typ", "role", "kind"]
class TokenInvalidError(Exception):
pass
class TokenExpiredError(TokenInvalidError):
pass
@dataclass(frozen=True, slots=True)
class TokenPolicy:
secret: str
issuer: str
audience: str
access_ttl: timedelta
@dataclass(frozen=True, slots=True)
class AccessClaims:
subject: UUID
role: str
kind: str
token_id: UUID
issued_at: datetime
def encode_access_token(
policy: TokenPolicy,
*,
subject: UUID,
role: str,
kind: str,
now: datetime | None = None,
) -> str:
emis_a = now or datetime.now(UTC)
return jwt.encode(
{
"iss": policy.issuer,
"aud": policy.audience,
"sub": str(subject),
"iat": emis_a,
"exp": emis_a + policy.access_ttl,
"jti": str(uuid4()),
"typ": ACCESS_TOKEN_TYPE,
"role": role,
"kind": kind,
},
policy.secret,
algorithm=_ALGORITHME,
)
def decode_access_token(policy: TokenPolicy, token: str) -> AccessClaims:
try:
charge = jwt.decode(
token,
policy.secret,
algorithms=[_ALGORITHME],
audience=policy.audience,
issuer=policy.issuer,
options={"require": _CLAIMS_REQUIS},
)
except jwt.ExpiredSignatureError as erreur:
raise TokenExpiredError("Jeton expiré") from erreur
except jwt.InvalidTokenError as erreur:
raise TokenInvalidError("Jeton invalide") from erreur
if charge["typ"] != ACCESS_TOKEN_TYPE:
raise TokenInvalidError("Type de jeton inattendu")
try:
sujet = UUID(charge["sub"])
identifiant = UUID(charge["jti"])
except (AttributeError, TypeError, ValueError) as erreur:
raise TokenInvalidError("Identifiants du jeton illisibles") from erreur
return AccessClaims(
subject=sujet,
role=str(charge["role"]),
kind=str(charge["kind"]),
token_id=identifiant,
issued_at=datetime.fromtimestamp(charge["iat"], tz=UTC),
)
def generate_refresh_secret() -> str:
return secrets.token_urlsafe(REFRESH_SECRET_BYTES)
# SHA-256 nu, pas Argon2id : 256 bits de CSPRNG n'ont ni dictionnaire ni préimage atteignable,
# et une KDF lente coûterait 17 ms à chaque rafraîchissement pour aucun gain.
def fingerprint_refresh(secret: str) -> bytes:
return hashlib.sha256(secret.encode("utf-8")).digest()