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

@@ -17,15 +17,15 @@ use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
/**
* Charge les utilisateurs depuis le domaine pour l'authentification Symfony.
* Loads users from the domain for Symfony authentication.
*
* Ce provider fait le pont entre Symfony Security et notre Domain Layer.
* Il ne révèle jamais si un utilisateur existe ou non pour des raisons de sécurité.
* Les utilisateurs sont isolés par tenant (établissement).
* This provider bridges Symfony Security with our Domain Layer.
* It never reveals whether a user exists or not for security reasons.
* Users are isolated by tenant (school).
*
* @implements UserProviderInterface<SecurityUser>
*
* @see Story 1.4 - Connexion utilisateur (AC2: pas de révélation d'existence du compte)
* @see Story 1.4 - User login (AC2: no account existence disclosure)
*/
final readonly class DatabaseUserProvider implements UserProviderInterface
{
@@ -50,12 +50,12 @@ final readonly class DatabaseUserProvider implements UserProviderInterface
$user = $this->userRepository->findByEmail($email, $tenantId);
// Message générique pour ne pas révéler l'existence du compte
// Generic message to not reveal account existence
if ($user === null) {
throw new SymfonyUserNotFoundException();
}
// Ne pas permettre la connexion si le compte n'est pas actif
// Do not allow login if the account is not active
if (!$user->peutSeConnecter()) {
throw new SymfonyUserNotFoundException();
}

View File

@@ -7,15 +7,15 @@ namespace App\Administration\Infrastructure\Security;
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTCreatedEvent;
/**
* Enrichit le payload JWT avec les claims métier.
* Enriches the JWT payload with business claims.
*
* Claims ajoutés:
* - sub: Email de l'utilisateur (identifiant Symfony Security)
* - user_id: UUID de l'utilisateur (pour les consommateurs d'API)
* - tenant_id: UUID du tenant pour l'isolation multi-tenant
* - roles: Liste des rôles Symfony pour l'autorisation
* Added claims:
* - sub: User email (Symfony Security identifier)
* - user_id: User UUID (for API consumers)
* - tenant_id: Tenant UUID for multi-tenant isolation
* - roles: List of Symfony roles for authorization
*
* @see Story 1.4 - Connexion utilisateur
* @see Story 1.4 - User login
*/
final readonly class JwtPayloadEnricher
{
@@ -29,7 +29,7 @@ final readonly class JwtPayloadEnricher
$payload = $event->getData();
// Claims métier pour l'isolation multi-tenant et l'autorisation
// Business claims for multi-tenant isolation and authorization
$payload['user_id'] = $user->userId();
$payload['tenant_id'] = $user->tenantId();
$payload['roles'] = $user->getRoles();

View File

@@ -22,11 +22,11 @@ use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface;
/**
* Gère les échecs de login : rate limiting Fibonacci, audit, messages user-friendly.
* Handles login failures: Fibonacci rate limiting, audit, user-friendly messages.
*
* Important: Ne jamais révéler si l'email existe ou non (AC2).
* Important: Never reveal whether the email exists or not (AC2).
*
* @see Story 1.4 - T5: Endpoint Login Backend
* @see Story 1.4 - T5: Backend Login Endpoint
*/
final readonly class LoginFailureHandler implements AuthenticationFailureHandlerInterface
{
@@ -46,10 +46,10 @@ final readonly class LoginFailureHandler implements AuthenticationFailureHandler
$ipAddress = $request->getClientIp() ?? 'unknown';
$userAgent = $request->headers->get('User-Agent', 'unknown');
// Enregistrer l'échec et obtenir le nouvel état
// Record the failure and get the new state
$result = $this->rateLimiter->recordFailure($request, $email);
// Émettre l'événement d'échec
// Dispatch the failure event
$this->eventBus->dispatch(new ConnexionEchouee(
email: $email,
ipAddress: $ipAddress,
@@ -58,7 +58,7 @@ final readonly class LoginFailureHandler implements AuthenticationFailureHandler
occurredOn: $this->clock->now(),
));
// Si l'IP vient d'être bloquée
// If the IP was just blocked
if ($result->ipBlocked) {
$this->eventBus->dispatch(new CompteBloqueTemporairement(
email: $email,
@@ -72,7 +72,7 @@ final readonly class LoginFailureHandler implements AuthenticationFailureHandler
return $this->createBlockedResponse($result);
}
// Réponse standard d'échec avec infos sur le délai et CAPTCHA
// Standard failure response with delay and CAPTCHA info
return $this->createFailureResponse($result);
}
@@ -106,13 +106,13 @@ final readonly class LoginFailureHandler implements AuthenticationFailureHandler
'attempts' => $result->attempts,
];
// Ajouter le délai si applicable
// Add delay if applicable
if ($result->delaySeconds > 0) {
$data['delay'] = $result->delaySeconds;
$data['delayFormatted'] = $result->getFormattedDelay();
}
// Indiquer si CAPTCHA requis pour la prochaine tentative
// Indicate if CAPTCHA is required for the next attempt
if ($result->requiresCaptcha) {
$data['captchaRequired'] = true;
}

View File

@@ -17,9 +17,9 @@ use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Messenger\MessageBusInterface;
/**
* Gère les actions post-login réussi : refresh token, reset rate limit, audit.
* Handles post-login success actions: refresh token, reset rate limit, audit.
*
* @see Story 1.4 - T5: Endpoint Login Backend
* @see Story 1.4 - T5: Backend Login Endpoint
*/
final readonly class LoginSuccessHandler
{
@@ -48,13 +48,13 @@ final readonly class LoginSuccessHandler
$ipAddress = $request->getClientIp() ?? 'unknown';
$userAgent = $request->headers->get('User-Agent', 'unknown');
// Créer le device fingerprint
// Create the device fingerprint
$fingerprint = DeviceFingerprint::fromRequest($userAgent, $ipAddress);
// Détecter si c'est un mobile (pour le TTL du refresh token)
// Detect if this is a mobile device (for refresh token TTL)
$isMobile = str_contains(strtolower($userAgent), 'mobile');
// Créer le refresh token
// Create the refresh token
$refreshToken = $this->refreshTokenManager->create(
$userId,
$tenantId,
@@ -62,7 +62,7 @@ final readonly class LoginSuccessHandler
$isMobile,
);
// Ajouter le refresh token en cookie HttpOnly
// Add the refresh token as HttpOnly cookie
$cookie = Cookie::create('refresh_token')
->withValue($refreshToken->toTokenString())
->withExpires($refreshToken->expiresAt)
@@ -73,10 +73,10 @@ final readonly class LoginSuccessHandler
$response->headers->setCookie($cookie);
// Reset le rate limiter pour cet email
// Reset the rate limiter for this email
$this->rateLimiter->reset($email);
// Émettre l'événement de connexion réussie
// Dispatch the successful login event
$this->eventBus->dispatch(new ConnexionReussie(
userId: $user->userId(),
email: $email,

View File

@@ -10,12 +10,12 @@ use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* Adapter entre le Domain User et Symfony Security.
* Adapter between the Domain User and Symfony Security.
*
* Ce DTO est utilisé par le système d'authentification Symfony.
* Il ne contient pas de logique métier - c'est un simple transporteur de données.
* This DTO is used by the Symfony authentication system.
* It contains no business logic - it's a simple data carrier.
*
* @see Story 1.4 - Connexion utilisateur
* @see Story 1.4 - User login
*/
final readonly class SecurityUser implements UserInterface, PasswordAuthenticatedUserInterface
{
@@ -24,7 +24,7 @@ final readonly class SecurityUser implements UserInterface, PasswordAuthenticate
/**
* @param non-empty-string $email
* @param list<string> $roles Les rôles Symfony (ROLE_*)
* @param list<string> $roles Symfony roles (ROLE_*)
*/
public function __construct(
private UserId $userId,
@@ -74,6 +74,6 @@ final readonly class SecurityUser implements UserInterface, PasswordAuthenticate
public function eraseCredentials(): void
{
// Rien à effacer, les données sont immutables
// Nothing to erase, data is immutable
}
}