feat: Réinitialisation de mot de passe avec tokens sécurisés

Implémentation complète du flux de réinitialisation de mot de passe (Story 1.5):

Backend:
- Aggregate PasswordResetToken avec TTL 1h, UUID v7, usage unique
- Endpoint POST /api/password/forgot avec rate limiting (3/h par email, 10/h par IP)
- Endpoint POST /api/password/reset avec validation token
- Templates email (demande + confirmation)
- Repository Redis avec TTL 2h pour distinguer expiré/invalide

Frontend:
- Page /mot-de-passe-oublie avec message générique (anti-énumération)
- Page /reset-password/[token] avec validation temps réel des critères
- Gestion erreurs: token invalide, expiré, déjà utilisé

Tests:
- 14 tests unitaires PasswordResetToken
- 7 tests unitaires RequestPasswordResetHandler
- 7 tests unitaires ResetPasswordHandler
- Tests E2E Playwright pour le flux complet
This commit is contained in:
2026-02-01 23:15:01 +01:00
parent b7354b8448
commit affad287f9
71 changed files with 4829 additions and 222 deletions

View File

@@ -6,6 +6,7 @@ namespace App\Administration\Domain\Model\User;
use App\Administration\Domain\Event\CompteActive;
use App\Administration\Domain\Event\CompteCreated;
use App\Administration\Domain\Event\MotDePasseChange;
use App\Administration\Domain\Exception\CompteNonActivableException;
use App\Administration\Domain\Model\ConsentementParental\ConsentementParental;
use App\Administration\Domain\Policy\ConsentementParentalPolicy;
@@ -14,11 +15,11 @@ use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
/**
* Aggregate Root représentant un utilisateur dans Classeo.
* Aggregate Root representing a user in Classeo.
*
* Un utilisateur appartient à un établissement (tenant) et possède un rôle.
* Le cycle de vie du compte passe par plusieurs statuts : création → activation.
* Les mineurs (< 15 ans) nécessitent un consentement parental avant activation.
* A user belongs to a school (tenant) and has a role.
* The account lifecycle goes through multiple statuses: creation → activation.
* Minors (< 15 years) require parental consent before activation.
*/
final class User extends AggregateRoot
{
@@ -39,7 +40,7 @@ final class User extends AggregateRoot
}
/**
* Crée un nouveau compte utilisateur en attente d'activation.
* Creates a new user account awaiting activation.
*/
public static function creer(
Email $email,
@@ -72,9 +73,9 @@ final class User extends AggregateRoot
}
/**
* Active le compte avec le mot de passe hashé.
* Activates the account with the hashed password.
*
* @throws CompteNonActivableException si le compte ne peut pas être activé
* @throws CompteNonActivableException if the account cannot be activated
*/
public function activer(
string $hashedPassword,
@@ -85,7 +86,7 @@ final class User extends AggregateRoot
throw CompteNonActivableException::carStatutIncompatible($this->id, $this->statut);
}
// Vérifier si le consentement parental est requis
// Check if parental consent is required
if ($consentementPolicy->estRequis($this->dateNaissance)) {
if ($this->consentementParental === null) {
throw CompteNonActivableException::carConsentementManquant($this->id);
@@ -107,20 +108,20 @@ final class User extends AggregateRoot
}
/**
* Enregistre le consentement parental donné par le parent.
* Records the parental consent given by the parent.
*/
public function enregistrerConsentementParental(ConsentementParental $consentement): void
{
$this->consentementParental = $consentement;
// Si le compte était en attente de consentement, passer en attente d'activation
// If the account was awaiting consent, move to awaiting activation
if ($this->statut === StatutCompte::CONSENTEMENT_REQUIS) {
$this->statut = StatutCompte::EN_ATTENTE;
}
}
/**
* Vérifie si cet utilisateur est mineur et nécessite un consentement parental.
* Checks if this user is a minor and requires parental consent.
*/
public function necessiteConsentementParental(ConsentementParentalPolicy $policy): bool
{
@@ -128,7 +129,7 @@ final class User extends AggregateRoot
}
/**
* Vérifie si le compte est actif et peut se connecter.
* Checks if the account is active and can log in.
*/
public function peutSeConnecter(): bool
{
@@ -136,9 +137,26 @@ final class User extends AggregateRoot
}
/**
* Reconstitue un User depuis le stockage.
* Changes the user's password.
*
* @internal Pour usage par l'Infrastructure uniquement
* Used during password reset.
*/
public function changerMotDePasse(string $hashedPassword, DateTimeImmutable $at): void
{
$this->hashedPassword = $hashedPassword;
$this->recordEvent(new MotDePasseChange(
userId: (string) $this->id,
email: (string) $this->email,
tenantId: $this->tenantId,
occurredOn: $at,
));
}
/**
* Reconstitutes a User from storage.
*
* @internal For Infrastructure use only
*/
public static function reconstitute(
UserId $id,