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);
}
}
}