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:
Johan LEROY
2026-09-15 14:52:29 +02:00
parent 1f6210698d
commit 7fdd6513ca
9 changed files with 860 additions and 2 deletions
+38
View File
@@ -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