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,320 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Command\ResetPassword;
use App\Administration\Application\Command\ResetPassword\ResetPasswordCommand;
use App\Administration\Application\Command\ResetPassword\ResetPasswordHandler;
use App\Administration\Application\Port\PasswordHasher;
use App\Administration\Domain\Event\MotDePasseChange;
use App\Administration\Domain\Event\PasswordResetTokenUsed;
use App\Administration\Domain\Exception\PasswordResetTokenAlreadyUsedException;
use App\Administration\Domain\Exception\PasswordResetTokenExpiredException;
use App\Administration\Domain\Exception\PasswordResetTokenNotFoundException;
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetToken;
use App\Administration\Domain\Model\RefreshToken\DeviceFingerprint;
use App\Administration\Domain\Model\RefreshToken\RefreshToken;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\User;
use App\Administration\Domain\Policy\ConsentementParentalPolicy;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryPasswordResetTokenRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryRefreshTokenRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
final class ResetPasswordHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private const string EMAIL = 'user@example.com';
private const string NEW_PASSWORD = 'NewSecurePassword123!';
public const string HASHED_PASSWORD = '$argon2id$newhashedpassword';
private InMemoryUserRepository $userRepository;
private InMemoryPasswordResetTokenRepository $tokenRepository;
private InMemoryRefreshTokenRepository $refreshTokenRepository;
private Clock $clock;
/** @var DomainEvent[] */
private array $dispatchedEvents = [];
private ResetPasswordHandler $handler;
private TenantId $tenantId;
private PasswordHasher $passwordHasher;
protected function setUp(): void
{
$this->userRepository = new InMemoryUserRepository();
$this->clock = new class implements Clock {
public DateTimeImmutable $now;
public function __construct()
{
$this->now = new DateTimeImmutable('2026-01-28 10:00:00');
}
#[Override]
public function now(): DateTimeImmutable
{
return $this->now;
}
};
$this->tokenRepository = new InMemoryPasswordResetTokenRepository($this->clock);
$this->refreshTokenRepository = new InMemoryRefreshTokenRepository();
$this->passwordHasher = new class implements PasswordHasher {
#[Override]
public function hash(string $plainPassword): string
{
return ResetPasswordHandlerTest::HASHED_PASSWORD;
}
#[Override]
public function verify(string $hashedPassword, string $plainPassword): bool
{
return true;
}
};
$this->dispatchedEvents = [];
$eventBus = new class($this->dispatchedEvents) implements MessageBusInterface {
/** @param DomainEvent[] $events */
public function __construct(private array &$events)
{
}
#[Override]
public function dispatch(object $message, array $stamps = []): Envelope
{
$this->events[] = $message;
return new Envelope($message);
}
};
$this->tenantId = TenantId::fromString(self::TENANT_ID);
$this->handler = new ResetPasswordHandler(
$this->tokenRepository,
$this->userRepository,
$this->refreshTokenRepository,
$this->passwordHasher,
$this->clock,
$eventBus,
);
}
#[Test]
public function itResetsPasswordWithValidToken(): void
{
$user = $this->createAndSaveUser(self::EMAIL);
$token = $this->createAndSaveToken($user);
$command = new ResetPasswordCommand(
token: $token->tokenValue,
newPassword: self::NEW_PASSWORD,
);
($this->handler)($command);
// Verify password was updated
$updatedUser = $this->userRepository->get($user->id);
self::assertSame(self::HASHED_PASSWORD, $updatedUser->hashedPassword);
}
#[Test]
public function itDispatchesPasswordChangedEvent(): void
{
$user = $this->createAndSaveUser(self::EMAIL);
$token = $this->createAndSaveToken($user);
$command = new ResetPasswordCommand(
token: $token->tokenValue,
newPassword: self::NEW_PASSWORD,
);
($this->handler)($command);
// Should have MotDePasseChange and PasswordResetTokenUsed events
$passwordChangedEvents = array_filter(
$this->dispatchedEvents,
static fn ($e) => $e instanceof MotDePasseChange
);
self::assertCount(1, $passwordChangedEvents);
}
#[Test]
public function itDispatchesTokenUsedEvent(): void
{
$user = $this->createAndSaveUser(self::EMAIL);
$token = $this->createAndSaveToken($user);
$command = new ResetPasswordCommand(
token: $token->tokenValue,
newPassword: self::NEW_PASSWORD,
);
($this->handler)($command);
$tokenUsedEvents = array_filter(
$this->dispatchedEvents,
static fn ($e) => $e instanceof PasswordResetTokenUsed
);
self::assertCount(1, $tokenUsedEvents);
}
#[Test]
public function itThrowsWhenTokenNotFound(): void
{
$command = new ResetPasswordCommand(
token: 'nonexistent-token',
newPassword: self::NEW_PASSWORD,
);
$this->expectException(PasswordResetTokenNotFoundException::class);
($this->handler)($command);
}
#[Test]
public function itThrowsWhenTokenExpired(): void
{
$user = $this->createAndSaveUser(self::EMAIL);
// Create an expired token (2 hours ago)
$token = PasswordResetToken::generate(
userId: (string) $user->id,
email: self::EMAIL,
tenantId: $this->tenantId,
createdAt: new DateTimeImmutable('2026-01-28 07:00:00'), // 3 hours ago
);
$this->tokenRepository->save($token);
$command = new ResetPasswordCommand(
token: $token->tokenValue,
newPassword: self::NEW_PASSWORD,
);
$this->expectException(PasswordResetTokenExpiredException::class);
($this->handler)($command);
}
#[Test]
public function itThrowsWhenTokenAlreadyUsed(): void
{
$user = $this->createAndSaveUser(self::EMAIL);
$token = $this->createAndSaveToken($user);
$command = new ResetPasswordCommand(
token: $token->tokenValue,
newPassword: self::NEW_PASSWORD,
);
// First use succeeds
($this->handler)($command);
// Second attempt with same token should fail with "already used"
// (token remains in storage until TTL expiry to preserve this error semantic)
$this->expectException(PasswordResetTokenAlreadyUsedException::class);
($this->handler)($command);
}
#[Test]
public function itKeepsUsedTokenInStorageToPreserveErrorSemantics(): void
{
$user = $this->createAndSaveUser(self::EMAIL);
$token = $this->createAndSaveToken($user);
$tokenValue = $token->tokenValue;
$command = new ResetPasswordCommand(
token: $tokenValue,
newPassword: self::NEW_PASSWORD,
);
($this->handler)($command);
// Token should remain in storage (marked as used) until TTL expiry
// This allows distinguishing "already used" (410) from "invalid" (400)
$foundToken = $this->tokenRepository->findByTokenValue($tokenValue);
self::assertNotNull($foundToken);
self::assertTrue($foundToken->isUsed());
}
#[Test]
public function itInvalidatesAllUserSessionsAfterPasswordReset(): void
{
$user = $this->createAndSaveUser(self::EMAIL);
$token = $this->createAndSaveToken($user);
// Create active refresh tokens for the user (simulating active sessions)
$refreshToken = RefreshToken::create(
userId: $user->id,
tenantId: $this->tenantId,
deviceFingerprint: DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1'),
issuedAt: $this->clock->now(),
);
$this->refreshTokenRepository->save($refreshToken);
// Verify user has active sessions before reset
self::assertTrue($this->refreshTokenRepository->hasActiveSessionsForUser($user->id));
$command = new ResetPasswordCommand(
token: $token->tokenValue,
newPassword: self::NEW_PASSWORD,
);
($this->handler)($command);
// All sessions should be invalidated after password reset (AC3)
self::assertFalse($this->refreshTokenRepository->hasActiveSessionsForUser($user->id));
}
private function createAndSaveUser(string $email): User
{
$user = User::creer(
email: new Email($email),
role: Role::PROF,
tenantId: $this->tenantId,
schoolName: 'École Test',
dateNaissance: new DateTimeImmutable('1990-01-01'),
createdAt: $this->clock->now(),
);
// Activate user
$consentementPolicy = new ConsentementParentalPolicy($this->clock);
$user->activer(
hashedPassword: '$argon2id$oldhashed',
at: $this->clock->now(),
consentementPolicy: $consentementPolicy,
);
$this->userRepository->save($user);
return $user;
}
private function createAndSaveToken(User $user): PasswordResetToken
{
$token = PasswordResetToken::generate(
userId: (string) $user->id,
email: self::EMAIL,
tenantId: $this->tenantId,
createdAt: $this->clock->now(),
);
// Drain events to avoid counting generation event
$token->pullDomainEvents();
$this->tokenRepository->save($token);
return $token;
}
}