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.
48 lines
1.7 KiB
Python
48 lines
1.7 KiB
Python
# 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)
|