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