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,140 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\InMemory;
use App\Administration\Domain\Exception\PasswordResetTokenNotFoundException;
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetToken;
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetTokenId;
use App\Administration\Domain\Repository\PasswordResetTokenRepository;
use App\Shared\Domain\Clock;
use DateTimeImmutable;
use Override;
final class InMemoryPasswordResetTokenRepository implements PasswordResetTokenRepository
{
/** @var array<string, PasswordResetToken> Indexed by token value */
private array $byTokenValue = [];
/** @var array<string, string> Maps ID to token value */
private array $idToTokenValue = [];
/** @var array<string, string> Maps user ID to token value */
private array $userIdToTokenValue = [];
public function __construct(
private ?Clock $clock = null,
) {
}
#[Override]
public function save(PasswordResetToken $token): void
{
$this->byTokenValue[$token->tokenValue] = $token;
$this->idToTokenValue[(string) $token->id] = $token->tokenValue;
$this->userIdToTokenValue[$token->userId] = $token->tokenValue;
}
#[Override]
public function findByTokenValue(string $tokenValue): ?PasswordResetToken
{
return $this->byTokenValue[$tokenValue] ?? null;
}
#[Override]
public function getByTokenValue(string $tokenValue): PasswordResetToken
{
$token = $this->findByTokenValue($tokenValue);
if ($token === null) {
throw PasswordResetTokenNotFoundException::withTokenValue($tokenValue);
}
return $token;
}
#[Override]
public function get(PasswordResetTokenId $id): PasswordResetToken
{
$tokenValue = $this->idToTokenValue[(string) $id] ?? null;
if ($tokenValue === null) {
throw PasswordResetTokenNotFoundException::withId($id);
}
$token = $this->byTokenValue[$tokenValue] ?? null;
if ($token === null) {
throw PasswordResetTokenNotFoundException::withId($id);
}
return $token;
}
#[Override]
public function delete(PasswordResetTokenId $id): void
{
$tokenValue = $this->idToTokenValue[(string) $id] ?? null;
if ($tokenValue !== null) {
$token = $this->byTokenValue[$tokenValue] ?? null;
if ($token !== null) {
unset($this->userIdToTokenValue[$token->userId]);
}
unset($this->byTokenValue[$tokenValue]);
}
unset($this->idToTokenValue[(string) $id]);
}
#[Override]
public function deleteByTokenValue(string $tokenValue): void
{
$token = $this->byTokenValue[$tokenValue] ?? null;
if ($token !== null) {
unset($this->idToTokenValue[(string) $token->id]);
unset($this->userIdToTokenValue[$token->userId]);
}
unset($this->byTokenValue[$tokenValue]);
}
#[Override]
public function findValidTokenForUser(string $userId): ?PasswordResetToken
{
$tokenValue = $this->userIdToTokenValue[$userId] ?? null;
if ($tokenValue === null) {
return null;
}
$token = $this->byTokenValue[$tokenValue] ?? null;
if ($token === null) {
return null;
}
// Check if token is still valid (not used and not expired)
$now = $this->clock?->now() ?? new DateTimeImmutable();
if ($token->isUsed() || $token->isExpired($now)) {
return null;
}
return $token;
}
#[Override]
public function consumeIfValid(string $tokenValue, DateTimeImmutable $at): PasswordResetToken
{
$token = $this->getByTokenValue($tokenValue);
$token->validateForUse($at);
$token->use($at);
$this->save($token);
return $token;
}
}

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\InMemory;
use App\Administration\Domain\Model\RefreshToken\RefreshToken;
use App\Administration\Domain\Model\RefreshToken\RefreshTokenId;
use App\Administration\Domain\Model\RefreshToken\TokenFamilyId;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\RefreshTokenRepository;
use function array_unique;
use function array_values;
use function count;
use Override;
/**
* In-memory implementation of RefreshTokenRepository for testing.
*/
final class InMemoryRefreshTokenRepository implements RefreshTokenRepository
{
/** @var array<string, RefreshToken> Indexed by token ID */
private array $tokens = [];
/** @var array<string, list<string>> Maps family ID to token IDs */
private array $familyIndex = [];
/** @var array<string, list<string>> Maps user ID to family IDs */
private array $userIndex = [];
#[Override]
public function save(RefreshToken $token): void
{
$this->tokens[(string) $token->id] = $token;
// Index by family
$familyId = (string) $token->familyId;
if (!isset($this->familyIndex[$familyId])) {
$this->familyIndex[$familyId] = [];
}
$this->familyIndex[$familyId][] = (string) $token->id;
$this->familyIndex[$familyId] = array_values(array_unique($this->familyIndex[$familyId]));
// Index by user
$userId = (string) $token->userId;
if (!isset($this->userIndex[$userId])) {
$this->userIndex[$userId] = [];
}
$this->userIndex[$userId][] = $familyId;
$this->userIndex[$userId] = array_values(array_unique($this->userIndex[$userId]));
}
#[Override]
public function find(RefreshTokenId $id): ?RefreshToken
{
return $this->tokens[(string) $id] ?? null;
}
#[Override]
public function findByToken(string $tokenValue): ?RefreshToken
{
return $this->find(RefreshTokenId::fromString($tokenValue));
}
#[Override]
public function delete(RefreshTokenId $id): void
{
unset($this->tokens[(string) $id]);
}
#[Override]
public function invalidateFamily(TokenFamilyId $familyId): void
{
$familyIdStr = (string) $familyId;
if (!isset($this->familyIndex[$familyIdStr])) {
return;
}
// Delete all tokens in the family
foreach ($this->familyIndex[$familyIdStr] as $tokenId) {
unset($this->tokens[$tokenId]);
}
// Remove family index
unset($this->familyIndex[$familyIdStr]);
}
#[Override]
public function invalidateAllForUser(UserId $userId): void
{
$userIdStr = (string) $userId;
if (!isset($this->userIndex[$userIdStr])) {
return;
}
// Invalidate all families for this user
foreach ($this->userIndex[$userIdStr] as $familyId) {
$this->invalidateFamily(TokenFamilyId::fromString($familyId));
}
// Remove user index
unset($this->userIndex[$userIdStr]);
}
/**
* Helper method for testing: check if user has any active sessions.
*/
public function hasActiveSessionsForUser(UserId $userId): bool
{
return isset($this->userIndex[(string) $userId]) && count($this->userIndex[(string) $userId]) > 0;
}
}