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