diff --git a/apps/backend/app/api/openapi.py b/apps/backend/app/api/openapi.py new file mode 100644 index 0000000..fedb3bf --- /dev/null +++ b/apps/backend/app/api/openapi.py @@ -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`." + ), + }, +} diff --git a/apps/backend/app/api/v1/endpoints/auth.py b/apps/backend/app/api/v1/endpoints/auth.py index faff2b1..9e79763 100644 --- a/apps/backend/app/api/v1/endpoints/auth.py +++ b/apps/backend/app/api/v1/endpoints/auth.py @@ -11,6 +11,12 @@ from app.api.deps import ( get_client_ip, 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.logging import get_logger from app.schemas.auth import ( @@ -19,6 +25,7 @@ from app.schemas.auth import ( PrincipalResponse, TokenResponse, ) +from app.schemas.errors import ErrorResponse from app.services.auth import ( AuthenticatedSession, InvalidCredentialsError, @@ -32,6 +39,45 @@ logger = get_logger(__name__) DETAIL_IDENTIFIANTS = "Identifiants invalides" 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( response: Response, settings: SettingsDep, session: AuthenticatedSession @@ -61,7 +107,12 @@ def lit_le_cookie(request: Request, settings: SettingsDep) -> str: 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( payload: LoginRequest, request: Request, @@ -98,7 +149,8 @@ async def login( "/refresh", response_model=TokenResponse, 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( request: Request, @@ -133,7 +185,7 @@ async def refresh( "/logout", status_code=status.HTTP_204_NO_CONTENT, summary="Ferme la session courante", - dependencies=[Depends(require_trusted_origin)], + dependencies=[Depends(require_trusted_origin), Depends(cookie_de_rafraichissement)], ) async def logout( request: Request, response: Response, settings: SettingsDep, service: AuthServiceDep @@ -150,6 +202,7 @@ async def logout( status_code=status.HTTP_204_NO_CONTENT, summary="Ferme toutes les sessions du compte", dependencies=[Depends(require_trusted_origin)], + responses=REPONSES_AUTHENTIFIEES, ) async def logout_all( principal: CurrentPrincipalDep, @@ -163,7 +216,12 @@ async def logout_all( 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: return PrincipalResponse.from_principal(principal) @@ -173,6 +231,7 @@ async def me(principal: CurrentPrincipalDep) -> PrincipalResponse: response_model=TokenResponse, summary="Change son propre mot de passe", dependencies=[Depends(require_trusted_origin)], + responses=REPONSES_MOT_DE_PASSE, ) async def change_password( payload: PasswordChangeRequest, diff --git a/apps/backend/app/api/v1/endpoints/health.py b/apps/backend/app/api/v1/endpoints/health.py index bf6b2ee..e6d780a 100644 --- a/apps/backend/app/api/v1/endpoints/health.py +++ b/apps/backend/app/api/v1/endpoints/health.py @@ -3,16 +3,17 @@ from sqlalchemy import text from sqlalchemy.exc import SQLAlchemyError from app.api.deps import SessionDep, SettingsDep +from app.api.openapi import REPONSE_INDISPONIBLE from app.core.logging import get_logger from app.schemas.health import LivenessStatus, ReadinessStatus logger = get_logger(__name__) -router = APIRouter(tags=["health"]) +router = APIRouter() 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: return LivenessStatus( 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: try: version: str | None = await session.scalar(TIMESCALEDB_VERSION) diff --git a/apps/backend/app/api/v1/endpoints/users.py b/apps/backend/app/api/v1/endpoints/users.py index 825645d..794a10a 100644 --- a/apps/backend/app/api/v1/endpoints/users.py +++ b/apps/backend/app/api/v1/endpoints/users.py @@ -3,7 +3,9 @@ from uuid import UUID from fastapi import APIRouter, HTTPException, Response, status from app.api.deps import AdminDep, UserServiceDep +from app.api.openapi import REPONSE_VALIDATION, Reponses from app.core.logging import get_logger +from app.schemas.errors import ErrorResponse from app.schemas.user import ( TemporaryPasswordResponse, UserCreateRequest, @@ -15,6 +17,28 @@ from app.services.user import EmailAlreadyUsedError, LastAdminError, UserNotFoun router = APIRouter() 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") 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, status_code=status.HTTP_201_CREATED, summary="Crée un compte avec un mot de passe provisoire", + responses=REPONSES_CREATION, ) async def create_user( 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( user_id: UUID, payload: UserUpdateRequest, @@ -92,6 +122,7 @@ async def update_user( "/{user_id}/password-reset", response_model=TemporaryPasswordResponse, summary="Réinitialise le mot de passe et ferme les sessions", + responses=REPONSES_INTROUVABLE, ) async def reset_password( user_id: UUID, acteur: AdminDep, service: UserServiceDep, response: Response diff --git a/apps/backend/app/api/v1/router.py b/apps/backend/app/api/v1/router.py index 76e6f28..4a35810 100644 --- a/apps/backend/app/api/v1/router.py +++ b/apps/backend/app/api/v1/router.py @@ -1,8 +1,9 @@ from fastapi import APIRouter +from app.api.openapi import REPONSE_SERVEUR, REPONSES_ADMIN 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(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) diff --git a/apps/backend/app/core/config.py b/apps/backend/app/core/config.py index 4731f81..6733b3a 100644 --- a/apps/backend/app/core/config.py +++ b/apps/backend/app/core/config.py @@ -8,6 +8,7 @@ Environment = Literal["local", "dev", "staging", "prod"] SameSite = Literal["lax", "strict", "none"] SECRET_KEY_MIN_LENGTH = 32 +REFRESH_COOKIE_DEFAUT = "ev_refresh" SENTINELLES_INTERDITES = frozenset( {"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) 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_samesite: SameSite = "strict" cookie_secure: bool | None = None diff --git a/apps/backend/app/main.py b/apps/backend/app/main.py index 6c3c866..de1235e 100644 --- a/apps/backend/app/main.py +++ b/apps/backend/app/main.py @@ -7,6 +7,7 @@ from prometheus_fastapi_instrumentator import Instrumentator from app.api.errors import register_error_handlers 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.v1.router import api_router from app.core.config import Settings, get_settings @@ -37,6 +38,9 @@ def create_app(settings: Settings | None = None) -> FastAPI: application = FastAPI( title=resolved.name, version=resolved.version, + summary=SUMMARY, + description=DESCRIPTION, + openapi_tags=TAGS, debug=resolved.debug, lifespan=lifespan, docs_url="/docs" if documentee else None, diff --git a/apps/backend/app/schemas/errors.py b/apps/backend/app/schemas/errors.py new file mode 100644 index 0000000..5ed1d6c --- /dev/null +++ b/apps/backend/app/schemas/errors.py @@ -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