Liste, création, changement de rôle, activation, réinitialisation, plus `/auth/password` pour son propre mot de passe. Les schémas de lecture et d'écriture sont séparés : un modèle unique laisserait passer `role` ou `is_active` depuis un corps de requête et renverrait `password_hash` en réponse, soit l'attribution de masse, API3 du top 10 API. Un test envoie ces deux champs et vérifie qu'ils sont ignorés. Le service refuse de rétrograder ou de désactiver le dernier administrateur actif. Sans cette garde, un administrateur peut se verrouiller lui-même dehors et il ne reste que `psql` pour rentrer. Tout changement de rôle ou désactivation révoque les sessions de la cible, et `credentials_changed_at` rend le jeton d'accès encore valide inutilisable dès la requête suivante. La promesse de révocation immédiate ne tient que si les deux sont faits. Le changement de son propre mot de passe révoque toutes les familles puis en rouvre une : l'appareil courant reste connecté, tous les autres sont déconnectés. Il faut le coder explicitement pour l'obtenir. Les mots de passe provisoires sont tirés au sort et affichés une seule fois, sous `Cache-Control: no-store`.
203 lines
6.8 KiB
Python
203 lines
6.8 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.core.cookies import RefreshCookie, cookie_name
|
|
from app.core.logging import get_logger
|
|
from app.schemas.auth import (
|
|
LoginRequest,
|
|
PasswordChangeRequest,
|
|
PrincipalResponse,
|
|
TokenResponse,
|
|
)
|
|
from app.services.auth import (
|
|
AuthenticatedSession,
|
|
InvalidCredentialsError,
|
|
RateLimitedError,
|
|
SessionRejectedError,
|
|
)
|
|
|
|
router = APIRouter()
|
|
logger = get_logger(__name__)
|
|
|
|
DETAIL_IDENTIFIANTS = "Identifiants invalides"
|
|
DETAIL_SESSION = "Session invalide"
|
|
|
|
|
|
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")
|
|
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)],
|
|
)
|
|
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)],
|
|
)
|
|
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)],
|
|
)
|
|
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é")
|
|
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)],
|
|
)
|
|
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)
|