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

@@ -64,12 +64,12 @@ final class ActivationTokenTest extends TestCase
}
#[Test]
public function tokenValueIsUuidV4Format(): void
public function tokenValueIsUuidV7Format(): void
{
$token = $this->createToken();
self::assertMatchesRegularExpression(
'/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i',
'/^[a-f0-9]{8}-[a-f0-9]{4}-7[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i',
$token->tokenValue,
);
}

View File

@@ -0,0 +1,293 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\PasswordResetToken;
use App\Administration\Domain\Event\PasswordResetTokenGenerated;
use App\Administration\Domain\Event\PasswordResetTokenUsed;
use App\Administration\Domain\Exception\PasswordResetTokenAlreadyUsedException;
use App\Administration\Domain\Exception\PasswordResetTokenExpiredException;
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetToken;
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetTokenId;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class PasswordResetTokenTest extends TestCase
{
private TenantId $tenantId;
private DateTimeImmutable $now;
protected function setUp(): void
{
$this->tenantId = TenantId::generate();
$this->now = new DateTimeImmutable('2026-01-28 10:00:00');
}
#[Test]
public function itGeneratesANewToken(): void
{
$token = PasswordResetToken::generate(
userId: 'user-123',
email: 'user@example.com',
tenantId: $this->tenantId,
createdAt: $this->now,
);
self::assertInstanceOf(PasswordResetTokenId::class, $token->id);
self::assertNotEmpty($token->tokenValue);
self::assertSame('user-123', $token->userId);
self::assertSame('user@example.com', $token->email);
self::assertTrue($token->tenantId->equals($this->tenantId));
self::assertSame($this->now, $token->createdAt);
self::assertNull($token->usedAt);
}
#[Test]
public function itExpiresAfterOneHour(): void
{
$token = PasswordResetToken::generate(
userId: 'user-123',
email: 'user@example.com',
tenantId: $this->tenantId,
createdAt: $this->now,
);
$expectedExpiresAt = $this->now->modify('+1 hour');
self::assertEquals($expectedExpiresAt, $token->expiresAt);
}
#[Test]
public function itIsNotExpiredBeforeOneHour(): void
{
$token = PasswordResetToken::generate(
userId: 'user-123',
email: 'user@example.com',
tenantId: $this->tenantId,
createdAt: $this->now,
);
$fiftyNineMinutesLater = $this->now->modify('+59 minutes');
self::assertFalse($token->isExpired($fiftyNineMinutesLater));
}
#[Test]
public function itIsExpiredAfterOneHour(): void
{
$token = PasswordResetToken::generate(
userId: 'user-123',
email: 'user@example.com',
tenantId: $this->tenantId,
createdAt: $this->now,
);
$oneHourLater = $this->now->modify('+1 hour');
self::assertTrue($token->isExpired($oneHourLater));
}
#[Test]
public function itRecordsGenerationEvent(): void
{
$token = PasswordResetToken::generate(
userId: 'user-123',
email: 'user@example.com',
tenantId: $this->tenantId,
createdAt: $this->now,
);
$events = $token->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(PasswordResetTokenGenerated::class, $events[0]);
/** @var PasswordResetTokenGenerated $event */
$event = $events[0];
self::assertTrue($event->tokenId->equals($token->id));
self::assertSame('user-123', $event->userId);
self::assertSame('user@example.com', $event->email);
self::assertTrue($event->tenantId->equals($this->tenantId));
}
#[Test]
public function itCanBeUsedWhenValid(): void
{
$token = PasswordResetToken::generate(
userId: 'user-123',
email: 'user@example.com',
tenantId: $this->tenantId,
createdAt: $this->now,
);
$token->pullDomainEvents(); // Clear generation event
$useAt = $this->now->modify('+30 minutes');
$token->use($useAt);
self::assertSame($useAt, $token->usedAt);
self::assertTrue($token->isUsed());
}
#[Test]
public function itRecordsUsedEventWhenConsumed(): void
{
$token = PasswordResetToken::generate(
userId: 'user-123',
email: 'user@example.com',
tenantId: $this->tenantId,
createdAt: $this->now,
);
$token->pullDomainEvents(); // Clear generation event
$useAt = $this->now->modify('+30 minutes');
$token->use($useAt);
$events = $token->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(PasswordResetTokenUsed::class, $events[0]);
/** @var PasswordResetTokenUsed $event */
$event = $events[0];
self::assertTrue($event->tokenId->equals($token->id));
self::assertSame('user-123', $event->userId);
}
#[Test]
public function itCannotBeUsedTwice(): void
{
$token = PasswordResetToken::generate(
userId: 'user-123',
email: 'user@example.com',
tenantId: $this->tenantId,
createdAt: $this->now,
);
$useAt = $this->now->modify('+30 minutes');
$token->use($useAt);
$this->expectException(PasswordResetTokenAlreadyUsedException::class);
$token->use($useAt->modify('+1 minute'));
}
#[Test]
public function itCannotBeUsedWhenExpired(): void
{
$token = PasswordResetToken::generate(
userId: 'user-123',
email: 'user@example.com',
tenantId: $this->tenantId,
createdAt: $this->now,
);
$this->expectException(PasswordResetTokenExpiredException::class);
$twoHoursLater = $this->now->modify('+2 hours');
$token->use($twoHoursLater);
}
#[Test]
public function itValidatesForUseWithoutConsuming(): void
{
$token = PasswordResetToken::generate(
userId: 'user-123',
email: 'user@example.com',
tenantId: $this->tenantId,
createdAt: $this->now,
);
$thirtyMinutesLater = $this->now->modify('+30 minutes');
// Should not throw
$token->validateForUse($thirtyMinutesLater);
// Token should NOT be marked as used
self::assertFalse($token->isUsed());
self::assertNull($token->usedAt);
}
#[Test]
public function validateForUseThrowsWhenExpired(): void
{
$token = PasswordResetToken::generate(
userId: 'user-123',
email: 'user@example.com',
tenantId: $this->tenantId,
createdAt: $this->now,
);
$this->expectException(PasswordResetTokenExpiredException::class);
$twoHoursLater = $this->now->modify('+2 hours');
$token->validateForUse($twoHoursLater);
}
#[Test]
public function validateForUseThrowsWhenAlreadyUsed(): void
{
$token = PasswordResetToken::generate(
userId: 'user-123',
email: 'user@example.com',
tenantId: $this->tenantId,
createdAt: $this->now,
);
$token->use($this->now->modify('+30 minutes'));
$this->expectException(PasswordResetTokenAlreadyUsedException::class);
$token->validateForUse($this->now->modify('+31 minutes'));
}
#[Test]
public function itCanBeReconstitutedFromStorage(): void
{
$id = PasswordResetTokenId::generate();
$tokenValue = 'abc-123-def-456';
$expiresAt = $this->now->modify('+1 hour');
$usedAt = $this->now->modify('+30 minutes');
$token = PasswordResetToken::reconstitute(
id: $id,
tokenValue: $tokenValue,
userId: 'user-123',
email: 'user@example.com',
tenantId: $this->tenantId,
createdAt: $this->now,
expiresAt: $expiresAt,
usedAt: $usedAt,
);
self::assertTrue($token->id->equals($id));
self::assertSame($tokenValue, $token->tokenValue);
self::assertSame('user-123', $token->userId);
self::assertSame('user@example.com', $token->email);
self::assertTrue($token->tenantId->equals($this->tenantId));
self::assertSame($this->now, $token->createdAt);
self::assertEquals($expiresAt, $token->expiresAt);
self::assertSame($usedAt, $token->usedAt);
self::assertTrue($token->isUsed());
}
#[Test]
public function reconstituteDoesNotRecordEvents(): void
{
$token = PasswordResetToken::reconstitute(
id: PasswordResetTokenId::generate(),
tokenValue: 'abc-123',
userId: 'user-123',
email: 'user@example.com',
tenantId: $this->tenantId,
createdAt: $this->now,
expiresAt: $this->now->modify('+1 hour'),
usedAt: null,
);
$events = $token->pullDomainEvents();
self::assertCount(0, $events);
}
}