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.
This commit is contained in:
@@ -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`).",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from app.api.deps import (
|
|||||||
require_trusted_origin,
|
require_trusted_origin,
|
||||||
)
|
)
|
||||||
from app.api.openapi import (
|
from app.api.openapi import (
|
||||||
|
REPONSE_ORIGINE_REFUSEE,
|
||||||
REPONSE_VALIDATION,
|
REPONSE_VALIDATION,
|
||||||
REPONSES_AUTHENTIFIEES,
|
REPONSES_AUTHENTIFIEES,
|
||||||
Reponses,
|
Reponses,
|
||||||
@@ -61,6 +62,7 @@ REPONSES_LOGIN: Reponses = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
REPONSES_REFRESH: Reponses = {
|
REPONSES_REFRESH: Reponses = {
|
||||||
|
**REPONSE_ORIGINE_REFUSEE,
|
||||||
401: {
|
401: {
|
||||||
"model": ErrorResponse,
|
"model": ErrorResponse,
|
||||||
"description": (
|
"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 = {
|
REPONSES_MOT_DE_PASSE: Reponses = {
|
||||||
**REPONSE_VALIDATION,
|
**REPONSE_VALIDATION,
|
||||||
|
**REPONSE_ORIGINE_REFUSEE,
|
||||||
401: {
|
401: {
|
||||||
"model": ErrorResponse,
|
"model": ErrorResponse,
|
||||||
"description": "Jeton d'accès invalide, ou mot de passe courant faux.",
|
"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,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
summary="Ferme la session courante",
|
summary="Ferme la session courante",
|
||||||
dependencies=[Depends(require_trusted_origin), Depends(cookie_de_rafraichissement)],
|
dependencies=[Depends(require_trusted_origin), Depends(cookie_de_rafraichissement)],
|
||||||
|
responses=REPONSES_LOGOUT,
|
||||||
)
|
)
|
||||||
async def logout(
|
async def logout(
|
||||||
request: Request, response: Response, settings: SettingsDep, service: AuthServiceDep
|
request: Request, response: Response, settings: SettingsDep, service: AuthServiceDep
|
||||||
@@ -202,7 +210,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,
|
responses=REPONSES_LOGOUT_ALL,
|
||||||
)
|
)
|
||||||
async def logout_all(
|
async def logout_all(
|
||||||
principal: CurrentPrincipalDep,
|
principal: CurrentPrincipalDep,
|
||||||
|
|||||||
@@ -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": {
|
"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.",
|
"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": {
|
"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": [
|
"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": [
|
"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": {
|
"401": {
|
||||||
"description": "Jeton d'accès invalide, ou mot de passe courant faux.",
|
"description": "Jeton d'accès invalide, ou mot de passe courant faux.",
|
||||||
"content": {
|
"content": {
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ METHODES = {"get", "post", "patch", "put", "delete"}
|
|||||||
# documenté y serait faux.
|
# documenté y serait faux.
|
||||||
SANS_REFUS = {("POST", "/api/v1/auth/logout")}
|
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")
|
@pytest.fixture(scope="module")
|
||||||
def schema() -> dict[str, Any]:
|
def schema() -> dict[str, Any]:
|
||||||
@@ -58,6 +65,16 @@ def test_every_administration_route_documents_the_role_refusal(schema: dict[str,
|
|||||||
assert sans_403 == []
|
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:
|
def test_the_validation_model_matches_what_the_handler_returns(schema: dict[str, Any]) -> None:
|
||||||
modeles = {
|
modeles = {
|
||||||
operation["responses"]["422"]["content"]["application/json"]["schema"]["$ref"]
|
operation["responses"]["422"]["content"]["application/json"]["schema"]["$ref"]
|
||||||
|
|||||||
@@ -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/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 |
|
| 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/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/refresh` | Fait tourner la session. Cookie seulement | 401, 403, 500 |
|
||||||
| POST | `/api/v1/auth/logout` | Ferme la session courante. Idempotente | 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, 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, 422, 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/auth/me` | Décrit le compte connecté | 401, 500 |
|
||||||
| GET | `/api/v1/users` | Liste les comptes. `admin` | 401, 403, 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 |
|
| POST | `/api/v1/users` | Crée un compte, rend un mot de passe provisoire. `admin` | 401, 403, 409, 422, 500 |
|
||||||
|
|||||||
@@ -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 |
|
| `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: "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` 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 |
|
| `422` | corps invalide | le détail donne `champ` et `type`, jamais la valeur envoyée |
|
||||||
|
|
||||||
## Les quatre règles qui comptent
|
## Les quatre règles qui comptent
|
||||||
|
|||||||
Reference in New Issue
Block a user