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;
}
}

View File

@@ -0,0 +1,239 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\Redis;
use App\Administration\Domain\Exception\PasswordResetTokenNotFoundException;
use App\Administration\Domain\Exception\TokenConsumptionInProgressException;
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetToken;
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetTokenId;
use App\Administration\Domain\Repository\PasswordResetTokenRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\Lock\LockFactory;
final readonly class RedisPasswordResetTokenRepository implements PasswordResetTokenRepository
{
private const string KEY_PREFIX = 'password_reset:';
/**
* Cache TTL is 2 hours: 1 hour validity + 1 hour grace period.
*
* Keeping tokens longer than their domain expiry allows distinguishing
* "expired" (410) from "invalid/not found" (400) in API responses.
*/
private const int TTL_SECONDS = 60 * 60 * 2; // 2 hours
public function __construct(
private CacheItemPoolInterface $passwordResetTokensCache,
private LockFactory $lockFactory,
) {
}
#[Override]
public function save(PasswordResetToken $token): void
{
// Store by token value for lookup during password reset
$item = $this->passwordResetTokensCache->getItem(self::KEY_PREFIX . $token->tokenValue);
$item->set($this->serialize($token));
$item->expiresAfter(self::TTL_SECONDS);
$this->passwordResetTokensCache->save($item);
// Also store by ID for direct access
$idItem = $this->passwordResetTokensCache->getItem(self::KEY_PREFIX . 'id:' . $token->id);
$idItem->set($token->tokenValue);
$idItem->expiresAfter(self::TTL_SECONDS);
$this->passwordResetTokensCache->save($idItem);
// Store by user_id for lookup of existing tokens
$userItem = $this->passwordResetTokensCache->getItem(self::KEY_PREFIX . 'user:' . $token->userId);
$userItem->set($token->tokenValue);
$userItem->expiresAfter(self::TTL_SECONDS);
$this->passwordResetTokensCache->save($userItem);
}
#[Override]
public function findByTokenValue(string $tokenValue): ?PasswordResetToken
{
$item = $this->passwordResetTokensCache->getItem(self::KEY_PREFIX . $tokenValue);
if (!$item->isHit()) {
return null;
}
/** @var array{id: string, token_value: string, user_id: string, email: string, tenant_id: string, created_at: string, expires_at: string, used_at: string|null} $data */
$data = $item->get();
return $this->deserialize($data);
}
#[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
{
// First get the token value from the ID index
$idItem = $this->passwordResetTokensCache->getItem(self::KEY_PREFIX . 'id:' . $id);
if (!$idItem->isHit()) {
throw PasswordResetTokenNotFoundException::withId($id);
}
/** @var string $tokenValue */
$tokenValue = $idItem->get();
$token = $this->findByTokenValue($tokenValue);
if ($token === null) {
throw PasswordResetTokenNotFoundException::withId($id);
}
return $token;
}
#[Override]
public function delete(PasswordResetTokenId $id): void
{
// Get token first to clean up all indices
$idItem = $this->passwordResetTokensCache->getItem(self::KEY_PREFIX . 'id:' . $id);
if ($idItem->isHit()) {
/** @var string $tokenValue */
$tokenValue = $idItem->get();
$token = $this->findByTokenValue($tokenValue);
if ($token !== null) {
$this->passwordResetTokensCache->deleteItem(self::KEY_PREFIX . 'user:' . $token->userId);
}
$this->passwordResetTokensCache->deleteItem(self::KEY_PREFIX . $tokenValue);
}
$this->passwordResetTokensCache->deleteItem(self::KEY_PREFIX . 'id:' . $id);
}
#[Override]
public function deleteByTokenValue(string $tokenValue): void
{
$token = $this->findByTokenValue($tokenValue);
if ($token !== null) {
$this->passwordResetTokensCache->deleteItem(self::KEY_PREFIX . 'id:' . $token->id);
$this->passwordResetTokensCache->deleteItem(self::KEY_PREFIX . 'user:' . $token->userId);
}
$this->passwordResetTokensCache->deleteItem(self::KEY_PREFIX . $tokenValue);
}
#[Override]
public function findValidTokenForUser(string $userId): ?PasswordResetToken
{
$userItem = $this->passwordResetTokensCache->getItem(self::KEY_PREFIX . 'user:' . $userId);
if (!$userItem->isHit()) {
return null;
}
/** @var string $tokenValue */
$tokenValue = $userItem->get();
$token = $this->findByTokenValue($tokenValue);
if ($token === null) {
return null;
}
// Check if token is still valid (not used and not expired)
if ($token->isUsed() || $token->isExpired(new DateTimeImmutable())) {
return null;
}
return $token;
}
#[Override]
public function consumeIfValid(string $tokenValue, DateTimeImmutable $at): PasswordResetToken
{
// Use Symfony Lock for atomic lock acquisition (Redis SETNX under the hood)
$lock = $this->lockFactory->createLock(
resource: self::KEY_PREFIX . 'lock:' . $tokenValue,
ttl: 30, // 30 seconds max for password hashing + save
autoRelease: true,
);
// Try to acquire lock without blocking
if (!$lock->acquire(blocking: false)) {
// Another request is consuming this token
// Check if the token was already used by the other request
$token = $this->findByTokenValue($tokenValue);
if ($token !== null && $token->isUsed()) {
$token->validateForUse($at); // Will throw AlreadyUsedException
}
// Lock is held but token not yet consumed - client should retry
throw new TokenConsumptionInProgressException($tokenValue);
}
try {
// Get and validate token
$token = $this->getByTokenValue($tokenValue);
$token->validateForUse($at);
// Mark as used
$token->use($at);
// Save the consumed token
$this->save($token);
return $token;
} finally {
// Always release the lock
$lock->release();
}
}
/**
* @return array{id: string, token_value: string, user_id: string, email: string, tenant_id: string, created_at: string, expires_at: string, used_at: string|null}
*/
private function serialize(PasswordResetToken $token): array
{
return [
'id' => (string) $token->id,
'token_value' => $token->tokenValue,
'user_id' => $token->userId,
'email' => $token->email,
'tenant_id' => (string) $token->tenantId,
'created_at' => $token->createdAt->format(DateTimeImmutable::ATOM),
'expires_at' => $token->expiresAt->format(DateTimeImmutable::ATOM),
'used_at' => $token->usedAt?->format(DateTimeImmutable::ATOM),
];
}
/**
* @param array{id: string, token_value: string, user_id: string, email: string, tenant_id: string, created_at: string, expires_at: string, used_at: string|null} $data
*/
private function deserialize(array $data): PasswordResetToken
{
return PasswordResetToken::reconstitute(
id: PasswordResetTokenId::fromString($data['id']),
tokenValue: $data['token_value'],
userId: $data['user_id'],
email: $data['email'],
tenantId: TenantId::fromString($data['tenant_id']),
createdAt: new DateTimeImmutable($data['created_at']),
expiresAt: new DateTimeImmutable($data['expires_at']),
usedAt: $data['used_at'] !== null ? new DateTimeImmutable($data['used_at']) : null,
);
}
}

View File

@@ -14,33 +14,47 @@ use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use DateTimeInterface;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
/**
* Implémentation Redis du repository de refresh tokens.
* Redis implementation of the refresh tokens repository.
*
* Structure de stockage :
* - Token individuel : refresh:{token_id} → données JSON du token
* - Index famille : refresh_family:{family_id} → set des token_ids de la famille
* Storage structure:
* - Individual token: refresh:{token_id} → token JSON data
* - Family index: refresh_family:{family_id} → set of token_ids in the family
* - User index: refresh_user:{user_id} → set of family_ids for the user
*
* @see Story 1.4 - Connexion utilisateur
* @see Story 1.4 - User login
*/
final readonly class RedisRefreshTokenRepository implements RefreshTokenRepository
{
private const string TOKEN_PREFIX = 'refresh:';
private const string FAMILY_PREFIX = 'refresh_family:';
private const string USER_PREFIX = 'refresh_user:';
/**
* Maximum TTL for user index (7 days + 10% jitter margin).
*
* Must be >= the longest possible token TTL to ensure invalidateAllForUser() works correctly.
* RefreshTokenManager applies ±10% jitter, so max token TTL = 604800 * 1.1 = 665280s.
* We use 8 days (691200s) for a safe margin.
*/
private const int MAX_USER_INDEX_TTL = 691200;
public function __construct(
private CacheItemPoolInterface $refreshTokensCache,
private LoggerInterface $logger = new NullLogger(),
) {
}
public function save(RefreshToken $token): void
{
// Sauvegarder le token
// Save the token
$tokenItem = $this->refreshTokensCache->getItem(self::TOKEN_PREFIX . $token->id);
$tokenItem->set($this->serialize($token));
// Calculer le TTL restant
// Calculate remaining TTL
$now = new DateTimeImmutable();
$ttl = $token->expiresAt->getTimestamp() - $now->getTimestamp();
if ($ttl > 0) {
@@ -49,9 +63,9 @@ final readonly class RedisRefreshTokenRepository implements RefreshTokenReposito
$this->refreshTokensCache->save($tokenItem);
// Ajouter à l'index famille
// Ne jamais réduire le TTL de l'index famille
// L'index doit survivre aussi longtemps que le token le plus récent de la famille
// Add to family index
// Never reduce the family index TTL
// The index must survive as long as the most recent token in the family
$familyItem = $this->refreshTokensCache->getItem(self::FAMILY_PREFIX . $token->familyId);
/** @var list<string> $familyTokenIds */
@@ -59,17 +73,32 @@ final readonly class RedisRefreshTokenRepository implements RefreshTokenReposito
$familyTokenIds[] = (string) $token->id;
$familyItem->set(array_unique($familyTokenIds));
// Seulement étendre le TTL, jamais le réduire
// Pour les tokens rotated (ancien), on ne change pas le TTL de l'index
// Only extend TTL, never reduce
// For rotated tokens (old), we don't change the index TTL
if (!$token->isRotated && $ttl > 0) {
$familyItem->expiresAfter($ttl);
} elseif (!$familyItem->isHit()) {
// Nouveau index - définir le TTL initial
// New index - set initial TTL
$familyItem->expiresAfter($ttl > 0 ? $ttl : 604800);
}
// Si c'est un token rotaté et l'index existe déjà, on garde le TTL existant
// If it's a rotated token and the index already exists, keep the existing TTL
$this->refreshTokensCache->save($familyItem);
// Add to user index (for invalidating all sessions)
$userItem = $this->refreshTokensCache->getItem(self::USER_PREFIX . $token->userId);
/** @var list<string> $userFamilyIds */
$userFamilyIds = $userItem->isHit() ? $userItem->get() : [];
$userFamilyIds[] = (string) $token->familyId;
$userItem->set(array_unique($userFamilyIds));
// Always use max TTL for user index to ensure it survives as long as any token
// This prevents invalidateAllForUser() from missing long-lived sessions (e.g., mobile)
// when a shorter-lived session (e.g., web) is created afterwards
$userItem->expiresAfter(self::MAX_USER_INDEX_TTL);
$this->refreshTokensCache->save($userItem);
}
public function find(RefreshTokenId $id): ?RefreshToken
@@ -107,15 +136,56 @@ final readonly class RedisRefreshTokenRepository implements RefreshTokenReposito
/** @var list<string> $tokenIds */
$tokenIds = $familyItem->get();
// Supprimer tous les tokens de la famille
// Delete all tokens in the family
foreach ($tokenIds as $tokenId) {
$this->refreshTokensCache->deleteItem(self::TOKEN_PREFIX . $tokenId);
}
// Supprimer l'index famille
// Delete the family index
$this->refreshTokensCache->deleteItem(self::FAMILY_PREFIX . $familyId);
}
/**
* Invalidates all refresh token sessions for a user.
*
* IMPORTANT: This method relies on the user index (refresh_user:{userId}) which is only
* created when tokens are saved with this repository version. Tokens created before this
* code was deployed will NOT have a user index and will not be invalidated.
*
* For deployments with existing tokens, either:
* - Wait for old tokens to naturally expire (max 7 days)
* - Run a migration script to rebuild user indexes
* - Force all users to re-login after deployment
*/
public function invalidateAllForUser(UserId $userId): void
{
$userItem = $this->refreshTokensCache->getItem(self::USER_PREFIX . $userId);
if (!$userItem->isHit()) {
// User index doesn't exist - this could mean:
// 1. User has no active sessions (normal case)
// 2. User has legacy sessions created before user index was implemented (migration needed)
// Log at info level to help operators identify migration needs
$this->logger->info('No user index found when invalidating sessions. Legacy tokens may exist.', [
'user_id' => (string) $userId,
'action' => 'invalidateAllForUser',
]);
return;
}
/** @var list<string> $familyIds */
$familyIds = $userItem->get();
// Invalidate all token families for the user
foreach ($familyIds as $familyId) {
$this->invalidateFamily(TokenFamilyId::fromString($familyId));
}
// Delete the user index
$this->refreshTokensCache->deleteItem(self::USER_PREFIX . $userId);
}
/**
* @return array<string, mixed>
*/