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.
This commit is contained in:
Johan LEROY
2026-09-15 14:41:25 +02:00
parent a8f59e6e76
commit ef933bea1a
24 changed files with 1904 additions and 10 deletions
@@ -0,0 +1,119 @@
"""tentatives de connexion et journal d audit
Revision ID: 517053a3c044
Revises: b1a7c3d9e240
Create Date: 2026-09-15 14:31:07.966180
Deux tables aux vocations opposees. `login_attempt` est le compteur de la limitation
de debit : son volume est pilote par l'attaquant, donc elle se purge. `audit_log` est
en ajout seul, garanti par deux declencheurs.
Le declencheur TRUNCATE n'est pas redondant : TRUNCATE ne passe pas par les
declencheurs de ligne. Et RAISE EXCEPTION plutot qu'un RETURN NULL, qui annulerait
l'operation silencieusement.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "517053a3c044"
down_revision: str | Sequence[str] | None = "b1a7c3d9e240"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
FONCTION_AJOUT_SEUL = """
CREATE FUNCTION audit_log_append_only() RETURNS trigger AS $$
BEGIN
RAISE EXCEPTION 'audit_log est en ajout seul : % interdit', TG_OP;
END
$$ LANGUAGE plpgsql;
"""
DECLENCHEUR_LIGNE = """
CREATE TRIGGER audit_log_no_update_delete
BEFORE UPDATE OR DELETE ON audit_log
FOR EACH ROW EXECUTE FUNCTION audit_log_append_only();
"""
DECLENCHEUR_TRUNCATE = """
CREATE TRIGGER audit_log_no_truncate
BEFORE TRUNCATE ON audit_log
FOR EACH STATEMENT EXECUTE FUNCTION audit_log_append_only();
"""
def upgrade() -> None:
op.create_table(
"login_attempt",
sa.Column("id", sa.BigInteger(), sa.Identity(always=True), nullable=False),
sa.Column(
"occurred_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("email_tried", sa.String(length=320), nullable=False),
sa.Column("client_ip", postgresql.INET(), nullable=True),
sa.Column("outcome", sa.Text(), nullable=False),
sa.Column("user_id", sa.UUID(), nullable=True),
sa.CheckConstraint(
"outcome in ('success', 'bad_credentials', 'throttled', 'inactive')",
name="ck_login_attempt_outcome",
),
sa.PrimaryKeyConstraint("id", name="pk_login_attempt"),
)
op.create_index(
"ix_login_attempt_email_date", "login_attempt", ["email_tried", "occurred_at"]
)
op.create_index("ix_login_attempt_ip_date", "login_attempt", ["client_ip", "occurred_at"])
op.create_table(
"audit_log",
sa.Column("id", sa.BigInteger(), sa.Identity(always=True), nullable=False),
sa.Column(
"occurred_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("actor_id", sa.UUID(), nullable=True),
sa.Column("actor_email", sa.Text(), nullable=True),
sa.Column("actor_role", sa.Text(), nullable=True),
sa.Column("action", sa.Text(), nullable=False),
sa.Column("target_type", sa.Text(), nullable=True),
sa.Column("target_id", sa.Text(), nullable=True),
sa.Column("outcome", sa.Text(), nullable=False),
sa.Column("client_ip", postgresql.INET(), nullable=True),
sa.Column("user_agent", sa.Text(), nullable=True),
sa.Column(
"detail",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("jsonb_build_object()"),
nullable=False,
),
sa.CheckConstraint("outcome in ('success', 'failure')", name="ck_audit_log_outcome"),
sa.PrimaryKeyConstraint("id", name="pk_audit_log"),
)
op.create_index("ix_audit_log_date", "audit_log", ["occurred_at"])
op.create_index("ix_audit_log_action_date", "audit_log", ["action", "occurred_at"])
op.execute(FONCTION_AJOUT_SEUL)
op.execute(DECLENCHEUR_LIGNE)
op.execute(DECLENCHEUR_TRUNCATE)
def downgrade() -> None:
op.execute("DROP TRIGGER IF EXISTS audit_log_no_truncate ON audit_log;")
op.execute("DROP TRIGGER IF EXISTS audit_log_no_update_delete ON audit_log;")
op.execute("DROP FUNCTION IF EXISTS audit_log_append_only();")
op.drop_index("ix_audit_log_action_date", table_name="audit_log")
op.drop_index("ix_audit_log_date", table_name="audit_log")
op.drop_table("audit_log")
op.drop_index("ix_login_attempt_ip_date", table_name="login_attempt")
op.drop_index("ix_login_attempt_email_date", table_name="login_attempt")
op.drop_table("login_attempt")
+165 -1
View File
@@ -1,10 +1,174 @@
# Piège : `get_current_principal()` relit le compte en base à chaque requête au lieu de faire
# confiance aux claims. C'est le renoncement assumé à la propriété « sans état » : sur un seul
# service et une seule base, elle n'achetait rien, et la lecture par clé primaire coûte moins
# d'un pour cent du budget d'une requête. Ce qu'elle achète, c'est la révocation immédiate.
# Piège : le `Principal` est construit depuis la ligne, jamais depuis le claim `role`. Un claim
# périmé ne peut donc pas provoquer d'élévation de privilège.
from collections.abc import Callable
from datetime import timedelta
from functools import lru_cache
from typing import Annotated
from fastapi import Depends
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import Settings, get_settings
from app.core.hashing import Argon2Hasher, build_hasher
from app.core.principal import Principal
from app.core.roles import AccountKind, Role, has_at_least
from app.core.security import TokenExpiredError, TokenInvalidError, TokenPolicy
from app.core.security import decode_access_token as decode_token
from app.db.session import get_session
from app.repositories.audit_log import AuditLogRepository
from app.repositories.login_attempt import LoginAttemptRepository
from app.repositories.user import UserRepository
from app.services.auth import AuthService, LoginPolicy
SessionDep = Annotated[AsyncSession, Depends(get_session)]
SettingsDep = Annotated[Settings, Depends(get_settings)]
CODE_CHANGEMENT_REQUIS = "password_change_required"
_porteur = HTTPBearer(auto_error=False, scheme_name="Jeton d'accès")
CredentialsDep = Annotated[HTTPAuthorizationCredentials | None, Depends(_porteur)]
def _non_authentifie(description: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentification requise",
headers={"WWW-Authenticate": f'Bearer error="{description}"'},
)
def get_token_policy(settings: SettingsDep) -> TokenPolicy:
return TokenPolicy(
secret=settings.secret_key.get_secret_value(),
issuer=settings.jwt_issuer,
audience=settings.jwt_audience,
access_ttl=timedelta(seconds=settings.access_token_ttl_seconds),
)
# Construire un `Argon2Hasher` calcule un haché leurre, donc 17 ms : il est mis en cache sur
# les paramètres plutôt que reconstruit à chaque requête.
@lru_cache
def _hasher_cache(
time_cost: int, memory_cost_kib: int, parallelism: int, max_concurrency: int
) -> Argon2Hasher:
return build_hasher(
time_cost=time_cost,
memory_cost_kib=memory_cost_kib,
parallelism=parallelism,
max_concurrency=max_concurrency,
)
def get_hasher(settings: SettingsDep) -> Argon2Hasher:
return _hasher_cache(
settings.argon2_time_cost,
settings.argon2_memory_cost_kib,
settings.argon2_parallelism,
settings.argon2_max_concurrency,
)
def get_client_ip(request: Request, settings: SettingsDep) -> str | None:
# Derrière un proxy, `request.client.host` vaut l'IP du proxy : le compteur par IP
# deviendrait global, donc un déni de service auto-infligé. Le dernier élément est le seul
# qu'un proxy de confiance ait écrit, les précédents sont fournis par le client.
if settings.trust_proxy_headers:
transmis = request.headers.get("x-forwarded-for")
if transmis:
return transmis.split(",")[-1].strip()
return request.client.host if request.client else None
def get_auth_service(
session: SessionDep,
settings: SettingsDep,
hasher: Annotated[Argon2Hasher, Depends(get_hasher)],
token_policy: Annotated[TokenPolicy, Depends(get_token_policy)],
) -> AuthService:
return AuthService(
users=UserRepository(session),
attempts=LoginAttemptRepository(session),
audit=AuditLogRepository(session),
hasher=hasher,
transaction=session,
token_policy=token_policy,
login_policy=LoginPolicy(
window_seconds=settings.login_window_seconds,
max_failures_per_identifier_and_ip=(settings.login_max_failures_per_identifier_and_ip),
max_failures_per_ip=settings.login_max_failures_per_ip,
max_failures_per_identifier=settings.login_max_failures_per_identifier,
),
)
AuthServiceDep = Annotated[AuthService, Depends(get_auth_service)]
async def get_current_principal(
credentials: CredentialsDep,
session: SessionDep,
token_policy: Annotated[TokenPolicy, Depends(get_token_policy)],
) -> Principal:
if credentials is None:
raise _non_authentifie("invalid_request")
try:
claims = decode_token(token_policy, credentials.credentials)
except TokenExpiredError as erreur:
raise _non_authentifie("expired") from erreur
except TokenInvalidError as erreur:
raise _non_authentifie("invalid_token") from erreur
compte = await UserRepository(session).get_by_id(claims.subject)
if compte is None or not compte.is_active:
raise _non_authentifie("invalid_token")
if claims.issued_at < compte.credentials_changed_at:
raise _non_authentifie("token_stale")
if claims.role != compte.role:
raise _non_authentifie("token_stale")
return Principal(
id=compte.id,
email=compte.email,
role=Role(compte.role),
kind=AccountKind(compte.kind),
must_change_password=compte.must_change_password,
)
CurrentPrincipalDep = Annotated[Principal, Depends(get_current_principal)]
def require_role(minimum: Role) -> Callable[[Principal], Principal]:
def garde(principal: CurrentPrincipalDep) -> Principal:
if principal.must_change_password:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=CODE_CHANGEMENT_REQUIS
)
if not has_at_least(principal.role, minimum):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Droits insuffisants")
return principal
return garde
LecteurDep = Annotated[Principal, Depends(require_role(Role.LECTEUR))]
OperateurDep = Annotated[Principal, Depends(require_role(Role.OPERATEUR))]
AdminDep = Annotated[Principal, Depends(require_role(Role.ADMIN))]
def require_trusted_origin(request: Request, settings: SettingsDep) -> None:
# Un navigateur envoie toujours `Origin` sur une requête non sûre. Son absence signale un
# client hors navigateur, qui ne détient aucun cookie de victime : rien à protéger.
origine = request.headers.get("origin")
if origine is None:
return
if origine not in settings.allowed_origins:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Origine refusée")
+47
View File
@@ -0,0 +1,47 @@
# Piège : la réponse 422 par défaut de FastAPI contient la clé `input`, c'est-à-dire la valeur
# rejetée. Sur `/auth/login`, un corps malformé renverrait donc le mot de passe au client et le
# déposerait dans les journaux d'erreur. `validation_error_handler()` ne laisse passer que le
# champ fautif et le type d'erreur.
import uuid
from typing import Any
from fastapi import FastAPI, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from app.core.logging import get_logger
logger = get_logger(__name__)
async def validation_error_handler(_: Request, exception: RequestValidationError) -> JSONResponse:
champs: list[dict[str, Any]] = [
{
"champ": ".".join(str(element) for element in erreur["loc"]),
"type": erreur["type"],
}
for erreur in exception.errors()
]
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, content={"detail": champs}
)
async def unhandled_error_handler(request: Request, exception: Exception) -> JSONResponse:
correlation = uuid.uuid4().hex
logger.exception(
"erreur non gérée correlation=%s methode=%s chemin=%s",
correlation,
request.method,
request.url.path,
)
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"detail": "Erreur interne", "correlation": correlation},
)
def register_error_handlers(application: FastAPI) -> None:
application.add_exception_handler(RequestValidationError, validation_error_handler) # type: ignore[arg-type]
application.add_exception_handler(Exception, unhandled_error_handler)
+56
View File
@@ -0,0 +1,56 @@
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from app.api.deps import AuthServiceDep, CurrentPrincipalDep, get_client_ip
from app.core.logging import get_logger
from app.schemas.auth import LoginRequest, PrincipalResponse, TokenResponse
from app.services.auth import InvalidCredentialsError, RateLimitedError
router = APIRouter()
logger = get_logger(__name__)
DETAIL_IDENTIFIANTS = "Identifiants invalides"
@router.post("/login", response_model=TokenResponse, summary="Ouvre une session")
async def login(
payload: LoginRequest,
request: Request,
response: Response,
service: AuthServiceDep,
client_ip: str | None = Depends(get_client_ip),
) -> TokenResponse:
# Une réponse d'authentification ne doit jamais être conservée par un intermédiaire.
response.headers["Cache-Control"] = "no-store"
agent = request.headers.get("user-agent")
try:
session = await service.authenticate(
email=payload.email,
password=payload.password,
client_ip=client_ip,
user_agent=agent,
)
except RateLimitedError as erreur:
logger.warning("auth.rate_limited email=%s ip=%s", payload.email, client_ip)
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Trop de tentatives, réessayez plus tard",
headers={"Retry-After": str(erreur.retry_after)},
) from erreur
except InvalidCredentialsError as erreur:
logger.warning("auth.login.failure email=%s ip=%s", payload.email, client_ip)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail=DETAIL_IDENTIFIANTS
) from erreur
logger.info("auth.login.success user_id=%s ip=%s", session.principal.id, client_ip)
return TokenResponse(
access_token=session.access_token,
expires_in=session.expires_in,
principal=PrincipalResponse.from_principal(session.principal),
)
@router.get("/me", response_model=PrincipalResponse, summary="Décrit le compte connecté")
async def me(principal: CurrentPrincipalDep) -> PrincipalResponse:
return PrincipalResponse.from_principal(principal)
+3 -2
View File
@@ -1,6 +1,7 @@
from fastapi import APIRouter
from app.api.v1.endpoints import health
from app.api.v1.endpoints import auth, health
api_router = APIRouter()
api_router.include_router(health.router, prefix="/health")
api_router.include_router(health.router, prefix="/health", tags=["health"])
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
+3
View File
@@ -5,6 +5,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from prometheus_fastapi_instrumentator import Instrumentator
from app.api.errors import register_error_handlers
from app.api.v1.router import api_router
from app.core.config import Settings, get_settings
from app.core.logging import configure_logging, get_logger
@@ -46,6 +47,8 @@ def create_app(settings: Settings | None = None) -> FastAPI:
allow_headers=["*"],
)
register_error_handlers(application)
Instrumentator().instrument(application).expose(
application, endpoint="/metrics", include_in_schema=False
)
+3 -1
View File
@@ -1,6 +1,8 @@
# Piège : tout modèle absent de ce module reste invisible de `alembic revision
# --autogenerate`, qui générerait alors un drop de sa table.
from app.models.audit_log import AuditLog
from app.models.login_attempt import LoginAttempt
from app.models.user import AppUser
__all__ = ["AppUser"]
__all__ = ["AppUser", "AuditLog", "LoginAttempt"]
+64
View File
@@ -0,0 +1,64 @@
# Pourquoi : `actor_id` ne porte volontairement aucune clé étrangère. Une contrainte
# `ON DELETE SET NULL` déclencherait un UPDATE que le déclencheur d'ajout seul refuserait, donc
# la suppression d'un compte échouerait ; une contrainte `NO ACTION` interdirait toute
# suppression. `actor_email` et `actor_role` sont dénormalisés pour la même raison : le journal
# dit ce qui était vrai au moment de l'acte, pas ce qui est vrai aujourd'hui.
import uuid
from datetime import datetime
from enum import StrEnum
from typing import Any
from sqlalchemy import BigInteger, CheckConstraint, DateTime, Identity, Index, Text, func
from sqlalchemy.dialects.postgresql import INET, JSONB
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class AuditOutcome(StrEnum):
SUCCES = "success"
ECHEC = "failure"
class AuditAction(StrEnum):
COMPTE_CREE = "user.created"
COMPTE_ROLE_CHANGE = "user.role_changed"
COMPTE_DESACTIVE = "user.disabled"
COMPTE_ACTIVE = "user.enabled"
COMPTE_MOT_DE_PASSE_REINITIALISE = "user.password_reset_by_admin"
COMPTE_MOT_DE_PASSE_CHANGE = "user.password_changed"
REFRESH_REUTILISE = "auth.refresh_reuse_detected"
SESSIONS_REVOQUEES = "auth.all_sessions_revoked"
LIMITE_PAR_IDENTIFIANT = "auth.identifier_throttled"
ADMIN_AMORCE = "bootstrap.admin_created"
ISSUES_AUTORISEES = ", ".join(f"'{issue.value}'" for issue in AuditOutcome)
class AuditLog(Base):
__tablename__ = "audit_log"
__table_args__ = (
CheckConstraint(f"outcome in ({ISSUES_AUTORISEES})", name="ck_audit_log_outcome"),
Index("ix_audit_log_date", "occurred_at"),
Index("ix_audit_log_action_date", "action", "occurred_at"),
)
id: Mapped[int] = mapped_column(BigInteger, Identity(always=True), primary_key=True)
occurred_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
actor_id: Mapped[uuid.UUID | None] = mapped_column(PG_UUID(as_uuid=True), nullable=True)
actor_email: Mapped[str | None] = mapped_column(Text, nullable=True)
actor_role: Mapped[str | None] = mapped_column(Text, nullable=True)
action: Mapped[str] = mapped_column(Text, nullable=False)
target_type: Mapped[str | None] = mapped_column(Text, nullable=True)
target_id: Mapped[str | None] = mapped_column(Text, nullable=True)
outcome: Mapped[str] = mapped_column(Text, nullable=False)
client_ip: Mapped[str | None] = mapped_column(INET, nullable=True)
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
detail: Mapped[dict[str, Any]] = mapped_column(
JSONB, nullable=False, server_default=func.jsonb_build_object()
)
+44
View File
@@ -0,0 +1,44 @@
# Pourquoi : les tentatives vivent ici et non dans `audit_log`, qui est en ajout seul. Leur
# volume est piloté par l'attaquant : une force brute y écrirait des millions de lignes
# indestructibles. Cette table-ci se purge, et c'est aussi le compteur de la limitation.
# Piège : la tentative est enregistrée même quand l'email est inconnu, sinon le 429 dirait
# qu'un compte existe.
import uuid
from datetime import datetime
from enum import StrEnum
from sqlalchemy import BigInteger, CheckConstraint, DateTime, Identity, Index, String, Text, func
from sqlalchemy.dialects.postgresql import INET
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class LoginOutcome(StrEnum):
SUCCES = "success"
IDENTIFIANTS_INVALIDES = "bad_credentials"
LIMITE = "throttled"
COMPTE_INDISPONIBLE = "inactive"
ISSUES_AUTORISEES = ", ".join(f"'{issue.value}'" for issue in LoginOutcome)
class LoginAttempt(Base):
__tablename__ = "login_attempt"
__table_args__ = (
CheckConstraint(f"outcome in ({ISSUES_AUTORISEES})", name="ck_login_attempt_outcome"),
Index("ix_login_attempt_email_date", "email_tried", "occurred_at"),
Index("ix_login_attempt_ip_date", "client_ip", "occurred_at"),
)
id: Mapped[int] = mapped_column(BigInteger, Identity(always=True), primary_key=True)
occurred_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
email_tried: Mapped[str] = mapped_column(String(320), nullable=False)
client_ip: Mapped[str | None] = mapped_column(INET, nullable=True)
outcome: Mapped[str] = mapped_column(Text, nullable=False)
user_id: Mapped[uuid.UUID | None] = mapped_column(PG_UUID(as_uuid=True), nullable=True)
@@ -0,0 +1,62 @@
# Piège : `detail` passe par une liste blanche de clés et jamais par un `dict(**kwargs)`. La
# table est en ajout seul : une clé inattendue qui porterait un secret ou une donnée
# personnelle ne pourrait plus en être retirée.
from collections.abc import Mapping
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.principal import Principal
from app.models.audit_log import AuditAction, AuditLog, AuditOutcome
CLES_DE_DETAIL_AUTORISEES = frozenset(
{
"email",
"role_avant",
"role_apres",
"famille",
"motif",
"source",
"sessions_revoquees",
}
)
def assemble_detail(brut: Mapping[str, Any] | None) -> dict[str, Any]:
if not brut:
return {}
return {cle: valeur for cle, valeur in brut.items() if cle in CLES_DE_DETAIL_AUTORISEES}
class AuditLogRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def record(
self,
*,
action: AuditAction,
outcome: AuditOutcome = AuditOutcome.SUCCES,
actor: Principal | None = None,
actor_label: str | None = None,
target_type: str | None = None,
target_id: str | None = None,
client_ip: str | None = None,
user_agent: str | None = None,
detail: Mapping[str, Any] | None = None,
) -> None:
self._session.add(
AuditLog(
actor_id=actor.id if actor else None,
actor_email=actor.email if actor else actor_label,
actor_role=actor.role.value if actor else None,
action=action.value,
target_type=target_type,
target_id=target_id,
outcome=outcome.value,
client_ip=client_ip,
user_agent=user_agent,
detail=assemble_detail(detail),
)
)
@@ -0,0 +1,67 @@
# Pourquoi : les trois compteurs tiennent en une seule requête, grâce aux clauses FILTER de
# PostgreSQL. Trois `count(*)` séparés feraient trois allers-retours sur le chemin critique de
# la connexion, qui est justement celui qu'un attaquant martèle.
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from uuid import UUID
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.login_attempt import LoginAttempt, LoginOutcome
@dataclass(frozen=True, slots=True)
class FailureCounts:
per_identifier_and_ip: int
per_ip: int
per_identifier: int
class LoginAttemptRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def record(
self,
*,
email: str,
client_ip: str | None,
outcome: LoginOutcome,
user_id: UUID | None = None,
) -> None:
self._session.add(
LoginAttempt(
email_tried=email.strip().lower(),
client_ip=client_ip,
outcome=outcome.value,
user_id=user_id,
)
)
async def count_recent_failures(
self, *, email: str, client_ip: str | None, window_seconds: int
) -> FailureCounts:
identifiant = email.strip().lower()
meme_email = LoginAttempt.email_tried == identifiant
meme_ip = LoginAttempt.client_ip == client_ip
requete = select(
func.count().filter(and_(meme_email, meme_ip)),
func.count().filter(meme_ip),
func.count().filter(meme_email),
).where(
LoginAttempt.outcome != LoginOutcome.SUCCES.value,
LoginAttempt.occurred_at > datetime.now(UTC) - timedelta(seconds=window_seconds),
meme_email | meme_ip,
)
par_identifiant_et_ip, par_ip, par_identifiant = (
await self._session.execute(requete)
).one()
return FailureCounts(
per_identifier_and_ip=par_identifiant_et_ip,
per_ip=par_ip,
per_identifier=par_identifiant,
)
+3 -3
View File
@@ -67,7 +67,7 @@ class UserRepository:
.values(
password_hash=password_hash,
must_change_password=must_change_password,
credentials_changed_at=func.now(),
credentials_changed_at=func.clock_timestamp(),
)
)
@@ -86,12 +86,12 @@ class UserRepository:
await self._session.execute(
update(AppUser)
.where(AppUser.id == user_id)
.values(role=role.value, credentials_changed_at=func.now())
.values(role=role.value, credentials_changed_at=func.clock_timestamp())
)
async def set_active(self, user_id: UUID, *, is_active: bool) -> None:
await self._session.execute(
update(AppUser)
.where(AppUser.id == user_id)
.values(is_active=is_active, credentials_changed_at=func.now())
.values(is_active=is_active, credentials_changed_at=func.clock_timestamp())
)
+44
View File
@@ -0,0 +1,44 @@
# Contrainte : le mot de passe est borné à 128 caractères. Sans plafond, une chaîne de dix
# mégaoctets ferait travailler Argon2 gratuitement, à la charge du serveur.
from typing import Literal, Self
from uuid import UUID
from pydantic import BaseModel, ConfigDict, EmailStr, Field
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
PASSWORD_MIN_LENGTH = 12
PASSWORD_MAX_LENGTH = 128
class LoginRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=1, max_length=PASSWORD_MAX_LENGTH)
class PasswordChangeRequest(BaseModel):
current_password: str = Field(min_length=1, max_length=PASSWORD_MAX_LENGTH)
new_password: str = Field(min_length=PASSWORD_MIN_LENGTH, max_length=PASSWORD_MAX_LENGTH)
class PrincipalResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
email: str
role: Role
kind: AccountKind
must_change_password: bool
@classmethod
def from_principal(cls, principal: Principal) -> Self:
return cls.model_validate(principal)
class TokenResponse(BaseModel):
access_token: str
token_type: Literal["bearer"] = "bearer" # noqa: S105
expires_in: int
principal: PrincipalResponse
+171
View File
@@ -0,0 +1,171 @@
# 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")
+1 -1
View File
@@ -6,7 +6,7 @@ requires-python = ">=3.14,<3.15"
dependencies = [
"fastapi>=0.141.1",
"uvicorn[standard]>=0.53.0",
"pydantic>=2.13.5",
"pydantic[email]>=2.13.5",
"pydantic-settings>=2.15.0",
"sqlalchemy[asyncio]>=2.0.52",
"asyncpg>=0.31.0",
+106
View File
@@ -0,0 +1,106 @@
from collections.abc import Iterator
from uuid import uuid4
import pytest
from fastapi import FastAPI
from httpx import AsyncClient
from app.api.deps import get_auth_service
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
from app.services.auth import (
AuthenticatedSession,
InvalidCredentialsError,
RateLimitedError,
)
IDENTIFIANTS = {"email": "operateur@enervision.fr", "password": "un-mot-de-passe-valide"}
PRINCIPAL = Principal(
id=uuid4(),
email="operateur@enervision.fr",
role=Role.OPERATEUR,
kind=AccountKind.HUMAIN,
must_change_password=False,
)
class FauxService:
def __init__(self, erreur: Exception | None = None) -> None:
self._erreur = erreur
async def authenticate(self, **_: object) -> AuthenticatedSession:
if self._erreur is not None:
raise self._erreur
return AuthenticatedSession(
principal=PRINCIPAL, access_token="un.jeton.factice", expires_in=900
)
@pytest.fixture
def fake_auth_service(app: FastAPI) -> Iterator[list[Exception | None]]:
programme: list[Exception | None] = [None]
app.dependency_overrides[get_auth_service] = lambda: FauxService(programme[0])
yield programme
app.dependency_overrides.pop(get_auth_service, None)
async def test_login_returns_the_token_and_the_principal_when_credentials_match(
fake_auth_service: list[Exception | None], client: AsyncClient
) -> None:
response = await client.post("/api/v1/auth/login", json=IDENTIFIANTS)
assert response.status_code == 200
corps = response.json()
assert corps["access_token"] == "un.jeton.factice"
assert corps["token_type"] == "bearer"
assert corps["principal"]["role"] == "operateur"
async def test_login_forbids_intermediaries_from_caching_the_response(
fake_auth_service: list[Exception | None], client: AsyncClient
) -> None:
response = await client.post("/api/v1/auth/login", json=IDENTIFIANTS)
assert response.headers["cache-control"] == "no-store"
async def test_login_never_reveals_which_half_of_the_credentials_was_wrong(
fake_auth_service: list[Exception | None], client: AsyncClient
) -> None:
fake_auth_service[0] = InvalidCredentialsError("Identifiants invalides")
response = await client.post("/api/v1/auth/login", json=IDENTIFIANTS)
assert response.status_code == 401
assert response.json() == {"detail": "Identifiants invalides"}
async def test_login_returns_429_with_a_retry_after_when_the_rate_limit_is_reached(
fake_auth_service: list[Exception | None], client: AsyncClient
) -> None:
fake_auth_service[0] = RateLimitedError(900)
response = await client.post("/api/v1/auth/login", json=IDENTIFIANTS)
assert response.status_code == 429
assert response.headers["retry-after"] == "900"
@pytest.mark.parametrize(
"corps",
[
{"email": "pas-une-adresse", "password": "un-mot-de-passe-valide"},
{"email": "operateur@enervision.fr"},
{"email": "operateur@enervision.fr", "password": "x" * 129},
],
ids=["adresse_invalide", "mot_de_passe_absent", "mot_de_passe_trop_long"],
)
async def test_login_rejects_a_malformed_body_without_echoing_the_password(
fake_auth_service: list[Exception | None], client: AsyncClient, corps: dict[str, str]
) -> None:
response = await client.post("/api/v1/auth/login", json=corps)
assert response.status_code == 422
assert "un-mot-de-passe-valide" not in response.text
assert "x" * 129 not in response.text
@@ -0,0 +1,113 @@
from collections.abc import Callable, Iterator
from uuid import uuid4
import pytest
from fastapi import FastAPI
from httpx import AsyncClient
from app.api.deps import AdminDep, get_current_principal, require_role
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
CHEMIN_ADMIN = "/api/v1/essai-admin"
def principal(role: Role = Role.LECTEUR, *, must_change_password: bool = False) -> Principal:
return Principal(
id=uuid4(),
email=f"{role.value}@enervision.fr",
role=role,
kind=AccountKind.HUMAIN,
must_change_password=must_change_password,
)
@pytest.fixture
def route_admin(app: FastAPI) -> None:
@app.get(CHEMIN_ADMIN)
async def _reserve_aux_admins(acteur: AdminDep) -> dict[str, str]:
return {"email": acteur.email}
@pytest.fixture
def connecte(app: FastAPI) -> Iterator[Callable[[Principal], None]]:
def installe(acteur: Principal) -> None:
app.dependency_overrides[get_current_principal] = lambda: acteur
yield installe
app.dependency_overrides.pop(get_current_principal, None)
async def test_me_returns_401_when_no_credentials_are_sent(client: AsyncClient) -> None:
response = await client.get("/api/v1/auth/me")
assert response.status_code == 401
assert "Bearer" in response.headers["www-authenticate"]
async def test_me_returns_401_when_the_token_is_not_readable(client: AsyncClient) -> None:
response = await client.get(
"/api/v1/auth/me", headers={"Authorization": "Bearer nimporte.quoi.ici"}
)
assert response.status_code == 401
assert 'error="invalid_token"' in response.headers["www-authenticate"]
async def test_me_describes_the_connected_account(
connecte: Callable[[Principal], None], client: AsyncClient
) -> None:
acteur = principal(Role.OPERATEUR)
connecte(acteur)
response = await client.get("/api/v1/auth/me")
assert response.status_code == 200
assert response.json()["email"] == acteur.email
@pytest.mark.parametrize(
("role", "attendu"),
[(Role.LECTEUR, 403), (Role.OPERATEUR, 403), (Role.ADMIN, 200)],
ids=["lecteur_refuse", "operateur_refuse", "admin_accepte"],
)
async def test_an_admin_route_only_answers_to_an_admin(
route_admin: None,
connecte: Callable[[Principal], None],
client: AsyncClient,
role: Role,
attendu: int,
) -> None:
connecte(principal(role))
response = await client.get(CHEMIN_ADMIN)
assert response.status_code == attendu
async def test_a_pending_password_change_blocks_every_business_route(
route_admin: None, connecte: Callable[[Principal], None], client: AsyncClient
) -> None:
connecte(principal(Role.ADMIN, must_change_password=True))
response = await client.get(CHEMIN_ADMIN)
assert response.status_code == 403
assert response.json()["detail"] == "password_change_required"
async def test_a_pending_password_change_still_allows_reading_ones_own_account(
connecte: Callable[[Principal], None], client: AsyncClient
) -> None:
connecte(principal(Role.LECTEUR, must_change_password=True))
response = await client.get("/api/v1/auth/me")
assert response.status_code == 200
assert response.json()["must_change_password"] is True
def test_require_role_builds_one_guard_per_minimum_level() -> None:
garde = require_role(Role.OPERATEUR)
assert callable(garde)
@@ -0,0 +1,74 @@
# Ce test est le garde-fou de l'autorisation : rendre une route publique oblige à modifier
# `ROUTES_PUBLIQUES` ci-dessous, ce qui apparaît en clair dans la diff d'une pull request et
# demande une justification au relecteur.
# Pourquoi : il interroge réellement chaque route sans jeton au lieu d'inspecter l'arbre de
# dépendances. L'arbre n'est accessible que par l'API privée de FastAPI, et surtout une route
# peut porter la bonne dépendance tout en répondant quand même.
from typing import Any
import pytest
from fastapi import FastAPI
from httpx import AsyncClient
ROUTES_PUBLIQUES = frozenset(
{
("GET", "/api/v1/health/live"),
("GET", "/api/v1/health/ready"),
("POST", "/api/v1/auth/login"),
("GET", "/metrics"),
}
)
VALEURS_DE_SUBSTITUTION = "00000000-0000-0000-0000-000000000000"
STATUTS_DE_REFUS = {401, 403}
def routes_declarees(app: FastAPI) -> list[tuple[str, str]]:
schema: dict[str, Any] = app.openapi()
return [
(methode.upper(), chemin)
for chemin, operations in schema["paths"].items()
for methode in operations
if methode.upper() in {"GET", "POST", "PATCH", "PUT", "DELETE"}
]
def routes_protegees(app: FastAPI) -> list[tuple[str, str]]:
return [route for route in routes_declarees(app) if route not in ROUTES_PUBLIQUES]
def test_the_public_allow_list_has_no_stale_entry(app: FastAPI) -> None:
declarees = set(routes_declarees(app)) | {("GET", "/metrics")}
inconnues = ROUTES_PUBLIQUES - declarees
assert inconnues == set()
async def test_every_route_rejects_an_anonymous_caller_unless_explicitly_public(
app: FastAPI, client: AsyncClient
) -> None:
ouvertes: list[tuple[str, str, int]] = []
for methode, chemin in routes_protegees(app):
concret = chemin.replace("{user_id}", VALEURS_DE_SUBSTITUTION)
response = await client.request(methode, concret, json={})
if response.status_code not in STATUTS_DE_REFUS:
ouvertes.append((methode, chemin, response.status_code))
assert ouvertes == []
async def test_the_declared_routes_are_actually_reachable(app: FastAPI) -> None:
assert ("POST", "/api/v1/auth/login") in routes_declarees(app)
assert ("GET", "/api/v1/auth/me") in routes_declarees(app)
@pytest.mark.parametrize(
"chemin",
["/api/v1/health/live", "/api/v1/health/ready"],
ids=["sonde_de_vie", "sonde_de_disponibilite"],
)
def test_the_health_probes_stay_public(app: FastAPI, chemin: str) -> None:
assert ("GET", chemin) in ROUTES_PUBLIQUES
@@ -0,0 +1,125 @@
# Les trois refus ci-dessous sont la preuve que l'ajout seul est une propriété de la base et
# non une convention de code Python. Ce sont eux qu'il faut montrer, pas la classe du dépôt.
import uuid
import pytest
from sqlalchemy import text
from sqlalchemy.exc import DBAPIError
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
from app.models.audit_log import AuditAction, AuditOutcome
from app.repositories.audit_log import (
CLES_DE_DETAIL_AUTORISEES,
AuditLogRepository,
assemble_detail,
)
pytestmark = pytest.mark.integration
ACTEUR = Principal(
id=uuid.uuid4(),
email="admin@enervision.fr",
role=Role.ADMIN,
kind=AccountKind.HUMAIN,
must_change_password=False,
)
async def une_ligne(session: AsyncSession) -> None:
await AuditLogRepository(session).record(
action=AuditAction.COMPTE_CREE, actor=ACTEUR, target_type="app_user", target_id="x"
)
await session.flush()
@pytest.mark.parametrize(
"instruction",
[
"update audit_log set action = 'falsifie'",
"delete from audit_log",
"truncate audit_log",
],
ids=["modification", "suppression", "vidage"],
)
async def test_the_database_refuses_to_mutate_the_audit_log(
session: AsyncSession, instruction: str
) -> None:
await une_ligne(session)
with pytest.raises(DBAPIError, match="ajout seul"):
await session.execute(text(instruction))
await session.rollback()
async def test_record_keeps_a_snapshot_of_the_actor(session: AsyncSession) -> None:
depot = AuditLogRepository(session)
await depot.record(action=AuditAction.COMPTE_DESACTIVE, actor=ACTEUR)
await session.flush()
ligne = (
await session.execute(
text("select actor_id, actor_email, actor_role, outcome from audit_log")
)
).one()
await session.rollback()
assert ligne.actor_id == ACTEUR.id
assert ligne.actor_email == ACTEUR.email
assert ligne.actor_role == Role.ADMIN.value
assert ligne.outcome == AuditOutcome.SUCCES.value
async def test_record_accepts_a_label_when_there_is_no_authenticated_actor(
session: AsyncSession,
) -> None:
depot = AuditLogRepository(session)
await depot.record(action=AuditAction.ADMIN_AMORCE, actor_label="cli")
await session.flush()
ligne = (await session.execute(text("select actor_id, actor_email from audit_log"))).one()
await session.rollback()
assert ligne.actor_id is None
assert ligne.actor_email == "cli"
async def test_record_drops_the_detail_keys_outside_the_allow_list(
session: AsyncSession,
) -> None:
depot = AuditLogRepository(session)
await depot.record(
action=AuditAction.COMPTE_ROLE_CHANGE,
actor=ACTEUR,
detail={"role_avant": "lecteur", "mot_de_passe": "ne-doit-pas-passer"},
)
await session.flush()
detail = (await session.execute(text("select detail from audit_log"))).scalar_one()
await session.rollback()
assert detail == {"role_avant": "lecteur"}
@pytest.mark.parametrize(
("brut", "attendu"),
[
(None, {}),
({}, {}),
({"motif": "reutilisation"}, {"motif": "reutilisation"}),
({"password": "x"}, {}),
],
ids=["absent", "vide", "cle_autorisee", "cle_refusee"],
)
def test_assemble_detail_only_keeps_the_allowed_keys(
brut: dict[str, str] | None, attendu: dict[str, str]
) -> None:
assert assemble_detail(brut) == attendu
def test_the_allow_list_never_mentions_a_secret() -> None:
suspects = {"password", "mot_de_passe", "token", "jeton", "secret", "hash"}
assert CLES_DE_DETAIL_AUTORISEES & suspects == set()
@@ -0,0 +1,118 @@
import uuid
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.login_attempt import LoginOutcome
from app.repositories.login_attempt import LoginAttemptRepository
pytestmark = pytest.mark.integration
IP = "203.0.113.10"
AUTRE_IP = "198.51.100.7"
def adresse() -> str:
return f"tentative-{uuid.uuid4().hex[:12]}@enervision.fr"
async def echoue(
depot: LoginAttemptRepository, email: str, ip: str | None, combien: int = 1
) -> None:
for _ in range(combien):
await depot.record(email=email, client_ip=ip, outcome=LoginOutcome.IDENTIFIANTS_INVALIDES)
async def test_count_recent_failures_separates_the_three_counters(
session: AsyncSession,
) -> None:
depot = LoginAttemptRepository(session)
cible, voisin = adresse(), adresse()
await echoue(depot, cible, IP, combien=3)
await echoue(depot, cible, AUTRE_IP, combien=2)
await echoue(depot, voisin, IP, combien=4)
await session.flush()
compteurs = await depot.count_recent_failures(email=cible, client_ip=IP, window_seconds=900)
await session.rollback()
assert compteurs.per_identifier_and_ip == 3
assert compteurs.per_identifier == 5
assert compteurs.per_ip == 7
async def test_count_recent_failures_ignores_successful_attempts(
session: AsyncSession,
) -> None:
depot = LoginAttemptRepository(session)
cible = adresse()
await echoue(depot, cible, IP, combien=2)
await depot.record(email=cible, client_ip=IP, outcome=LoginOutcome.SUCCES)
await session.flush()
compteurs = await depot.count_recent_failures(email=cible, client_ip=IP, window_seconds=900)
await session.rollback()
assert compteurs.per_identifier_and_ip == 2
async def test_count_recent_failures_forgets_what_falls_outside_the_window(
session: AsyncSession,
) -> None:
depot = LoginAttemptRepository(session)
cible = adresse()
await echoue(depot, cible, IP, combien=2)
await session.flush()
await session.execute(
text(
"update login_attempt set occurred_at = now() - interval '2 hours' "
"where email_tried = :e"
),
{"e": cible},
)
compteurs = await depot.count_recent_failures(email=cible, client_ip=IP, window_seconds=900)
await session.rollback()
assert compteurs.per_identifier_and_ip == 0
async def test_count_recent_failures_still_counts_when_the_address_is_unknown(
session: AsyncSession,
) -> None:
depot = LoginAttemptRepository(session)
inconnu = adresse()
await echoue(depot, inconnu, IP, combien=5)
await session.flush()
compteurs = await depot.count_recent_failures(email=inconnu, client_ip=IP, window_seconds=900)
await session.rollback()
assert compteurs.per_identifier_and_ip == 5
async def test_record_normalises_the_address_before_counting(session: AsyncSession) -> None:
depot = LoginAttemptRepository(session)
cible = adresse()
await echoue(depot, cible.upper(), IP, combien=2)
await session.flush()
compteurs = await depot.count_recent_failures(email=cible, client_ip=IP, window_seconds=900)
await session.rollback()
assert compteurs.per_identifier_and_ip == 2
async def test_count_recent_failures_tolerates_a_missing_client_address(
session: AsyncSession,
) -> None:
depot = LoginAttemptRepository(session)
cible = adresse()
await echoue(depot, cible, None, combien=2)
await session.flush()
compteurs = await depot.count_recent_failures(email=cible, client_ip=None, window_seconds=900)
await session.rollback()
assert compteurs.per_identifier == 2
@@ -0,0 +1,196 @@
import uuid
import pytest
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.roles import AccountKind, Role
from app.repositories.user import UserRepository
pytestmark = pytest.mark.integration
def adresse() -> str:
return f"compte-{uuid.uuid4().hex[:12]}@enervision.fr"
async def test_create_normalises_the_email_to_lower_case(session: AsyncSession) -> None:
depot = UserRepository(session)
saisie = adresse().upper()
compte = await depot.create(email=saisie, password_hash="$argon2id$x", role=Role.LECTEUR)
enregistre = compte.email
await session.rollback()
assert enregistre == saisie.lower()
async def test_the_database_refuses_an_email_written_in_upper_case(
session: AsyncSession,
) -> None:
saisie = adresse().upper()
with pytest.raises(IntegrityError):
await session.execute(
text(
"insert into app_user (email, password_hash, role) "
"values (:e, '$argon2id$x', 'lecteur')"
),
{"e": saisie},
)
await session.rollback()
async def test_the_database_refuses_two_accounts_sharing_an_email(
session: AsyncSession,
) -> None:
depot = UserRepository(session)
saisie = adresse()
await depot.create(email=saisie, password_hash="$argon2id$x", role=Role.LECTEUR)
with pytest.raises(IntegrityError):
await depot.create(email=saisie, password_hash="$argon2id$y", role=Role.ADMIN)
await session.rollback()
async def test_get_by_email_is_case_insensitive(session: AsyncSession) -> None:
depot = UserRepository(session)
saisie = adresse()
await depot.create(email=saisie, password_hash="$argon2id$x", role=Role.OPERATEUR)
trouve = await depot.get_by_email(saisie.upper())
role = trouve.role if trouve else None
await session.rollback()
assert role == Role.OPERATEUR.value
async def test_get_by_email_returns_nothing_for_an_unknown_address(
session: AsyncSession,
) -> None:
trouve = await UserRepository(session).get_by_email(adresse())
assert trouve is None
async def test_set_role_moves_the_credentials_marker_forward(session: AsyncSession) -> None:
depot = UserRepository(session)
compte = await depot.create(email=adresse(), password_hash="$argon2id$x", role=Role.LECTEUR)
avant = compte.credentials_changed_at
await depot.set_role(compte.id, Role.ADMIN)
await session.refresh(compte)
apres, role = compte.credentials_changed_at, compte.role
await session.rollback()
assert role == Role.ADMIN.value
assert apres > avant
async def test_set_active_moves_the_credentials_marker_forward(session: AsyncSession) -> None:
depot = UserRepository(session)
compte = await depot.create(email=adresse(), password_hash="$argon2id$x", role=Role.LECTEUR)
avant = compte.credentials_changed_at
await depot.set_active(compte.id, is_active=False)
await session.refresh(compte)
apres, actif = compte.credentials_changed_at, compte.is_active
await session.rollback()
assert actif is False
assert apres > avant
async def test_rehash_password_leaves_the_credentials_marker_untouched(
session: AsyncSession,
) -> None:
depot = UserRepository(session)
compte = await depot.create(email=adresse(), password_hash="$argon2id$x", role=Role.LECTEUR)
avant = compte.credentials_changed_at
await depot.rehash_password(compte.id, "$argon2id$plus-recent")
await session.refresh(compte)
apres, empreinte = compte.credentials_changed_at, compte.password_hash
await session.rollback()
assert empreinte == "$argon2id$plus-recent"
assert apres == avant
async def test_update_password_moves_the_credentials_marker_forward(
session: AsyncSession,
) -> None:
depot = UserRepository(session)
compte = await depot.create(email=adresse(), password_hash="$argon2id$x", role=Role.LECTEUR)
avant = compte.credentials_changed_at
await depot.update_password(compte.id, "$argon2id$neuf", must_change_password=False)
await session.refresh(compte)
apres = compte.credentials_changed_at
await session.rollback()
assert apres > avant
async def test_touch_last_login_records_the_connection_date(session: AsyncSession) -> None:
depot = UserRepository(session)
compte = await depot.create(email=adresse(), password_hash="$argon2id$x", role=Role.LECTEUR)
await depot.touch_last_login(compte.id)
await session.refresh(compte)
date = compte.last_login_at
await session.rollback()
assert date is not None
async def test_count_active_admins_only_counts_enabled_administrators(
session: AsyncSession,
) -> None:
depot = UserRepository(session)
depart = await depot.count_active_admins()
await depot.create(email=adresse(), password_hash="$argon2id$x", role=Role.ADMIN)
desactive = await depot.create(email=adresse(), password_hash="$argon2id$x", role=Role.ADMIN)
await depot.set_active(desactive.id, is_active=False)
total = await depot.count_active_admins()
await session.rollback()
assert total == depart + 1
async def test_create_accepts_a_service_account(session: AsyncSession) -> None:
depot = UserRepository(session)
compte = await depot.create(
email=adresse(),
password_hash="$argon2id$x",
role=Role.OPERATEUR,
kind=AccountKind.SERVICE,
)
nature = compte.kind
await session.rollback()
assert nature == AccountKind.SERVICE.value
async def test_list_all_returns_the_accounts_sorted_by_email(session: AsyncSession) -> None:
depot = UserRepository(session)
await depot.create(email=f"zz-{adresse()}", password_hash="$argon2id$x", role=Role.LECTEUR)
await depot.create(email=f"aa-{adresse()}", password_hash="$argon2id$x", role=Role.LECTEUR)
comptes = await depot.list_all()
emails = [compte.email for compte in comptes]
await session.rollback()
assert emails == sorted(emails)
async def test_get_by_id_returns_nothing_for_an_unknown_identifier(
session: AsyncSession,
) -> None:
trouve = await UserRepository(session).get_by_id(uuid.uuid4())
assert trouve is None
+234
View File
@@ -0,0 +1,234 @@
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
+57
View File
@@ -0,0 +1,57 @@
import pytest
from app import cli
def test_build_parser_reads_the_create_admin_arguments() -> None:
arguments = cli.build_parser().parse_args(
["create-admin", "--email", "admin@enervision.fr", "--generate", "--force"]
)
assert arguments.commande == "create-admin"
assert arguments.email == "admin@enervision.fr"
assert arguments.generate is True
assert arguments.force is True
def test_build_parser_requires_a_subcommand() -> None:
with pytest.raises(SystemExit):
cli.build_parser().parse_args([])
def test_build_parser_requires_an_email() -> None:
with pytest.raises(SystemExit):
cli.build_parser().parse_args(["create-admin"])
def test_read_password_generates_a_long_secret_when_asked(
capsys: pytest.CaptureFixture[str],
) -> None:
mot_de_passe = cli.read_password(generate=True)
assert len(mot_de_passe) >= cli.LONGUEUR_MOT_DE_PASSE_GENERE
assert mot_de_passe in capsys.readouterr().out
def test_read_password_accepts_two_matching_entries(monkeypatch: pytest.MonkeyPatch) -> None:
saisies = iter(["un-mot-de-passe-valide", "un-mot-de-passe-valide"])
monkeypatch.setattr(cli, "getpass", lambda _: next(saisies))
assert cli.read_password(generate=False) == "un-mot-de-passe-valide"
def test_read_password_refuses_a_password_below_the_minimum_length(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cli, "getpass", lambda _: "court")
with pytest.raises(SystemExit):
cli.read_password(generate=False)
def test_read_password_refuses_two_different_entries(monkeypatch: pytest.MonkeyPatch) -> None:
saisies = iter(["un-mot-de-passe-valide", "un-autre-mot-de-passe"])
monkeypatch.setattr(cli, "getpass", lambda _: next(saisies))
with pytest.raises(SystemExit):
cli.read_password(generate=False)
+29 -2
View File
@@ -279,6 +279,28 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/96/1a/d6d16babd0a5fe4c3fae40702158c570351694e74516d8d81b86c5637448/coverage-7.16.1-py3-none-any.whl", hash = "sha256:3d8bd4e58b6a5c2018d808f297905393c6c61da466a48c3f0596a76a4900ebe4", size = 215264, upload-time = "2026-09-13T19:12:18.895Z" },
]
[[package]]
name = "dnspython"
version = "2.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
]
[[package]]
name = "email-validator"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "dnspython" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
]
[[package]]
name = "enervision-backend"
version = "0.1.0"
@@ -290,7 +312,7 @@ dependencies = [
{ name = "asyncpg" },
{ name = "fastapi" },
{ name = "prometheus-fastapi-instrumentator" },
{ name = "pydantic" },
{ name = "pydantic", extra = ["email"] },
{ name = "pydantic-settings" },
{ name = "pyjwt" },
{ name = "python-json-logger" },
@@ -316,7 +338,7 @@ requires-dist = [
{ name = "asyncpg", specifier = ">=0.31.0" },
{ name = "fastapi", specifier = ">=0.141.1" },
{ name = "prometheus-fastapi-instrumentator", specifier = ">=8.1.0" },
{ name = "pydantic", specifier = ">=2.13.5" },
{ name = "pydantic", extras = ["email"], specifier = ">=2.13.5" },
{ name = "pydantic-settings", specifier = ">=2.15.0" },
{ name = "pyjwt", specifier = ">=2.10" },
{ name = "python-json-logger", specifier = ">=4.2.0" },
@@ -646,6 +668,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" },
]
[package.optional-dependencies]
email = [
{ name = "email-validator" },
]
[[package]]
name = "pydantic-core"
version = "2.46.5"