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\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\Shared\Domain\AggregateRoot;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Ramsey\Uuid\Uuid;
use function sprintf;
final class PasswordResetToken extends AggregateRoot
{
private const int EXPIRATION_HOURS = 1;
public private(set) ?DateTimeImmutable $usedAt = null;
private function __construct(
public private(set) PasswordResetTokenId $id,
public private(set) string $tokenValue,
public private(set) string $userId,
public private(set) string $email,
public private(set) TenantId $tenantId,
public private(set) DateTimeImmutable $createdAt,
public private(set) DateTimeImmutable $expiresAt,
) {
}
public static function generate(
string $userId,
string $email,
TenantId $tenantId,
DateTimeImmutable $createdAt,
): self {
$token = new self(
id: PasswordResetTokenId::generate(),
tokenValue: Uuid::uuid7()->toString(),
userId: $userId,
email: $email,
tenantId: $tenantId,
createdAt: $createdAt,
expiresAt: $createdAt->modify(sprintf('+%d hour', self::EXPIRATION_HOURS)),
);
$token->recordEvent(new PasswordResetTokenGenerated(
tokenId: $token->id,
tokenValue: $token->tokenValue,
userId: $userId,
email: $email,
tenantId: $tenantId,
occurredOn: $createdAt,
));
return $token;
}
/**
* Reconstitute a PasswordResetToken from storage.
* Does NOT record domain events (this is not a new creation).
*
* @internal For use by Infrastructure layer only
*/
public static function reconstitute(
PasswordResetTokenId $id,
string $tokenValue,
string $userId,
string $email,
TenantId $tenantId,
DateTimeImmutable $createdAt,
DateTimeImmutable $expiresAt,
?DateTimeImmutable $usedAt,
): self {
$token = new self(
id: $id,
tokenValue: $tokenValue,
userId: $userId,
email: $email,
tenantId: $tenantId,
createdAt: $createdAt,
expiresAt: $expiresAt,
);
$token->usedAt = $usedAt;
return $token;
}
public function isExpired(DateTimeImmutable $at): bool
{
return $at >= $this->expiresAt;
}
public function isUsed(): bool
{
return $this->usedAt !== null;
}
/**
* Validate that the token can be used (not expired, not already used).
* Does NOT mark the token as used - use use() for that after successful password reset.
*
* @throws PasswordResetTokenAlreadyUsedException if token was already used
* @throws PasswordResetTokenExpiredException if token is expired
*/
public function validateForUse(DateTimeImmutable $at): void
{
if ($this->isUsed()) {
throw PasswordResetTokenAlreadyUsedException::forToken($this->id);
}
if ($this->isExpired($at)) {
throw PasswordResetTokenExpiredException::forToken($this->id);
}
}
/**
* Mark the token as used. Should only be called after successful password reset.
*
* @throws PasswordResetTokenAlreadyUsedException if token was already used
* @throws PasswordResetTokenExpiredException if token is expired
*/
public function use(DateTimeImmutable $at): void
{
$this->validateForUse($at);
$this->usedAt = $at;
$this->recordEvent(new PasswordResetTokenUsed(
tokenId: $this->id,
userId: $this->userId,
occurredOn: $at,
));
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\PasswordResetToken;
use App\Shared\Domain\EntityId;
final readonly class PasswordResetTokenId extends EntityId
{
}