feat(backend): ouvre l'administration des comptes et le changement de mot de passe
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`.
This commit is contained in:
@@ -26,6 +26,7 @@ from app.repositories.login_attempt import LoginAttemptRepository
|
||||
from app.repositories.refresh_token import RefreshTokenRepository
|
||||
from app.repositories.user import UserRepository
|
||||
from app.services.auth import AuthService, LoginPolicy
|
||||
from app.services.user import UserService
|
||||
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
SettingsDep = Annotated[Settings, Depends(get_settings)]
|
||||
@@ -114,6 +115,22 @@ def get_auth_service(
|
||||
AuthServiceDep = Annotated[AuthService, Depends(get_auth_service)]
|
||||
|
||||
|
||||
def get_user_service(
|
||||
session: SessionDep,
|
||||
hasher: Annotated[Argon2Hasher, Depends(get_hasher)],
|
||||
) -> UserService:
|
||||
return UserService(
|
||||
users=UserRepository(session),
|
||||
refresh_tokens=RefreshTokenRepository(session),
|
||||
audit=AuditLogRepository(session),
|
||||
hasher=hasher,
|
||||
transaction=session,
|
||||
)
|
||||
|
||||
|
||||
UserServiceDep = Annotated[UserService, Depends(get_user_service)]
|
||||
|
||||
|
||||
async def get_current_principal(
|
||||
credentials: CredentialsDep,
|
||||
session: SessionDep,
|
||||
|
||||
@@ -13,7 +13,12 @@ from app.api.deps import (
|
||||
)
|
||||
from app.core.cookies import RefreshCookie, cookie_name
|
||||
from app.core.logging import get_logger
|
||||
from app.schemas.auth import LoginRequest, PrincipalResponse, TokenResponse
|
||||
from app.schemas.auth import (
|
||||
LoginRequest,
|
||||
PasswordChangeRequest,
|
||||
PrincipalResponse,
|
||||
TokenResponse,
|
||||
)
|
||||
from app.services.auth import (
|
||||
AuthenticatedSession,
|
||||
InvalidCredentialsError,
|
||||
@@ -161,3 +166,37 @@ async def logout_all(
|
||||
@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)
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Response, status
|
||||
|
||||
from app.api.deps import AdminDep, UserServiceDep
|
||||
from app.core.logging import get_logger
|
||||
from app.schemas.user import (
|
||||
TemporaryPasswordResponse,
|
||||
UserCreateRequest,
|
||||
UserResponse,
|
||||
UserUpdateRequest,
|
||||
)
|
||||
from app.services.user import EmailAlreadyUsedError, LastAdminError, UserNotFoundError
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@router.get("", response_model=list[UserResponse], summary="Liste les comptes")
|
||||
async def list_users(_: AdminDep, service: UserServiceDep) -> list[UserResponse]:
|
||||
comptes = await service.list_all()
|
||||
return [UserResponse.model_validate(compte) for compte in comptes]
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=TemporaryPasswordResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Crée un compte avec un mot de passe provisoire",
|
||||
)
|
||||
async def create_user(
|
||||
payload: UserCreateRequest,
|
||||
acteur: AdminDep,
|
||||
service: UserServiceDep,
|
||||
response: Response,
|
||||
) -> TemporaryPasswordResponse:
|
||||
# Le mot de passe provisoire ne doit être conservé par aucun intermédiaire.
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
try:
|
||||
cree = await service.create(
|
||||
actor=acteur,
|
||||
email=payload.email,
|
||||
role=payload.role,
|
||||
full_name=payload.full_name,
|
||||
)
|
||||
except EmailAlreadyUsedError as erreur:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT, detail="Adresse déjà utilisée"
|
||||
) from erreur
|
||||
|
||||
logger.info("user.created actor=%s target=%s", acteur.id, cree.user.id)
|
||||
return TemporaryPasswordResponse(
|
||||
user=UserResponse.model_validate(cree.user),
|
||||
temporary_password=cree.temporary_password,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{user_id}", response_model=UserResponse, summary="Change le rôle ou l'activation")
|
||||
async def update_user(
|
||||
user_id: UUID,
|
||||
payload: UserUpdateRequest,
|
||||
acteur: AdminDep,
|
||||
service: UserServiceDep,
|
||||
) -> UserResponse:
|
||||
compte = None
|
||||
try:
|
||||
if payload.role is not None:
|
||||
compte = await service.change_role(actor=acteur, user_id=user_id, role=payload.role)
|
||||
if payload.is_active is not None:
|
||||
compte = await service.set_active(
|
||||
actor=acteur, user_id=user_id, is_active=payload.is_active
|
||||
)
|
||||
except UserNotFoundError as erreur:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Compte introuvable"
|
||||
) from erreur
|
||||
except LastAdminError as erreur:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Dernier administrateur actif, l'opération le laisserait sans successeur",
|
||||
) from erreur
|
||||
|
||||
if compte is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Aucune modification demandée"
|
||||
)
|
||||
logger.info("user.updated actor=%s target=%s", acteur.id, user_id)
|
||||
return UserResponse.model_validate(compte)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{user_id}/password-reset",
|
||||
response_model=TemporaryPasswordResponse,
|
||||
summary="Réinitialise le mot de passe et ferme les sessions",
|
||||
)
|
||||
async def reset_password(
|
||||
user_id: UUID, acteur: AdminDep, service: UserServiceDep, response: Response
|
||||
) -> TemporaryPasswordResponse:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
try:
|
||||
reinitialise = await service.reset_password(actor=acteur, user_id=user_id)
|
||||
except UserNotFoundError as erreur:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Compte introuvable"
|
||||
) from erreur
|
||||
|
||||
logger.info("user.password_reset actor=%s target=%s", acteur.id, user_id)
|
||||
return TemporaryPasswordResponse(
|
||||
user=UserResponse.model_validate(reinitialise.user),
|
||||
temporary_password=reinitialise.temporary_password,
|
||||
)
|
||||
@@ -1,7 +1,8 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1.endpoints import auth, health
|
||||
from app.api.v1.endpoints import auth, health, users
|
||||
|
||||
api_router = APIRouter()
|
||||
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"])
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Contrainte : 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. C'est l'attribution de masse, API3 du top 10 API.
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||
|
||||
from app.core.roles import AccountKind, Role
|
||||
|
||||
|
||||
class UserCreateRequest(BaseModel):
|
||||
email: EmailStr
|
||||
role: Role
|
||||
full_name: str | None = Field(default=None, max_length=200)
|
||||
|
||||
|
||||
class UserUpdateRequest(BaseModel):
|
||||
role: Role | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
email: str
|
||||
role: Role
|
||||
kind: AccountKind
|
||||
is_active: bool
|
||||
must_change_password: bool
|
||||
full_name: str | None
|
||||
last_login_at: datetime | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class TemporaryPasswordResponse(BaseModel):
|
||||
# Affiché une seule fois : l'empreinte seule est conservée côté serveur.
|
||||
user: UserResponse
|
||||
temporary_password: str
|
||||
@@ -162,6 +162,44 @@ class AuthService:
|
||||
await self._refresh.revoke_family(ligne.family_id, RevocationReason.DECONNEXION)
|
||||
await self._transaction.commit()
|
||||
|
||||
async def change_password(
|
||||
self,
|
||||
*,
|
||||
principal: Principal,
|
||||
current_password: str,
|
||||
new_password: str,
|
||||
client_ip: str | None,
|
||||
user_agent: str | None,
|
||||
) -> AuthenticatedSession:
|
||||
compte = await self._users.get_by_id(principal.id)
|
||||
if compte is None or not await self._hasher.verify(compte.password_hash, current_password):
|
||||
raise InvalidCredentialsError("Identifiants invalides")
|
||||
|
||||
await self._users.update_password(
|
||||
principal.id, await self._hasher.hash(new_password), must_change_password=False
|
||||
)
|
||||
# Toutes les sessions tombent, puis on en rouvre une : l'appareil courant reste
|
||||
# connecté et tous les autres sont déconnectés.
|
||||
revoquees = await self._refresh.revoke_all_for_user(
|
||||
principal.id, RevocationReason.CHANGEMENT_MOT_DE_PASSE
|
||||
)
|
||||
secret = await self._ouvre_une_famille(
|
||||
user_id=principal.id, client_ip=client_ip, user_agent=user_agent
|
||||
)
|
||||
await self._audit.record(
|
||||
action=AuditAction.COMPTE_MOT_DE_PASSE_CHANGE,
|
||||
actor=principal,
|
||||
target_type="app_user",
|
||||
target_id=str(principal.id),
|
||||
client_ip=client_ip,
|
||||
user_agent=user_agent,
|
||||
detail={"sessions_revoquees": revoquees},
|
||||
)
|
||||
await self._transaction.commit()
|
||||
|
||||
rafraichi = await self._users.get_by_id(principal.id)
|
||||
return self._session(self._en_principal(rafraichi or compte), secret)
|
||||
|
||||
async def logout_all(self, principal: Principal) -> int:
|
||||
revoquees = await self._refresh.revoke_all_for_user(
|
||||
principal.id, RevocationReason.DECONNEXION
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
# Piège : `change_role()` et `set_active()` refusent de toucher au dernier administrateur actif.
|
||||
# Sans cette garde, un administrateur peut se rétrograder ou se désactiver lui-même, et plus
|
||||
# personne ne peut administrer la plateforme sans repasser par `psql`.
|
||||
|
||||
import secrets
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
from uuid import UUID
|
||||
|
||||
from app.core.hashing import Argon2Hasher
|
||||
from app.core.principal import Principal
|
||||
from app.core.roles import Role
|
||||
from app.models.audit_log import AuditAction
|
||||
from app.models.refresh_token import RevocationReason
|
||||
from app.models.user import AppUser
|
||||
from app.repositories.audit_log import AuditLogRepository
|
||||
from app.repositories.refresh_token import RefreshTokenRepository
|
||||
from app.repositories.user import UserRepository
|
||||
|
||||
LONGUEUR_MOT_DE_PASSE_TEMPORAIRE = 18
|
||||
|
||||
|
||||
class Transaction(Protocol):
|
||||
async def commit(self) -> None: ...
|
||||
|
||||
|
||||
class UserError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class UserNotFoundError(UserError):
|
||||
pass
|
||||
|
||||
|
||||
class EmailAlreadyUsedError(UserError):
|
||||
pass
|
||||
|
||||
|
||||
class LastAdminError(UserError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CreatedUser:
|
||||
user: AppUser
|
||||
temporary_password: str
|
||||
|
||||
|
||||
class UserService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
users: UserRepository,
|
||||
refresh_tokens: RefreshTokenRepository,
|
||||
audit: AuditLogRepository,
|
||||
hasher: Argon2Hasher,
|
||||
transaction: Transaction,
|
||||
) -> None:
|
||||
self._users = users
|
||||
self._refresh = refresh_tokens
|
||||
self._audit = audit
|
||||
self._hasher = hasher
|
||||
self._transaction = transaction
|
||||
|
||||
async def list_all(self) -> Sequence[AppUser]:
|
||||
return await self._users.list_all()
|
||||
|
||||
async def create(
|
||||
self, *, actor: Principal, email: str, role: Role, full_name: str | None
|
||||
) -> CreatedUser:
|
||||
if await self._users.get_by_email(email) is not None:
|
||||
raise EmailAlreadyUsedError(email)
|
||||
|
||||
provisoire = secrets.token_urlsafe(LONGUEUR_MOT_DE_PASSE_TEMPORAIRE)
|
||||
compte = await self._users.create(
|
||||
email=email,
|
||||
password_hash=await self._hasher.hash(provisoire),
|
||||
role=role,
|
||||
full_name=full_name,
|
||||
must_change_password=True,
|
||||
)
|
||||
await self._audit.record(
|
||||
action=AuditAction.COMPTE_CREE,
|
||||
actor=actor,
|
||||
target_type="app_user",
|
||||
target_id=str(compte.id),
|
||||
detail={"email": compte.email, "role_apres": role.value},
|
||||
)
|
||||
await self._transaction.commit()
|
||||
return CreatedUser(user=compte, temporary_password=provisoire)
|
||||
|
||||
async def change_role(self, *, actor: Principal, user_id: UUID, role: Role) -> AppUser:
|
||||
compte = await self._exige(user_id)
|
||||
if compte.role == role.value:
|
||||
return compte
|
||||
|
||||
await self._refuse_si_dernier_admin(compte, futur_role=role, futur_actif=compte.is_active)
|
||||
avant = compte.role
|
||||
await self._users.set_role(user_id, role)
|
||||
await self._refresh.revoke_all_for_user(user_id, RevocationReason.ADMINISTRATION)
|
||||
await self._audit.record(
|
||||
action=AuditAction.COMPTE_ROLE_CHANGE,
|
||||
actor=actor,
|
||||
target_type="app_user",
|
||||
target_id=str(user_id),
|
||||
detail={"role_avant": avant, "role_apres": role.value},
|
||||
)
|
||||
await self._transaction.commit()
|
||||
return await self._exige(user_id)
|
||||
|
||||
async def set_active(self, *, actor: Principal, user_id: UUID, is_active: bool) -> AppUser:
|
||||
compte = await self._exige(user_id)
|
||||
if compte.is_active == is_active:
|
||||
return compte
|
||||
|
||||
await self._refuse_si_dernier_admin(
|
||||
compte, futur_role=Role(compte.role), futur_actif=is_active
|
||||
)
|
||||
await self._users.set_active(user_id, is_active=is_active)
|
||||
if not is_active:
|
||||
await self._refresh.revoke_all_for_user(user_id, RevocationReason.ADMINISTRATION)
|
||||
await self._audit.record(
|
||||
action=AuditAction.COMPTE_ACTIVE if is_active else AuditAction.COMPTE_DESACTIVE,
|
||||
actor=actor,
|
||||
target_type="app_user",
|
||||
target_id=str(user_id),
|
||||
)
|
||||
await self._transaction.commit()
|
||||
return await self._exige(user_id)
|
||||
|
||||
async def reset_password(self, *, actor: Principal, user_id: UUID) -> CreatedUser:
|
||||
compte = await self._exige(user_id)
|
||||
provisoire = secrets.token_urlsafe(LONGUEUR_MOT_DE_PASSE_TEMPORAIRE)
|
||||
|
||||
await self._users.update_password(
|
||||
user_id, await self._hasher.hash(provisoire), must_change_password=True
|
||||
)
|
||||
await self._refresh.revoke_all_for_user(user_id, RevocationReason.CHANGEMENT_MOT_DE_PASSE)
|
||||
await self._audit.record(
|
||||
action=AuditAction.COMPTE_MOT_DE_PASSE_REINITIALISE,
|
||||
actor=actor,
|
||||
target_type="app_user",
|
||||
target_id=str(user_id),
|
||||
detail={"email": compte.email},
|
||||
)
|
||||
await self._transaction.commit()
|
||||
return CreatedUser(user=await self._exige(user_id), temporary_password=provisoire)
|
||||
|
||||
async def _exige(self, user_id: UUID) -> AppUser:
|
||||
compte = await self._users.get_by_id(user_id)
|
||||
if compte is None:
|
||||
raise UserNotFoundError(str(user_id))
|
||||
return compte
|
||||
|
||||
async def _refuse_si_dernier_admin(
|
||||
self, compte: AppUser, *, futur_role: Role, futur_actif: bool
|
||||
) -> None:
|
||||
etait_admin = compte.role == Role.ADMIN.value and compte.is_active
|
||||
reste_admin = futur_role is Role.ADMIN and futur_actif
|
||||
if not etait_admin or reste_admin:
|
||||
return
|
||||
if await self._users.count_active_admins() <= 1:
|
||||
raise LastAdminError(str(compte.id))
|
||||
Reference in New Issue
Block a user