diff --git a/apps/backend/alembic/versions/517053a3c044_tentatives_de_connexion_et_journal_d_audit.py b/apps/backend/alembic/versions/517053a3c044_tentatives_de_connexion_et_journal_d_audit.py new file mode 100644 index 0000000..59ffb85 --- /dev/null +++ b/apps/backend/alembic/versions/517053a3c044_tentatives_de_connexion_et_journal_d_audit.py @@ -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") diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index a25b1e1..a35d628 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -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") diff --git a/apps/backend/app/api/errors.py b/apps/backend/app/api/errors.py new file mode 100644 index 0000000..7485b55 --- /dev/null +++ b/apps/backend/app/api/errors.py @@ -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) diff --git a/apps/backend/app/api/v1/endpoints/auth.py b/apps/backend/app/api/v1/endpoints/auth.py new file mode 100644 index 0000000..0def0d4 --- /dev/null +++ b/apps/backend/app/api/v1/endpoints/auth.py @@ -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) diff --git a/apps/backend/app/api/v1/router.py b/apps/backend/app/api/v1/router.py index 8571d8f..473a024 100644 --- a/apps/backend/app/api/v1/router.py +++ b/apps/backend/app/api/v1/router.py @@ -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"]) diff --git a/apps/backend/app/main.py b/apps/backend/app/main.py index 1008100..2ddf1dd 100644 --- a/apps/backend/app/main.py +++ b/apps/backend/app/main.py @@ -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 ) diff --git a/apps/backend/app/models/__init__.py b/apps/backend/app/models/__init__.py index dd6cc73..222295d 100644 --- a/apps/backend/app/models/__init__.py +++ b/apps/backend/app/models/__init__.py @@ -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"] diff --git a/apps/backend/app/models/audit_log.py b/apps/backend/app/models/audit_log.py new file mode 100644 index 0000000..5775f5e --- /dev/null +++ b/apps/backend/app/models/audit_log.py @@ -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() + ) diff --git a/apps/backend/app/models/login_attempt.py b/apps/backend/app/models/login_attempt.py new file mode 100644 index 0000000..f4b7701 --- /dev/null +++ b/apps/backend/app/models/login_attempt.py @@ -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) diff --git a/apps/backend/app/repositories/audit_log.py b/apps/backend/app/repositories/audit_log.py new file mode 100644 index 0000000..aa00f72 --- /dev/null +++ b/apps/backend/app/repositories/audit_log.py @@ -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), + ) + ) diff --git a/apps/backend/app/repositories/login_attempt.py b/apps/backend/app/repositories/login_attempt.py new file mode 100644 index 0000000..8f8df09 --- /dev/null +++ b/apps/backend/app/repositories/login_attempt.py @@ -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, + ) diff --git a/apps/backend/app/repositories/user.py b/apps/backend/app/repositories/user.py index 9155db1..eaac079 100644 --- a/apps/backend/app/repositories/user.py +++ b/apps/backend/app/repositories/user.py @@ -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()) ) diff --git a/apps/backend/app/schemas/auth.py b/apps/backend/app/schemas/auth.py new file mode 100644 index 0000000..522b4c5 --- /dev/null +++ b/apps/backend/app/schemas/auth.py @@ -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 diff --git a/apps/backend/app/services/auth.py b/apps/backend/app/services/auth.py new file mode 100644 index 0000000..19a4e26 --- /dev/null +++ b/apps/backend/app/services/auth.py @@ -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") diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index 684524d..f0e4f22 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -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", diff --git a/apps/backend/tests/api/test_auth.py b/apps/backend/tests/api/test_auth.py new file mode 100644 index 0000000..08ceddd --- /dev/null +++ b/apps/backend/tests/api/test_auth.py @@ -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 diff --git a/apps/backend/tests/api/test_authorization.py b/apps/backend/tests/api/test_authorization.py new file mode 100644 index 0000000..05c9e28 --- /dev/null +++ b/apps/backend/tests/api/test_authorization.py @@ -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) diff --git a/apps/backend/tests/api/test_route_protection.py b/apps/backend/tests/api/test_route_protection.py new file mode 100644 index 0000000..bfdd2fe --- /dev/null +++ b/apps/backend/tests/api/test_route_protection.py @@ -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 diff --git a/apps/backend/tests/repositories/test_audit_log.py b/apps/backend/tests/repositories/test_audit_log.py new file mode 100644 index 0000000..1c6fc64 --- /dev/null +++ b/apps/backend/tests/repositories/test_audit_log.py @@ -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() diff --git a/apps/backend/tests/repositories/test_login_attempt.py b/apps/backend/tests/repositories/test_login_attempt.py new file mode 100644 index 0000000..7ac620a --- /dev/null +++ b/apps/backend/tests/repositories/test_login_attempt.py @@ -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 diff --git a/apps/backend/tests/repositories/test_user.py b/apps/backend/tests/repositories/test_user.py new file mode 100644 index 0000000..0701a2d --- /dev/null +++ b/apps/backend/tests/repositories/test_user.py @@ -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 diff --git a/apps/backend/tests/services/test_auth.py b/apps/backend/tests/services/test_auth.py new file mode 100644 index 0000000..3b8902f --- /dev/null +++ b/apps/backend/tests/services/test_auth.py @@ -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 diff --git a/apps/backend/tests/test_cli.py b/apps/backend/tests/test_cli.py new file mode 100644 index 0000000..d8465b5 --- /dev/null +++ b/apps/backend/tests/test_cli.py @@ -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) diff --git a/apps/backend/uv.lock b/apps/backend/uv.lock index edc09c2..7c2b8f4 100644 --- a/apps/backend/uv.lock +++ b/apps/backend/uv.lock @@ -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"