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

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\RequestPasswordReset;
use App\Shared\Domain\Tenant\TenantId;
/**
* Command to request a password reset.
*
* This command is dispatched when a user submits the "forgot password" form.
* The handler will generate a reset token and trigger the email sending.
*
* Security: The handler always returns success, even if the email doesn't exist,
* to prevent email enumeration attacks.
*/
final readonly class RequestPasswordResetCommand
{
public function __construct(
public string $email,
public TenantId $tenantId,
) {
}
}

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\RequestPasswordReset;
use App\Administration\Domain\Event\PasswordResetTokenGenerated;
use App\Administration\Domain\Exception\EmailInvalideException;
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetToken;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Repository\PasswordResetTokenRepository;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Clock;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;
/**
* Handles password reset requests.
*
* Security principles:
* - Never reveals if email exists (prevents enumeration)
* - Reuses existing valid token if one exists (prevents token flooding)
* - Rate limited at API level (3 requests/hour per IP)
*/
#[AsMessageHandler(bus: 'command.bus')]
final readonly class RequestPasswordResetHandler
{
public function __construct(
private UserRepository $userRepository,
private PasswordResetTokenRepository $tokenRepository,
private Clock $clock,
private MessageBusInterface $eventBus,
) {
}
/**
* Process password reset request.
*
* Always succeeds from the caller's perspective.
* Only generates token and dispatches events if user exists.
*
* Security: performs similar work for non-existent emails to prevent timing attacks.
*/
public function __invoke(RequestPasswordResetCommand $command): void
{
// Try to parse email - silently return on invalid email (no enumeration)
try {
$email = new Email($command->email);
} catch (EmailInvalideException) {
// Perform dummy work to match timing of valid email path
$this->simulateTokenLookup();
return;
}
// Try to find user by email for this tenant
$user = $this->userRepository->findByEmail($email, $command->tenantId);
// If user doesn't exist, silently return (no email enumeration)
if ($user === null) {
// Perform dummy work to match timing of valid email path
// This prevents timing attacks that could reveal email existence
$this->simulateTokenLookup();
return;
}
// Check if a valid token already exists for this user
$existingToken = $this->tokenRepository->findValidTokenForUser((string) $user->id);
if ($existingToken !== null) {
// Reuse existing token - manually dispatch event to resend email
// (reconstituted tokens don't have domain events)
$this->eventBus->dispatch(new PasswordResetTokenGenerated(
tokenId: $existingToken->id,
tokenValue: $existingToken->tokenValue,
userId: $existingToken->userId,
email: $existingToken->email,
tenantId: $existingToken->tenantId,
occurredOn: $this->clock->now(),
));
return;
}
// Generate new reset token
$now = $this->clock->now();
$token = PasswordResetToken::generate(
userId: (string) $user->id,
email: $command->email,
tenantId: $command->tenantId,
createdAt: $now,
);
// Persist token
$this->tokenRepository->save($token);
// Dispatch domain events (triggers email sending)
foreach ($token->pullDomainEvents() as $event) {
$this->eventBus->dispatch($event);
}
}
/**
* Performs dummy work to match timing of valid email path.
*
* This prevents timing attacks that could reveal email existence by ensuring
* similar processing time regardless of whether the email exists or not.
*/
private function simulateTokenLookup(): void
{
// Generate a fake userId to query (will not exist)
// This matches the timing of findValidTokenForUser() call
$fakeUserId = bin2hex(random_bytes(16));
$this->tokenRepository->findValidTokenForUser($fakeUserId);
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\ResetPassword;
/**
* Command to reset a user's password using a valid reset token.
*/
final readonly class ResetPasswordCommand
{
public function __construct(
public string $token,
public string $newPassword,
) {
}
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\ResetPassword;
use App\Administration\Application\Port\PasswordHasher;
use App\Administration\Domain\Exception\PasswordResetTokenAlreadyUsedException;
use App\Administration\Domain\Exception\PasswordResetTokenExpiredException;
use App\Administration\Domain\Exception\PasswordResetTokenNotFoundException;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\PasswordResetTokenRepository;
use App\Administration\Domain\Repository\RefreshTokenRepository;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Clock;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;
/**
* Handles password reset using a valid token.
*
* Security:
* - Validates token is not expired
* - Validates token is not already used
* - Marks token as used after successful reset
* - Invalidates all user sessions (force re-authentication)
* - Dispatches events for audit and confirmation email
*/
#[AsMessageHandler(bus: 'command.bus')]
final readonly class ResetPasswordHandler
{
public function __construct(
private PasswordResetTokenRepository $tokenRepository,
private UserRepository $userRepository,
private RefreshTokenRepository $refreshTokenRepository,
private PasswordHasher $passwordHasher,
private Clock $clock,
private MessageBusInterface $eventBus,
) {
}
/**
* Process password reset.
*
* @throws PasswordResetTokenNotFoundException if token doesn't exist
* @throws PasswordResetTokenExpiredException if token has expired
* @throws PasswordResetTokenAlreadyUsedException if token was already used
*/
public function __invoke(ResetPasswordCommand $command): void
{
$now = $this->clock->now();
// Atomically consume token (validates + marks as used in one operation)
// This prevents race conditions where two concurrent requests could both
// pass validation before either marks the token as used
$token = $this->tokenRepository->consumeIfValid($command->token, $now);
// Find user
$userId = UserId::fromString($token->userId);
$user = $this->userRepository->get($userId);
// Hash and update password
$hashedPassword = $this->passwordHasher->hash($command->newPassword);
$user->changerMotDePasse($hashedPassword, $now);
// Save user changes
$this->userRepository->save($user);
// Invalidate all user sessions (force re-authentication on all devices)
$this->refreshTokenRepository->invalidateAllForUser($userId);
// Note: We intentionally keep the used token in storage (until TTL expiry)
// This allows distinguishing "already used" (410) from "invalid" (400)
// when the same link is submitted again
// Dispatch domain events from user (MotDePasseChange)
foreach ($user->pullDomainEvents() as $event) {
$this->eventBus->dispatch($event);
}
// Dispatch domain events from token (PasswordResetTokenUsed)
foreach ($token->pullDomainEvents() as $event) {
$this->eventBus->dispatch($event);
}
}
}

View File

@@ -16,19 +16,19 @@ use App\Shared\Domain\Tenant\TenantId;
use InvalidArgumentException;
/**
* Gère le cycle de vie des refresh tokens.
* Manages the lifecycle of refresh tokens.
*
* Responsabilités :
* - Création de tokens pour nouvelles sessions
* - Rotation des tokens avec détection de replay
* - Invalidation de familles de tokens compromises
* Responsibilities:
* - Token creation for new sessions
* - Token rotation with replay detection
* - Invalidation of compromised token families
*
* @see Story 1.4 - Connexion utilisateur
* @see Story 1.4 - User login
*/
final readonly class RefreshTokenManager
{
private const int WEB_TTL_SECONDS = 86400; // 1 jour pour web
private const int MOBILE_TTL_SECONDS = 604800; // 7 jours pour mobile
private const int WEB_TTL_SECONDS = 86400; // 1 day for web
private const int MOBILE_TTL_SECONDS = 604800; // 7 days for mobile
public function __construct(
private RefreshTokenRepository $repository,
@@ -37,7 +37,7 @@ final readonly class RefreshTokenManager
}
/**
* Crée un nouveau refresh token pour une session.
* Creates a new refresh token for a session.
*/
public function create(
UserId $userId,
@@ -47,7 +47,7 @@ final readonly class RefreshTokenManager
): RefreshToken {
$ttl = $isMobile ? self::MOBILE_TTL_SECONDS : self::WEB_TTL_SECONDS;
// Ajouter un jitter de ±10% pour éviter les expirations simultanées
// Add ±10% jitter to avoid simultaneous expirations
$jitter = (int) ($ttl * 0.1 * (random_int(-100, 100) / 100));
$ttl += $jitter;
@@ -65,13 +65,13 @@ final readonly class RefreshTokenManager
}
/**
* Valide et rafraîchit un token.
* Validates and refreshes a token.
*
* @throws TokenReplayDetectedException si un replay attack est détecté
* @throws TokenAlreadyRotatedException si le token a déjà été rotaté mais est en grace period
* @throws InvalidArgumentException si le token est invalide ou expiré
* @throws TokenReplayDetectedException if a replay attack is detected
* @throws TokenAlreadyRotatedException if the token has already been rotated but is in grace period
* @throws InvalidArgumentException if the token is invalid or expired
*
* @return RefreshToken le nouveau token après rotation
* @return RefreshToken the new token after rotation
*/
public function refresh(
string $tokenString,
@@ -85,53 +85,53 @@ final readonly class RefreshTokenManager
throw new InvalidArgumentException('Token not found');
}
// Vérifier l'expiration
// Check expiration
if ($token->isExpired($now)) {
$this->repository->delete($tokenId);
throw new InvalidArgumentException('Token expired');
}
// Vérifier le device fingerprint
// Check device fingerprint
if (!$token->matchesDevice($deviceFingerprint)) {
// Potentielle tentative de vol de token - invalider toute la famille
// Potential token theft attempt - invalidate the entire family
$this->repository->invalidateFamily($token->familyId);
throw new TokenReplayDetectedException($token->familyId);
}
// Détecter les replay attacks
// Detect replay attacks
if ($token->isRotated) {
// Token déjà utilisé !
// Token already used!
if ($token->isInGracePeriod($now)) {
// Dans la grace period - probablement une race condition légitime
// On laisse passer mais on ne génère pas de nouveau token
// Le client devrait utiliser le token le plus récent
// Exception dédiée pour ne PAS supprimer le cookie lors d'une race condition légitime
// In grace period - probably a legitimate race condition
// We let it pass but don't generate a new token
// The client should use the most recent token
// Dedicated exception to NOT delete the cookie during a legitimate race condition
throw new TokenAlreadyRotatedException();
}
// Replay attack confirmé - invalider toute la famille
// Confirmed replay attack - invalidate the entire family
$this->repository->invalidateFamily($token->familyId);
throw new TokenReplayDetectedException($token->familyId);
}
// Rotation du token (préserve le TTL original)
// Rotate the token (preserves original TTL)
[$newToken, $rotatedOldToken] = $token->rotate($now);
// Sauvegarder le nouveau token EN PREMIER
// Important: sauvegarder le nouveau token EN PREMIER pour que l'index famille garde le bon TTL
// Save the new token FIRST
// Important: save the new token FIRST so the family index keeps the correct TTL
$this->repository->save($newToken);
// Mettre à jour l'ancien token comme rotaté (pour grace period)
// Update the old token as rotated (for grace period)
$this->repository->save($rotatedOldToken);
return $newToken;
}
/**
* Révoque un token (déconnexion).
* Revokes a token (logout).
*/
public function revoke(string $tokenString): void
{
@@ -140,18 +140,18 @@ final readonly class RefreshTokenManager
$token = $this->repository->find($tokenId);
if ($token !== null) {
// Invalider toute la famille pour une déconnexion complète
// Invalidate the entire family for a complete logout
$this->repository->invalidateFamily($token->familyId);
}
} catch (InvalidArgumentException) {
// Token invalide, rien à faire
// Invalid token, nothing to do
}
}
/**
* Invalide toute une famille de tokens.
* Invalidates an entire token family.
*
* Utilisé quand un utilisateur est suspendu/archivé pour révoquer toutes ses sessions.
* Used when a user is suspended/archived to revoke all their sessions.
*/
public function invalidateFamily(TokenFamilyId $familyId): void
{