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