Files
ENI-projet-piscine/apps/backend/tests/services/test_auth.py
T
Johan LEROY ef933bea1a feat(backend): authentifie par mot de passe et refuse les routes par défaut
Connexion, lecture du compte connecté, RBAC à trois rôles ordonnés et
limitation de débit à fenêtre glissante. Ajoute `login_attempt`, le
compteur de la limitation, et `audit_log`, en ajout seul.

Trois ordres d'exécution portent la sécurité de ce commit, et chacun a
son test :

- les compteurs sont lus AVANT le hachage Argon2, sinon chaque requête
  rejetée coûterait quand même 17 ms et 19 Mio, et la protection serait
  l'amplificateur de déni de service qu'elle doit empêcher ;
- un haché leurre est vérifié quand l'adresse est inconnue, sinon l'écart
  entre 2 ms et 17 ms est un oracle d'existence de compte ;
- la tentative échouée est validée en base avant que l'erreur ne soit
  levée, `get_session()` ne validant pas de lui-même.

Pas de verrouillage de compte : il suffirait de cinq requêtes pour mettre
un administrateur dehors, et il ne fait rien contre le bourrage
d'identifiants horizontal. Trois seuils le remplacent, dont un par couple
(identifiant, IP) qui garantit qu'un attaquant ne peut pas empêcher la
victime de se connecter depuis sa propre adresse.

`audit_log` est en ajout seul au niveau de PostgreSQL, par deux
déclencheurs. Le second n'est pas redondant : TRUNCATE ne passe pas par
les déclencheurs de ligne.

`test_route_protection.py` interroge réellement chaque route sans jeton.
Rendre une route publique impose donc de modifier une liste dans un
fichier de test, ce qui se voit en revue.

Le gestionnaire de 422 arrive ici et non plus tard : la réponse par
défaut de FastAPI contient la valeur rejetée, donc le mot de passe. Le
test qui le prouve serait rouge sans lui.
2026-09-15 14:41:25 +02:00

235 lines
7.4 KiB
Python

from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from typing import Any
from uuid import UUID, uuid4
import pytest
from app.core.security import TokenPolicy, decode_access_token
from app.models.login_attempt import LoginOutcome
from app.repositories.login_attempt import FailureCounts
from app.services.auth import (
AuthService,
InvalidCredentialsError,
LoginPolicy,
RateLimitedError,
)
POLITIQUE_JETON = TokenPolicy(
secret="un-secret-de-test-de-plus-de-trente-deux-caracteres",
issuer="enervision-api",
audience="enervision-web",
access_ttl=timedelta(minutes=15),
)
POLITIQUE_CONNEXION = LoginPolicy(
window_seconds=900,
max_failures_per_identifier_and_ip=5,
max_failures_per_ip=20,
max_failures_per_identifier=50,
)
@dataclass
class FauxCompte:
id: UUID = field(default_factory=uuid4)
email: str = "operateur@enervision.fr"
password_hash: str = "$argon2id$factice"
role: str = "operateur"
kind: str = "human"
is_active: bool = True
must_change_password: bool = False
credentials_changed_at: datetime = field(default_factory=lambda: datetime.now(UTC))
class FauxDepotComptes:
def __init__(self, compte: FauxCompte | None) -> None:
self.compte = compte
self.rehachages = 0
self.connexions_datees = 0
async def get_by_email(self, email: str) -> FauxCompte | None:
return self.compte
async def rehash_password(self, user_id: UUID, password_hash: str) -> None:
self.rehachages += 1
async def touch_last_login(self, user_id: UUID) -> None:
self.connexions_datees += 1
class FauxDepotTentatives:
def __init__(self, compteurs: FailureCounts | None = None) -> None:
self.compteurs = compteurs or FailureCounts(0, 0, 0)
self.enregistrees: list[str] = []
async def count_recent_failures(self, **_: object) -> FailureCounts:
return self.compteurs
async def record(self, *, outcome: object, **_: object) -> None:
self.enregistrees.append(str(outcome))
class FauxDepotAudit:
def __init__(self) -> None:
self.lignes: list[tuple[str, Mapping[str, Any] | None]] = []
async def record(self, *, action: object, detail: Any = None, **_: object) -> None:
self.lignes.append((str(action), detail))
class FauxHacheur:
def __init__(self, *, accepte: bool = True, rehachage_requis: bool = False) -> None:
self.verifications = 0
self.hachages = 0
self._accepte = accepte
self._rehachage_requis = rehachage_requis
async def hash(self, password: str) -> str:
self.hachages += 1
return "$argon2id$nouvelle"
async def verify(self, stored: str, password: str) -> bool:
self.verifications += 1
return self._accepte
async def verify_dummy(self) -> None:
self.verifications += 1
def needs_rehash(self, stored: str) -> bool:
return self._rehachage_requis
class FausseTransaction:
def __init__(self) -> None:
self.validations = 0
async def commit(self) -> None:
self.validations += 1
def fabrique_service(
*,
compte: FauxCompte | None = None,
compteurs: FailureCounts | None = None,
hacheur: FauxHacheur | None = None,
) -> tuple[AuthService, FauxDepotComptes, FauxDepotTentatives, FauxDepotAudit, FauxHacheur]:
comptes = FauxDepotComptes(compte)
tentatives = FauxDepotTentatives(compteurs)
audit = FauxDepotAudit()
hacheur = hacheur or FauxHacheur()
service = AuthService(
users=comptes, # type: ignore[arg-type]
attempts=tentatives, # type: ignore[arg-type]
audit=audit, # type: ignore[arg-type]
hasher=hacheur, # type: ignore[arg-type]
transaction=FausseTransaction(),
token_policy=POLITIQUE_JETON,
login_policy=POLITIQUE_CONNEXION,
)
return service, comptes, tentatives, audit, hacheur
async def connecte(service: AuthService, mot_de_passe: str = "un-mot-de-passe-valide") -> object:
return await service.authenticate(
email="operateur@enervision.fr",
password=mot_de_passe,
client_ip="203.0.113.10",
user_agent="pytest",
)
async def test_authenticate_returns_a_readable_access_token_when_credentials_match() -> None:
compte = FauxCompte()
service, comptes, tentatives, _, _ = fabrique_service(compte=compte)
session = await connecte(service)
claims = decode_access_token(POLITIQUE_JETON, session.access_token) # type: ignore[attr-defined]
assert claims.subject == compte.id
assert claims.role == "operateur"
assert tentatives.enregistrees == [LoginOutcome.SUCCES.value]
assert comptes.connexions_datees == 1
async def test_authenticate_verifies_a_decoy_digest_when_the_email_is_unknown() -> None:
service, _, tentatives, _, hacheur = fabrique_service(compte=None)
with pytest.raises(InvalidCredentialsError):
await connecte(service)
assert hacheur.verifications == 1
assert tentatives.enregistrees == [LoginOutcome.IDENTIFIANTS_INVALIDES.value]
async def test_authenticate_skips_hashing_entirely_when_the_rate_limit_is_reached() -> None:
compteurs = FailureCounts(per_identifier_and_ip=5, per_ip=5, per_identifier=5)
service, _, tentatives, audit, hacheur = fabrique_service(
compte=FauxCompte(), compteurs=compteurs
)
with pytest.raises(RateLimitedError):
await connecte(service)
assert hacheur.verifications == 0
assert hacheur.hachages == 0
assert tentatives.enregistrees == [LoginOutcome.LIMITE.value]
assert audit.lignes == []
async def test_authenticate_audits_when_the_identifier_threshold_alone_is_reached() -> None:
compteurs = FailureCounts(per_identifier_and_ip=0, per_ip=0, per_identifier=50)
service, _, _, audit, _ = fabrique_service(compte=FauxCompte(), compteurs=compteurs)
with pytest.raises(RateLimitedError):
await connecte(service)
assert len(audit.lignes) == 1
assert "identifier_throttled" in audit.lignes[0][0]
async def test_authenticate_rejects_a_wrong_password_with_the_generic_error() -> None:
service, _, tentatives, _, _ = fabrique_service(
compte=FauxCompte(), hacheur=FauxHacheur(accepte=False)
)
with pytest.raises(InvalidCredentialsError):
await connecte(service)
assert tentatives.enregistrees == [LoginOutcome.IDENTIFIANTS_INVALIDES.value]
@pytest.mark.parametrize(
"compte",
[FauxCompte(is_active=False), FauxCompte(kind="service")],
ids=["compte_desactive", "compte_de_service"],
)
async def test_authenticate_rejects_unavailable_accounts_after_checking_the_password(
compte: FauxCompte,
) -> None:
service, _, tentatives, _, hacheur = fabrique_service(compte=compte)
with pytest.raises(InvalidCredentialsError):
await connecte(service)
assert hacheur.verifications == 1
assert tentatives.enregistrees == [LoginOutcome.COMPTE_INDISPONIBLE.value]
async def test_authenticate_rehashes_the_password_when_the_parameters_changed() -> None:
service, comptes, _, _, _ = fabrique_service(
compte=FauxCompte(), hacheur=FauxHacheur(rehachage_requis=True)
)
await connecte(service)
assert comptes.rehachages == 1
async def test_authenticate_leaves_the_digest_alone_when_the_parameters_match() -> None:
service, comptes, _, _, _ = fabrique_service(compte=FauxCompte())
await connecte(service)
assert comptes.rehachages == 0