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:
+119
@@ -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")
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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())
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Generated
+29
-2
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user