From fabd073aaffe39cad03fcab99db7f2ae8c7fc5f4 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 11:16:38 +0200 Subject: [PATCH] fix(backend): documente le 403 CSRF de require_trusted_origin Le contrat OpenAPI et 31-contrat-authentification.md passaient sous silence le 403 leve par require_trusted_origin sur refresh, logout, logout-all et password. Ajoute REPONSE_ORIGINE_REFUSEE, regenere openapi.json et etend test_openapi.py pour verifier que ces quatre routes le declarent. --- apps/backend/app/api/openapi.py | 7 ++++ apps/backend/app/api/v1/endpoints/auth.py | 10 ++++- apps/backend/openapi.json | 40 +++++++++++++++++++ apps/backend/tests/api/test_openapi.py | 17 ++++++++ docs/architecture/20-backend.md | 8 ++-- .../31-contrat-authentification.md | 1 + 6 files changed, 78 insertions(+), 5 deletions(-) diff --git a/apps/backend/app/api/openapi.py b/apps/backend/app/api/openapi.py index fedb3bf..c96e351 100644 --- a/apps/backend/app/api/openapi.py +++ b/apps/backend/app/api/openapi.py @@ -112,3 +112,10 @@ REPONSES_ADMIN: Final[Reponses] = { ), }, } + +REPONSE_ORIGINE_REFUSEE: Final[Reponses] = { + 403: { + "model": ErrorResponse, + "description": "Origine non autorisée (protection CSRF de `require_trusted_origin`).", + }, +} diff --git a/apps/backend/app/api/v1/endpoints/auth.py b/apps/backend/app/api/v1/endpoints/auth.py index 9e79763..32bf8b2 100644 --- a/apps/backend/app/api/v1/endpoints/auth.py +++ b/apps/backend/app/api/v1/endpoints/auth.py @@ -12,6 +12,7 @@ from app.api.deps import ( require_trusted_origin, ) from app.api.openapi import ( + REPONSE_ORIGINE_REFUSEE, REPONSE_VALIDATION, REPONSES_AUTHENTIFIEES, Reponses, @@ -61,6 +62,7 @@ REPONSES_LOGIN: Reponses = { } REPONSES_REFRESH: Reponses = { + **REPONSE_ORIGINE_REFUSEE, 401: { "model": ErrorResponse, "description": ( @@ -70,8 +72,13 @@ REPONSES_REFRESH: Reponses = { }, } +REPONSES_LOGOUT: Reponses = {**REPONSE_ORIGINE_REFUSEE} + +REPONSES_LOGOUT_ALL: Reponses = {**REPONSES_AUTHENTIFIEES, **REPONSE_ORIGINE_REFUSEE} + REPONSES_MOT_DE_PASSE: Reponses = { **REPONSE_VALIDATION, + **REPONSE_ORIGINE_REFUSEE, 401: { "model": ErrorResponse, "description": "Jeton d'accès invalide, ou mot de passe courant faux.", @@ -186,6 +193,7 @@ async def refresh( status_code=status.HTTP_204_NO_CONTENT, summary="Ferme la session courante", dependencies=[Depends(require_trusted_origin), Depends(cookie_de_rafraichissement)], + responses=REPONSES_LOGOUT, ) async def logout( request: Request, response: Response, settings: SettingsDep, service: AuthServiceDep @@ -202,7 +210,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, + responses=REPONSES_LOGOUT_ALL, ) async def logout_all( principal: CurrentPrincipalDep, diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json index 8462c4d..cca65d0 100644 --- a/apps/backend/openapi.json +++ b/apps/backend/openapi.json @@ -186,6 +186,16 @@ } } }, + "403": { + "description": "Origine non autorisée (protection CSRF de `require_trusted_origin`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "401": { "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.", "content": { @@ -224,6 +234,16 @@ } } } + }, + "403": { + "description": "Origine non autorisée (protection CSRF de `require_trusted_origin`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } }, "security": [ @@ -263,6 +283,16 @@ } } } + }, + "403": { + "description": "Origine non autorisée (protection CSRF de `require_trusted_origin`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } }, "security": [ @@ -366,6 +396,16 @@ } } }, + "403": { + "description": "Origine non autorisée (protection CSRF de `require_trusted_origin`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "401": { "description": "Jeton d'accès invalide, ou mot de passe courant faux.", "content": { diff --git a/apps/backend/tests/api/test_openapi.py b/apps/backend/tests/api/test_openapi.py index ca979d5..96297c0 100644 --- a/apps/backend/tests/api/test_openapi.py +++ b/apps/backend/tests/api/test_openapi.py @@ -15,6 +15,13 @@ METHODES = {"get", "post", "patch", "put", "delete"} # documenté y serait faux. SANS_REFUS = {("POST", "/api/v1/auth/logout")} +ORIGINE_VERIFIEE = { + ("POST", "/api/v1/auth/refresh"), + ("POST", "/api/v1/auth/logout"), + ("POST", "/api/v1/auth/logout-all"), + ("POST", "/api/v1/auth/password"), +} + @pytest.fixture(scope="module") def schema() -> dict[str, Any]: @@ -58,6 +65,16 @@ def test_every_administration_route_documents_the_role_refusal(schema: dict[str, assert sans_403 == [] +def test_every_origin_checked_route_documents_the_csrf_refusal(schema: dict[str, Any]) -> None: + sans_403 = [ + (methode, chemin) + for methode, chemin, operation in operations(schema) + if (methode, chemin) in ORIGINE_VERIFIEE and "403" not in operation["responses"] + ] + + assert sans_403 == [] + + def test_the_validation_model_matches_what_the_handler_returns(schema: dict[str, Any]) -> None: modeles = { operation["responses"]["422"]["content"]["application/json"]["schema"]["$ref"] diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index 1454a39..7158c9a 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -131,10 +131,10 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke | GET | `/api/v1/health/live` | Le processus répond. Ne touche pas la base | 500 | | GET | `/api/v1/health/ready` | La base répond **et** l'extension TimescaleDB est chargée | 503, 500 | | POST | `/api/v1/auth/login` | Ouvre une session. Publique | 401, 422, 429, 500 | -| POST | `/api/v1/auth/refresh` | Fait tourner la session. Cookie seulement | 401, 500 | -| POST | `/api/v1/auth/logout` | Ferme la session courante. Idempotente | 500 | -| POST | `/api/v1/auth/logout-all` | Ferme toutes les sessions du compte | 401, 500 | -| POST | `/api/v1/auth/password` | Change son propre mot de passe | 401, 422, 500 | +| POST | `/api/v1/auth/refresh` | Fait tourner la session. Cookie seulement | 401, 403, 500 | +| POST | `/api/v1/auth/logout` | Ferme la session courante. Idempotente | 403, 500 | +| POST | `/api/v1/auth/logout-all` | Ferme toutes les sessions du compte | 401, 403, 500 | +| POST | `/api/v1/auth/password` | Change son propre mot de passe | 401, 403, 422, 500 | | GET | `/api/v1/auth/me` | Décrit le compte connecté | 401, 500 | | GET | `/api/v1/users` | Liste les comptes. `admin` | 401, 403, 500 | | POST | `/api/v1/users` | Crée un compte, rend un mot de passe provisoire. `admin` | 401, 403, 409, 422, 500 | diff --git a/docs/architecture/31-contrat-authentification.md b/docs/architecture/31-contrat-authentification.md index ec852c1..9c9fe66 100644 --- a/docs/architecture/31-contrat-authentification.md +++ b/docs/architecture/31-contrat-authentification.md @@ -68,6 +68,7 @@ Le secret de rafraîchissement **n'apparaît jamais** dans le corps de la répon | `401` sur `/auth/refresh` | session révoquée, expirée ou rejouée | **déconnecter** et renvoyer vers la page de connexion | | `403` avec `detail: "password_change_required"` | mot de passe provisoire | rediriger vers l'écran de changement de mot de passe | | `403` avec `detail: "Droits insuffisants"` | rôle trop bas | masquer ou griser l'action, ne pas déconnecter | +| `403` sur `/auth/refresh`, `/logout`, `/logout-all`, `/password` | origine hors liste autorisée (voir « Origines autorisées ») | erreur de configuration réseau, pas un cas à gérer par l'utilisateur | | `422` | corps invalide | le détail donne `champ` et `type`, jamais la valeur envoyée | ## Les quatre règles qui comptent