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