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:
@@ -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")
|
||||
Reference in New Issue
Block a user