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.
172 lines
6.1 KiB
Python
172 lines
6.1 KiB
Python
# Piège : les compteurs de limitation sont lus AVANT le hachage Argon2. Dans l'autre ordre,
|
|
# chaque requête rejetée coûterait quand même 17 ms de processeur et 19 Mio de mémoire, et la
|
|
# protection deviendrait l'amplificateur de déni de service qu'elle est censée empêcher.
|
|
# Piège : quand l'email est inconnu, `verify_dummy()` consomme le même temps qu'une
|
|
# vérification réelle. Sans lui, l'écart de temps de réponse est un oracle d'existence.
|
|
# Piège : la tentative échouée est validée en base AVANT que l'erreur ne soit levée.
|
|
# `get_session()` ne valide pas de lui-même, donc la preuve disparaîtrait avec la transaction.
|
|
|
|
from dataclasses import dataclass
|
|
from typing import NoReturn, Protocol
|
|
from uuid import UUID
|
|
|
|
from app.core.hashing import Argon2Hasher
|
|
from app.core.principal import Principal
|
|
from app.core.roles import AccountKind, Role
|
|
from app.core.security import TokenPolicy, encode_access_token
|
|
from app.models.audit_log import AuditAction
|
|
from app.models.login_attempt import LoginOutcome
|
|
from app.repositories.audit_log import AuditLogRepository
|
|
from app.repositories.login_attempt import LoginAttemptRepository
|
|
from app.repositories.user import UserRepository
|
|
|
|
|
|
class Transaction(Protocol):
|
|
async def commit(self) -> None: ...
|
|
|
|
|
|
class AuthError(Exception):
|
|
pass
|
|
|
|
|
|
class InvalidCredentialsError(AuthError):
|
|
pass
|
|
|
|
|
|
class RateLimitedError(AuthError):
|
|
def __init__(self, retry_after: int) -> None:
|
|
super().__init__("Trop de tentatives")
|
|
self.retry_after = retry_after
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class LoginPolicy:
|
|
window_seconds: int
|
|
max_failures_per_identifier_and_ip: int
|
|
max_failures_per_ip: int
|
|
max_failures_per_identifier: int
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class AuthenticatedSession:
|
|
principal: Principal
|
|
access_token: str
|
|
expires_in: int
|
|
|
|
|
|
class AuthService:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
users: UserRepository,
|
|
attempts: LoginAttemptRepository,
|
|
audit: AuditLogRepository,
|
|
hasher: Argon2Hasher,
|
|
transaction: Transaction,
|
|
token_policy: TokenPolicy,
|
|
login_policy: LoginPolicy,
|
|
) -> None:
|
|
self._users = users
|
|
self._attempts = attempts
|
|
self._audit = audit
|
|
self._hasher = hasher
|
|
self._transaction = transaction
|
|
self._token_policy = token_policy
|
|
self._login_policy = login_policy
|
|
|
|
async def authenticate(
|
|
self, *, email: str, password: str, client_ip: str | None, user_agent: str | None
|
|
) -> AuthenticatedSession:
|
|
await self._refuse_si_limite(email=email, client_ip=client_ip, user_agent=user_agent)
|
|
|
|
compte = await self._users.get_by_email(email)
|
|
if compte is None:
|
|
await self._hasher.verify_dummy()
|
|
await self._echoue(email, client_ip, LoginOutcome.IDENTIFIANTS_INVALIDES)
|
|
|
|
if not await self._hasher.verify(compte.password_hash, password):
|
|
await self._echoue(
|
|
email, client_ip, LoginOutcome.IDENTIFIANTS_INVALIDES, user_id=compte.id
|
|
)
|
|
|
|
if not compte.is_active or compte.kind != AccountKind.HUMAIN.value:
|
|
await self._echoue(
|
|
email, client_ip, LoginOutcome.COMPTE_INDISPONIBLE, user_id=compte.id
|
|
)
|
|
|
|
if self._hasher.needs_rehash(compte.password_hash):
|
|
await self._users.rehash_password(compte.id, await self._hasher.hash(password))
|
|
|
|
await self._users.touch_last_login(compte.id)
|
|
await self._attempts.record(
|
|
email=email, client_ip=client_ip, outcome=LoginOutcome.SUCCES, user_id=compte.id
|
|
)
|
|
await self._transaction.commit()
|
|
|
|
return self.issue_access_token(
|
|
Principal(
|
|
id=compte.id,
|
|
email=compte.email,
|
|
role=Role(compte.role),
|
|
kind=AccountKind(compte.kind),
|
|
must_change_password=compte.must_change_password,
|
|
)
|
|
)
|
|
|
|
def issue_access_token(self, principal: Principal) -> AuthenticatedSession:
|
|
jeton = encode_access_token(
|
|
self._token_policy,
|
|
subject=principal.id,
|
|
role=principal.role.value,
|
|
kind=principal.kind.value,
|
|
)
|
|
return AuthenticatedSession(
|
|
principal=principal,
|
|
access_token=jeton,
|
|
expires_in=int(self._token_policy.access_ttl.total_seconds()),
|
|
)
|
|
|
|
async def _refuse_si_limite(
|
|
self, *, email: str, client_ip: str | None, user_agent: str | None
|
|
) -> None:
|
|
politique = self._login_policy
|
|
compteurs = await self._attempts.count_recent_failures(
|
|
email=email, client_ip=client_ip, window_seconds=politique.window_seconds
|
|
)
|
|
|
|
depasse = (
|
|
compteurs.per_identifier_and_ip >= politique.max_failures_per_identifier_and_ip
|
|
or compteurs.per_ip >= politique.max_failures_per_ip
|
|
or compteurs.per_identifier >= politique.max_failures_per_identifier
|
|
)
|
|
if not depasse:
|
|
return
|
|
|
|
await self._attempts.record(email=email, client_ip=client_ip, outcome=LoginOutcome.LIMITE)
|
|
# Un blocage déclenché par l'identifiant seul signe une attaque distribuée : lui seul
|
|
# mérite une trace durable, les échecs ordinaires restent dans `login_attempt`.
|
|
if compteurs.per_identifier >= politique.max_failures_per_identifier:
|
|
await self._audit.record(
|
|
action=AuditAction.LIMITE_PAR_IDENTIFIANT,
|
|
actor_label=email.strip().lower(),
|
|
client_ip=client_ip,
|
|
user_agent=user_agent,
|
|
detail={"motif": "seuil par identifiant depasse"},
|
|
)
|
|
await self._transaction.commit()
|
|
raise RateLimitedError(politique.window_seconds)
|
|
|
|
async def _echoue(
|
|
self,
|
|
email: str,
|
|
client_ip: str | None,
|
|
outcome: LoginOutcome,
|
|
*,
|
|
user_id: UUID | None = None,
|
|
) -> NoReturn:
|
|
await self._attempts.record(
|
|
email=email, client_ip=client_ip, outcome=outcome, user_id=user_id
|
|
)
|
|
await self._transaction.commit()
|
|
raise InvalidCredentialsError("Identifiants invalides")
|