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.
262 lines
8.4 KiB
Python
262 lines
8.4 KiB
Python
# Piège : le jeton de rafraîchissement ne quitte jamais le cookie httpOnly, et le jeton
|
|
# d'accès ne va jamais dans un cookie. C'est ce qui réduit la surface CSRF aux trois routes de
|
|
# ce module : partout ailleurs, le navigateur n'attache rien de lui-même.
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
|
|
|
from app.api.deps import (
|
|
AuthServiceDep,
|
|
CurrentPrincipalDep,
|
|
SettingsDep,
|
|
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 (
|
|
LoginRequest,
|
|
PasswordChangeRequest,
|
|
PrincipalResponse,
|
|
TokenResponse,
|
|
)
|
|
from app.schemas.errors import ErrorResponse
|
|
from app.services.auth import (
|
|
AuthenticatedSession,
|
|
InvalidCredentialsError,
|
|
RateLimitedError,
|
|
SessionRejectedError,
|
|
)
|
|
|
|
router = APIRouter()
|
|
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
|
|
) -> TokenResponse:
|
|
response.headers["Cache-Control"] = "no-store"
|
|
response.set_cookie(**RefreshCookie.build(settings, session.refresh_secret).as_kwargs())
|
|
return TokenResponse(
|
|
access_token=session.access_token,
|
|
expires_in=session.expires_in,
|
|
principal=PrincipalResponse.from_principal(session.principal),
|
|
)
|
|
|
|
|
|
# Piège : une `HTTPException` construit sa propre réponse, donc tout en-tête posé sur la
|
|
# `Response` injectée est perdu. L'effacement du cookie doit voyager avec l'exception,
|
|
# sans quoi un navigateur garderait un cookie mort après une détection de réutilisation.
|
|
def entete_de_suppression(settings: SettingsDep) -> str:
|
|
temoin = Response()
|
|
temoin.delete_cookie(**RefreshCookie.expired(settings).as_deletion_kwargs())
|
|
return temoin.headers["set-cookie"]
|
|
|
|
|
|
def lit_le_cookie(request: Request, settings: SettingsDep) -> str:
|
|
secret = request.cookies.get(cookie_name(settings))
|
|
if not secret:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=DETAIL_SESSION)
|
|
return secret
|
|
|
|
|
|
@router.post(
|
|
"/login",
|
|
response_model=TokenResponse,
|
|
summary="Ouvre une session",
|
|
responses=REPONSES_LOGIN,
|
|
)
|
|
async def login(
|
|
payload: LoginRequest,
|
|
request: Request,
|
|
response: Response,
|
|
settings: SettingsDep,
|
|
service: AuthServiceDep,
|
|
client_ip: str | None = Depends(get_client_ip),
|
|
) -> TokenResponse:
|
|
response.headers["Cache-Control"] = "no-store"
|
|
agent = request.headers.get("user-agent")
|
|
|
|
try:
|
|
session = await service.authenticate(
|
|
email=payload.email, password=payload.password, client_ip=client_ip, user_agent=agent
|
|
)
|
|
except RateLimitedError as erreur:
|
|
logger.warning("auth.rate_limited email=%s ip=%s", payload.email, client_ip)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
detail="Trop de tentatives, réessayez plus tard",
|
|
headers={"Retry-After": str(erreur.retry_after)},
|
|
) from erreur
|
|
except InvalidCredentialsError as erreur:
|
|
logger.warning("auth.login.failure email=%s ip=%s", payload.email, client_ip)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED, detail=DETAIL_IDENTIFIANTS
|
|
) from erreur
|
|
|
|
logger.info("auth.login.success user_id=%s ip=%s", session.principal.id, client_ip)
|
|
return repond(response, settings, session)
|
|
|
|
|
|
@router.post(
|
|
"/refresh",
|
|
response_model=TokenResponse,
|
|
summary="Fait tourner la session",
|
|
dependencies=[Depends(require_trusted_origin), Depends(cookie_de_rafraichissement)],
|
|
responses=REPONSES_REFRESH,
|
|
)
|
|
async def refresh(
|
|
request: Request,
|
|
response: Response,
|
|
settings: SettingsDep,
|
|
service: AuthServiceDep,
|
|
client_ip: str | None = Depends(get_client_ip),
|
|
) -> TokenResponse:
|
|
response.headers["Cache-Control"] = "no-store"
|
|
|
|
try:
|
|
session = await service.refresh(
|
|
secret=lit_le_cookie(request, settings),
|
|
client_ip=client_ip,
|
|
user_agent=request.headers.get("user-agent"),
|
|
)
|
|
except SessionRejectedError as erreur:
|
|
logger.warning("auth.refresh.rejected ip=%s", client_ip)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail=DETAIL_SESSION,
|
|
headers={
|
|
"Set-Cookie": entete_de_suppression(settings),
|
|
"Cache-Control": "no-store",
|
|
},
|
|
) from erreur
|
|
|
|
return repond(response, settings, session)
|
|
|
|
|
|
@router.post(
|
|
"/logout",
|
|
status_code=status.HTTP_204_NO_CONTENT,
|
|
summary="Ferme la session courante",
|
|
dependencies=[Depends(require_trusted_origin), Depends(cookie_de_rafraichissement)],
|
|
)
|
|
async def logout(
|
|
request: Request, response: Response, settings: SettingsDep, service: AuthServiceDep
|
|
) -> None:
|
|
response.headers["Cache-Control"] = "no-store"
|
|
secret = request.cookies.get(cookie_name(settings))
|
|
if secret:
|
|
await service.logout(secret=secret)
|
|
response.delete_cookie(**RefreshCookie.expired(settings).as_deletion_kwargs())
|
|
|
|
|
|
@router.post(
|
|
"/logout-all",
|
|
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,
|
|
response: Response,
|
|
settings: SettingsDep,
|
|
service: AuthServiceDep,
|
|
) -> None:
|
|
response.headers["Cache-Control"] = "no-store"
|
|
revoquees = await service.logout_all(principal)
|
|
logger.info("auth.logout_all user_id=%s sessions=%s", principal.id, revoquees)
|
|
response.delete_cookie(**RefreshCookie.expired(settings).as_deletion_kwargs())
|
|
|
|
|
|
@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)
|
|
|
|
|
|
@router.post(
|
|
"/password",
|
|
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,
|
|
principal: CurrentPrincipalDep,
|
|
request: Request,
|
|
response: Response,
|
|
settings: SettingsDep,
|
|
service: AuthServiceDep,
|
|
client_ip: str | None = Depends(get_client_ip),
|
|
) -> TokenResponse:
|
|
response.headers["Cache-Control"] = "no-store"
|
|
|
|
try:
|
|
session = await service.change_password(
|
|
principal=principal,
|
|
current_password=payload.current_password,
|
|
new_password=payload.new_password,
|
|
client_ip=client_ip,
|
|
user_agent=request.headers.get("user-agent"),
|
|
)
|
|
except InvalidCredentialsError as erreur:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED, detail=DETAIL_IDENTIFIANTS
|
|
) from erreur
|
|
|
|
logger.info("auth.password_changed user_id=%s", principal.id)
|
|
return repond(response, settings, session)
|