feat(backend): documente le contrat d'erreur dans l'OpenAPI

Le schéma ne déclarait aucun code d'erreur : ni 401, ni 403, ni 404, ni 409,
ni 429. Swagger affirmait que /auth/login ne pouvait répondre que 200 ou 422,
alors que 31-contrat-authentification.md décrit ces codes comme le contrat que
le frontend doit traiter.

Le 422 publié était pire qu'absent : le schéma exposait HTTPValidationError,
le modèle par défaut de FastAPI avec sa clé `loc`, quand
validation_error_handler renvoie {"detail": [{"champ", "type"}]}. Un client
codé sur la documentation lisait une clé qui n'arrive jamais.

Les métadonnées arrivent avec : description, résumé et une description par
tag. `servers`, `license_info` et `contact` restent absents, ils poseraient
des décisions qui ne sont pas prises.

Le cookie de rafraîchissement devient visible par un APIKeyCookie en
auto_error=False, purement documentaire : lit_le_cookie() reste seul maître du
401 de /auth/refresh.

Au passage, health.py posait son tag deux fois, une fois sur son APIRouter et
une fois à l'include_router.
This commit is contained in:
Johan LEROY
2026-09-16 10:12:41 +02:00
parent 44468e85d7
commit 344f82fcdd
8 changed files with 245 additions and 11 deletions
+114
View File
@@ -0,0 +1,114 @@
# Piège : `cookie_de_rafraichissement` est purement documentaire, d'où son `auto_error=False`.
# Avec la valeur par défaut, FastAPI répondrait 403 avant d'atteindre `lit_le_cookie()`, et
# `/auth/refresh` cesserait de rendre le 401 que le frontend attend.
from typing import Any, Final
from fastapi.security import APIKeyCookie
from app.core.config import REFRESH_COOKIE_DEFAUT
from app.schemas.errors import ErrorResponse, InternalErrorResponse, ValidationErrorResponse
Reponses = dict[int | str, dict[str, Any]]
SUMMARY: Final = "Collecte, analyse et restitution de séries temporelles énergétiques."
DESCRIPTION: Final = """
Toutes les routes sont préfixées par `/api/v1`.
**Authentification.** Le jeton d'accès se présente dans l'en-tête `Authorization: Bearer ...`.
Le jeton de rafraîchissement est un cookie `HttpOnly` que le code client ne voit jamais : il
suffit d'émettre les requêtes avec les identifiants de session. `POST /auth/refresh` rend un
nouveau jeton d'accès et fait tourner le cookie.
**Rôles.** `lecteur`, puis `operateur`, puis `admin`. Chaque rôle couvre les droits du
précédent.
**Erreurs.** Le corps porte toujours une clé `detail`. Un `403` dont le `detail` vaut
`password_change_required` n'est pas un refus de droits : il exige le changement du mot de passe
provisoire avant toute autre action.
Le parcours de session complet est décrit dans
`docs/architecture/31-contrat-authentification.md`.
"""
TAGS: Final[list[dict[str, Any]]] = [
{
"name": "health",
"description": (
"Sondes d'infrastructure, publiques. `live` prouve que le processus répond, `ready` "
"que la base répond et que l'extension TimescaleDB est chargée."
),
},
{
"name": "auth",
"description": (
"Ouverture, rotation et fermeture de session, et changement de son propre mot de passe."
),
},
{
"name": "users",
"description": "Administration des comptes. Réservé au rôle `admin`.",
},
]
cookie_de_rafraichissement = APIKeyCookie(
name=REFRESH_COOKIE_DEFAUT,
scheme_name="Cookie de rafraîchissement",
description=(
"Cookie `HttpOnly` posé par `/auth/login` et tourné par `/auth/refresh`. Il prend le "
"préfixe `__Secure-` dès que l'API tourne derrière TLS, et n'est émis que vers "
"`/api/v1/auth`."
),
auto_error=False,
)
# Le 422 n'est déclaré que sur les routes qui acceptent un corps ou un paramètre : ailleurs,
# aucune validation ne peut échouer et l'annoncer serait faux.
REPONSE_VALIDATION: Final[Reponses] = {
422: {
"model": ValidationErrorResponse,
"description": (
"Corps invalide. Le détail nomme le champ fautif et le type d'erreur, jamais la "
"valeur envoyée."
),
},
}
REPONSE_SERVEUR: Final[Reponses] = {
500: {
"model": InternalErrorResponse,
"description": (
"Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas "
"renvoyée au client."
),
},
}
REPONSE_INDISPONIBLE: Final[Reponses] = {
503: {
"model": ErrorResponse,
"description": "Base injoignable, ou extension TimescaleDB absente de la base.",
},
}
REPONSES_AUTHENTIFIEES: Final[Reponses] = {
401: {
"model": ErrorResponse,
"description": (
"Jeton absent, illisible, périmé, ou rendu caduc par un changement de rôle ou une "
"désactivation. L'en-tête `WWW-Authenticate` porte la cause dans `error=`."
),
},
}
REPONSES_ADMIN: Final[Reponses] = {
**REPONSES_AUTHENTIFIEES,
403: {
"model": ErrorResponse,
"description": (
"Droits insuffisants, ou mot de passe provisoire à changer quand `detail` vaut "
"`password_change_required`."
),
},
}
+63 -4
View File
@@ -11,6 +11,12 @@ from app.api.deps import (
get_client_ip, get_client_ip,
require_trusted_origin, require_trusted_origin,
) )
from app.api.openapi import (
REPONSE_VALIDATION,
REPONSES_AUTHENTIFIEES,
Reponses,
cookie_de_rafraichissement,
)
from app.core.cookies import RefreshCookie, cookie_name from app.core.cookies import RefreshCookie, cookie_name
from app.core.logging import get_logger from app.core.logging import get_logger
from app.schemas.auth import ( from app.schemas.auth import (
@@ -19,6 +25,7 @@ from app.schemas.auth import (
PrincipalResponse, PrincipalResponse,
TokenResponse, TokenResponse,
) )
from app.schemas.errors import ErrorResponse
from app.services.auth import ( from app.services.auth import (
AuthenticatedSession, AuthenticatedSession,
InvalidCredentialsError, InvalidCredentialsError,
@@ -32,6 +39,45 @@ logger = get_logger(__name__)
DETAIL_IDENTIFIANTS = "Identifiants invalides" DETAIL_IDENTIFIANTS = "Identifiants invalides"
DETAIL_SESSION = "Session invalide" DETAIL_SESSION = "Session invalide"
REPONSES_LOGIN: Reponses = {
**REPONSE_VALIDATION,
401: {
"model": ErrorResponse,
"description": (
"Identifiants faux, compte inconnu ou compte désactivé. Le message est le même dans "
"les trois cas, et n'apprend donc rien sur l'existence du compte."
),
},
429: {
"model": ErrorResponse,
"description": "Trop de tentatives sur cette fenêtre glissante.",
"headers": {
"Retry-After": {
"description": "Secondes à attendre avant une nouvelle tentative.",
"schema": {"type": "integer"},
}
},
},
}
REPONSES_REFRESH: Reponses = {
401: {
"model": ErrorResponse,
"description": (
"Cookie absent, session expirée, révoquée, ou jeton déjà tourné. Dans ce dernier cas "
"toute la famille de sessions est révoquée et le cookie est effacé avec la réponse."
),
},
}
REPONSES_MOT_DE_PASSE: Reponses = {
**REPONSE_VALIDATION,
401: {
"model": ErrorResponse,
"description": "Jeton d'accès invalide, ou mot de passe courant faux.",
},
}
def repond( def repond(
response: Response, settings: SettingsDep, session: AuthenticatedSession response: Response, settings: SettingsDep, session: AuthenticatedSession
@@ -61,7 +107,12 @@ def lit_le_cookie(request: Request, settings: SettingsDep) -> str:
return secret return secret
@router.post("/login", response_model=TokenResponse, summary="Ouvre une session") @router.post(
"/login",
response_model=TokenResponse,
summary="Ouvre une session",
responses=REPONSES_LOGIN,
)
async def login( async def login(
payload: LoginRequest, payload: LoginRequest,
request: Request, request: Request,
@@ -98,7 +149,8 @@ async def login(
"/refresh", "/refresh",
response_model=TokenResponse, response_model=TokenResponse,
summary="Fait tourner la session", summary="Fait tourner la session",
dependencies=[Depends(require_trusted_origin)], dependencies=[Depends(require_trusted_origin), Depends(cookie_de_rafraichissement)],
responses=REPONSES_REFRESH,
) )
async def refresh( async def refresh(
request: Request, request: Request,
@@ -133,7 +185,7 @@ async def refresh(
"/logout", "/logout",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
summary="Ferme la session courante", summary="Ferme la session courante",
dependencies=[Depends(require_trusted_origin)], dependencies=[Depends(require_trusted_origin), Depends(cookie_de_rafraichissement)],
) )
async def logout( async def logout(
request: Request, response: Response, settings: SettingsDep, service: AuthServiceDep request: Request, response: Response, settings: SettingsDep, service: AuthServiceDep
@@ -150,6 +202,7 @@ async def logout(
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
summary="Ferme toutes les sessions du compte", summary="Ferme toutes les sessions du compte",
dependencies=[Depends(require_trusted_origin)], dependencies=[Depends(require_trusted_origin)],
responses=REPONSES_AUTHENTIFIEES,
) )
async def logout_all( async def logout_all(
principal: CurrentPrincipalDep, principal: CurrentPrincipalDep,
@@ -163,7 +216,12 @@ async def logout_all(
response.delete_cookie(**RefreshCookie.expired(settings).as_deletion_kwargs()) response.delete_cookie(**RefreshCookie.expired(settings).as_deletion_kwargs())
@router.get("/me", response_model=PrincipalResponse, summary="Décrit le compte connecté") @router.get(
"/me",
response_model=PrincipalResponse,
summary="Décrit le compte connecté",
responses=REPONSES_AUTHENTIFIEES,
)
async def me(principal: CurrentPrincipalDep) -> PrincipalResponse: async def me(principal: CurrentPrincipalDep) -> PrincipalResponse:
return PrincipalResponse.from_principal(principal) return PrincipalResponse.from_principal(principal)
@@ -173,6 +231,7 @@ async def me(principal: CurrentPrincipalDep) -> PrincipalResponse:
response_model=TokenResponse, response_model=TokenResponse,
summary="Change son propre mot de passe", summary="Change son propre mot de passe",
dependencies=[Depends(require_trusted_origin)], dependencies=[Depends(require_trusted_origin)],
responses=REPONSES_MOT_DE_PASSE,
) )
async def change_password( async def change_password(
payload: PasswordChangeRequest, payload: PasswordChangeRequest,
+4 -3
View File
@@ -3,16 +3,17 @@ from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from app.api.deps import SessionDep, SettingsDep from app.api.deps import SessionDep, SettingsDep
from app.api.openapi import REPONSE_INDISPONIBLE
from app.core.logging import get_logger from app.core.logging import get_logger
from app.schemas.health import LivenessStatus, ReadinessStatus from app.schemas.health import LivenessStatus, ReadinessStatus
logger = get_logger(__name__) logger = get_logger(__name__)
router = APIRouter(tags=["health"]) router = APIRouter()
TIMESCALEDB_VERSION = text("SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'") TIMESCALEDB_VERSION = text("SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'")
@router.get("/live", summary="Sonde de vivacite") @router.get("/live", summary="Sonde de vivacité")
async def liveness(settings: SettingsDep) -> LivenessStatus: async def liveness(settings: SettingsDep) -> LivenessStatus:
return LivenessStatus( return LivenessStatus(
status="ok", status="ok",
@@ -22,7 +23,7 @@ async def liveness(settings: SettingsDep) -> LivenessStatus:
) )
@router.get("/ready", summary="Sonde de disponibilite") @router.get("/ready", summary="Sonde de disponibilité", responses=REPONSE_INDISPONIBLE)
async def readiness(session: SessionDep) -> ReadinessStatus: async def readiness(session: SessionDep) -> ReadinessStatus:
try: try:
version: str | None = await session.scalar(TIMESCALEDB_VERSION) version: str | None = await session.scalar(TIMESCALEDB_VERSION)
+32 -1
View File
@@ -3,7 +3,9 @@ from uuid import UUID
from fastapi import APIRouter, HTTPException, Response, status from fastapi import APIRouter, HTTPException, Response, status
from app.api.deps import AdminDep, UserServiceDep from app.api.deps import AdminDep, UserServiceDep
from app.api.openapi import REPONSE_VALIDATION, Reponses
from app.core.logging import get_logger from app.core.logging import get_logger
from app.schemas.errors import ErrorResponse
from app.schemas.user import ( from app.schemas.user import (
TemporaryPasswordResponse, TemporaryPasswordResponse,
UserCreateRequest, UserCreateRequest,
@@ -15,6 +17,28 @@ from app.services.user import EmailAlreadyUsedError, LastAdminError, UserNotFoun
router = APIRouter() router = APIRouter()
logger = get_logger(__name__) logger = get_logger(__name__)
REPONSES_CREATION: Reponses = {
**REPONSE_VALIDATION,
409: {"model": ErrorResponse, "description": "Adresse déjà portée par un autre compte."},
}
REPONSES_INTROUVABLE: Reponses = {
**REPONSE_VALIDATION,
404: {"model": ErrorResponse, "description": "Aucun compte ne porte cet identifiant."},
}
REPONSES_MODIFICATION: Reponses = {
**REPONSES_INTROUVABLE,
400: {"model": ErrorResponse, "description": "Corps vide, aucune modification demandée."},
409: {
"model": ErrorResponse,
"description": (
"L'opération laisserait la plateforme sans administrateur actif, qu'il s'agisse de "
"rétrograder le dernier ou de le désactiver."
),
},
}
@router.get("", response_model=list[UserResponse], summary="Liste les comptes") @router.get("", response_model=list[UserResponse], summary="Liste les comptes")
async def list_users(_: AdminDep, service: UserServiceDep) -> list[UserResponse]: async def list_users(_: AdminDep, service: UserServiceDep) -> list[UserResponse]:
@@ -27,6 +51,7 @@ async def list_users(_: AdminDep, service: UserServiceDep) -> list[UserResponse]
response_model=TemporaryPasswordResponse, response_model=TemporaryPasswordResponse,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
summary="Crée un compte avec un mot de passe provisoire", summary="Crée un compte avec un mot de passe provisoire",
responses=REPONSES_CREATION,
) )
async def create_user( async def create_user(
payload: UserCreateRequest, payload: UserCreateRequest,
@@ -55,7 +80,12 @@ async def create_user(
) )
@router.patch("/{user_id}", response_model=UserResponse, summary="Change le rôle ou l'activation") @router.patch(
"/{user_id}",
response_model=UserResponse,
summary="Change le rôle ou l'activation",
responses=REPONSES_MODIFICATION,
)
async def update_user( async def update_user(
user_id: UUID, user_id: UUID,
payload: UserUpdateRequest, payload: UserUpdateRequest,
@@ -92,6 +122,7 @@ async def update_user(
"/{user_id}/password-reset", "/{user_id}/password-reset",
response_model=TemporaryPasswordResponse, response_model=TemporaryPasswordResponse,
summary="Réinitialise le mot de passe et ferme les sessions", summary="Réinitialise le mot de passe et ferme les sessions",
responses=REPONSES_INTROUVABLE,
) )
async def reset_password( async def reset_password(
user_id: UUID, acteur: AdminDep, service: UserServiceDep, response: Response user_id: UUID, acteur: AdminDep, service: UserServiceDep, response: Response
+3 -2
View File
@@ -1,8 +1,9 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.openapi import REPONSE_SERVEUR, REPONSES_ADMIN
from app.api.v1.endpoints import auth, health, users from app.api.v1.endpoints import auth, health, users
api_router = APIRouter() api_router = APIRouter(responses=REPONSE_SERVEUR)
api_router.include_router(health.router, prefix="/health", tags=["health"]) api_router.include_router(health.router, prefix="/health", tags=["health"])
api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(users.router, prefix="/users", tags=["users"]) api_router.include_router(users.router, prefix="/users", tags=["users"], responses=REPONSES_ADMIN)
+2 -1
View File
@@ -8,6 +8,7 @@ Environment = Literal["local", "dev", "staging", "prod"]
SameSite = Literal["lax", "strict", "none"] SameSite = Literal["lax", "strict", "none"]
SECRET_KEY_MIN_LENGTH = 32 SECRET_KEY_MIN_LENGTH = 32
REFRESH_COOKIE_DEFAUT = "ev_refresh"
SENTINELLES_INTERDITES = frozenset( SENTINELLES_INTERDITES = frozenset(
{"change_me", "changeme", "secret", "secret-de-test", "changez-moi", "todo"} {"change_me", "changeme", "secret", "secret-de-test", "changez-moi", "todo"}
) )
@@ -38,7 +39,7 @@ class Settings(BaseSettings):
access_token_ttl_seconds: int = Field(default=900, ge=60, le=3600) access_token_ttl_seconds: int = Field(default=900, ge=60, le=3600)
refresh_token_ttl_seconds: int = Field(default=604800, ge=3600, le=2592000) refresh_token_ttl_seconds: int = Field(default=604800, ge=3600, le=2592000)
refresh_cookie_name: str = "ev_refresh" refresh_cookie_name: str = REFRESH_COOKIE_DEFAUT
cookie_path: str = "/api/v1/auth" cookie_path: str = "/api/v1/auth"
cookie_samesite: SameSite = "strict" cookie_samesite: SameSite = "strict"
cookie_secure: bool | None = None cookie_secure: bool | None = None
+4
View File
@@ -7,6 +7,7 @@ from prometheus_fastapi_instrumentator import Instrumentator
from app.api.errors import register_error_handlers from app.api.errors import register_error_handlers
from app.api.middleware import SecurityHeadersMiddleware from app.api.middleware import SecurityHeadersMiddleware
from app.api.openapi import DESCRIPTION, SUMMARY, TAGS
from app.api.security import require_metrics_token from app.api.security import require_metrics_token
from app.api.v1.router import api_router from app.api.v1.router import api_router
from app.core.config import Settings, get_settings from app.core.config import Settings, get_settings
@@ -37,6 +38,9 @@ def create_app(settings: Settings | None = None) -> FastAPI:
application = FastAPI( application = FastAPI(
title=resolved.name, title=resolved.name,
version=resolved.version, version=resolved.version,
summary=SUMMARY,
description=DESCRIPTION,
openapi_tags=TAGS,
debug=resolved.debug, debug=resolved.debug,
lifespan=lifespan, lifespan=lifespan,
docs_url="/docs" if documentee else None, docs_url="/docs" if documentee else None,
+23
View File
@@ -0,0 +1,23 @@
# Piège : ces modèles ne décrivent rien, ils publient. Ce sont eux que Swagger montre, donc ils
# doivent suivre `validation_error_handler()` et `unhandled_error_handler()` d'`app/api/errors.py`
# à la lettre. Un champ renommé là-bas sans l'être ici rend la documentation fausse en silence.
from pydantic import BaseModel
class ErrorResponse(BaseModel):
detail: str
class FieldError(BaseModel):
champ: str
type: str
class ValidationErrorResponse(BaseModel):
detail: list[FieldError]
class InternalErrorResponse(BaseModel):
detail: str
correlation: str