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:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user