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,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\RequestPasswordReset;
|
||||
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
/**
|
||||
* Command to request a password reset.
|
||||
*
|
||||
* This command is dispatched when a user submits the "forgot password" form.
|
||||
* The handler will generate a reset token and trigger the email sending.
|
||||
*
|
||||
* Security: The handler always returns success, even if the email doesn't exist,
|
||||
* to prevent email enumeration attacks.
|
||||
*/
|
||||
final readonly class RequestPasswordResetCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $email,
|
||||
public TenantId $tenantId,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\RequestPasswordReset;
|
||||
|
||||
use App\Administration\Domain\Event\PasswordResetTokenGenerated;
|
||||
use App\Administration\Domain\Exception\EmailInvalideException;
|
||||
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetToken;
|
||||
use App\Administration\Domain\Model\User\Email;
|
||||
use App\Administration\Domain\Repository\PasswordResetTokenRepository;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/**
|
||||
* Handles password reset requests.
|
||||
*
|
||||
* Security principles:
|
||||
* - Never reveals if email exists (prevents enumeration)
|
||||
* - Reuses existing valid token if one exists (prevents token flooding)
|
||||
* - Rate limited at API level (3 requests/hour per IP)
|
||||
*/
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class RequestPasswordResetHandler
|
||||
{
|
||||
public function __construct(
|
||||
private UserRepository $userRepository,
|
||||
private PasswordResetTokenRepository $tokenRepository,
|
||||
private Clock $clock,
|
||||
private MessageBusInterface $eventBus,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Process password reset request.
|
||||
*
|
||||
* Always succeeds from the caller's perspective.
|
||||
* Only generates token and dispatches events if user exists.
|
||||
*
|
||||
* Security: performs similar work for non-existent emails to prevent timing attacks.
|
||||
*/
|
||||
public function __invoke(RequestPasswordResetCommand $command): void
|
||||
{
|
||||
// Try to parse email - silently return on invalid email (no enumeration)
|
||||
try {
|
||||
$email = new Email($command->email);
|
||||
} catch (EmailInvalideException) {
|
||||
// Perform dummy work to match timing of valid email path
|
||||
$this->simulateTokenLookup();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to find user by email for this tenant
|
||||
$user = $this->userRepository->findByEmail($email, $command->tenantId);
|
||||
|
||||
// If user doesn't exist, silently return (no email enumeration)
|
||||
if ($user === null) {
|
||||
// Perform dummy work to match timing of valid email path
|
||||
// This prevents timing attacks that could reveal email existence
|
||||
$this->simulateTokenLookup();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if a valid token already exists for this user
|
||||
$existingToken = $this->tokenRepository->findValidTokenForUser((string) $user->id);
|
||||
|
||||
if ($existingToken !== null) {
|
||||
// Reuse existing token - manually dispatch event to resend email
|
||||
// (reconstituted tokens don't have domain events)
|
||||
$this->eventBus->dispatch(new PasswordResetTokenGenerated(
|
||||
tokenId: $existingToken->id,
|
||||
tokenValue: $existingToken->tokenValue,
|
||||
userId: $existingToken->userId,
|
||||
email: $existingToken->email,
|
||||
tenantId: $existingToken->tenantId,
|
||||
occurredOn: $this->clock->now(),
|
||||
));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate new reset token
|
||||
$now = $this->clock->now();
|
||||
$token = PasswordResetToken::generate(
|
||||
userId: (string) $user->id,
|
||||
email: $command->email,
|
||||
tenantId: $command->tenantId,
|
||||
createdAt: $now,
|
||||
);
|
||||
|
||||
// Persist token
|
||||
$this->tokenRepository->save($token);
|
||||
|
||||
// Dispatch domain events (triggers email sending)
|
||||
foreach ($token->pullDomainEvents() as $event) {
|
||||
$this->eventBus->dispatch($event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs dummy work to match timing of valid email path.
|
||||
*
|
||||
* This prevents timing attacks that could reveal email existence by ensuring
|
||||
* similar processing time regardless of whether the email exists or not.
|
||||
*/
|
||||
private function simulateTokenLookup(): void
|
||||
{
|
||||
// Generate a fake userId to query (will not exist)
|
||||
// This matches the timing of findValidTokenForUser() call
|
||||
$fakeUserId = bin2hex(random_bytes(16));
|
||||
$this->tokenRepository->findValidTokenForUser($fakeUserId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\ResetPassword;
|
||||
|
||||
/**
|
||||
* Command to reset a user's password using a valid reset token.
|
||||
*/
|
||||
final readonly class ResetPasswordCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $token,
|
||||
public string $newPassword,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\ResetPassword;
|
||||
|
||||
use App\Administration\Application\Port\PasswordHasher;
|
||||
use App\Administration\Domain\Exception\PasswordResetTokenAlreadyUsedException;
|
||||
use App\Administration\Domain\Exception\PasswordResetTokenExpiredException;
|
||||
use App\Administration\Domain\Exception\PasswordResetTokenNotFoundException;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Repository\PasswordResetTokenRepository;
|
||||
use App\Administration\Domain\Repository\RefreshTokenRepository;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/**
|
||||
* Handles password reset using a valid token.
|
||||
*
|
||||
* Security:
|
||||
* - Validates token is not expired
|
||||
* - Validates token is not already used
|
||||
* - Marks token as used after successful reset
|
||||
* - Invalidates all user sessions (force re-authentication)
|
||||
* - Dispatches events for audit and confirmation email
|
||||
*/
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class ResetPasswordHandler
|
||||
{
|
||||
public function __construct(
|
||||
private PasswordResetTokenRepository $tokenRepository,
|
||||
private UserRepository $userRepository,
|
||||
private RefreshTokenRepository $refreshTokenRepository,
|
||||
private PasswordHasher $passwordHasher,
|
||||
private Clock $clock,
|
||||
private MessageBusInterface $eventBus,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Process password reset.
|
||||
*
|
||||
* @throws PasswordResetTokenNotFoundException if token doesn't exist
|
||||
* @throws PasswordResetTokenExpiredException if token has expired
|
||||
* @throws PasswordResetTokenAlreadyUsedException if token was already used
|
||||
*/
|
||||
public function __invoke(ResetPasswordCommand $command): void
|
||||
{
|
||||
$now = $this->clock->now();
|
||||
|
||||
// Atomically consume token (validates + marks as used in one operation)
|
||||
// This prevents race conditions where two concurrent requests could both
|
||||
// pass validation before either marks the token as used
|
||||
$token = $this->tokenRepository->consumeIfValid($command->token, $now);
|
||||
|
||||
// Find user
|
||||
$userId = UserId::fromString($token->userId);
|
||||
$user = $this->userRepository->get($userId);
|
||||
|
||||
// Hash and update password
|
||||
$hashedPassword = $this->passwordHasher->hash($command->newPassword);
|
||||
$user->changerMotDePasse($hashedPassword, $now);
|
||||
|
||||
// Save user changes
|
||||
$this->userRepository->save($user);
|
||||
|
||||
// Invalidate all user sessions (force re-authentication on all devices)
|
||||
$this->refreshTokenRepository->invalidateAllForUser($userId);
|
||||
|
||||
// Note: We intentionally keep the used token in storage (until TTL expiry)
|
||||
// This allows distinguishing "already used" (410) from "invalid" (400)
|
||||
// when the same link is submitted again
|
||||
|
||||
// Dispatch domain events from user (MotDePasseChange)
|
||||
foreach ($user->pullDomainEvents() as $event) {
|
||||
$this->eventBus->dispatch($event);
|
||||
}
|
||||
|
||||
// Dispatch domain events from token (PasswordResetTokenUsed)
|
||||
foreach ($token->pullDomainEvents() as $event) {
|
||||
$this->eventBus->dispatch($event);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,19 +16,19 @@ use App\Shared\Domain\Tenant\TenantId;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Gère le cycle de vie des refresh tokens.
|
||||
* Manages the lifecycle of refresh tokens.
|
||||
*
|
||||
* Responsabilités :
|
||||
* - Création de tokens pour nouvelles sessions
|
||||
* - Rotation des tokens avec détection de replay
|
||||
* - Invalidation de familles de tokens compromises
|
||||
* Responsibilities:
|
||||
* - Token creation for new sessions
|
||||
* - Token rotation with replay detection
|
||||
* - Invalidation of compromised token families
|
||||
*
|
||||
* @see Story 1.4 - Connexion utilisateur
|
||||
* @see Story 1.4 - User login
|
||||
*/
|
||||
final readonly class RefreshTokenManager
|
||||
{
|
||||
private const int WEB_TTL_SECONDS = 86400; // 1 jour pour web
|
||||
private const int MOBILE_TTL_SECONDS = 604800; // 7 jours pour mobile
|
||||
private const int WEB_TTL_SECONDS = 86400; // 1 day for web
|
||||
private const int MOBILE_TTL_SECONDS = 604800; // 7 days for mobile
|
||||
|
||||
public function __construct(
|
||||
private RefreshTokenRepository $repository,
|
||||
@@ -37,7 +37,7 @@ final readonly class RefreshTokenManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un nouveau refresh token pour une session.
|
||||
* Creates a new refresh token for a session.
|
||||
*/
|
||||
public function create(
|
||||
UserId $userId,
|
||||
@@ -47,7 +47,7 @@ final readonly class RefreshTokenManager
|
||||
): RefreshToken {
|
||||
$ttl = $isMobile ? self::MOBILE_TTL_SECONDS : self::WEB_TTL_SECONDS;
|
||||
|
||||
// Ajouter un jitter de ±10% pour éviter les expirations simultanées
|
||||
// Add ±10% jitter to avoid simultaneous expirations
|
||||
$jitter = (int) ($ttl * 0.1 * (random_int(-100, 100) / 100));
|
||||
$ttl += $jitter;
|
||||
|
||||
@@ -65,13 +65,13 @@ final readonly class RefreshTokenManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide et rafraîchit un token.
|
||||
* Validates and refreshes a token.
|
||||
*
|
||||
* @throws TokenReplayDetectedException si un replay attack est détecté
|
||||
* @throws TokenAlreadyRotatedException si le token a déjà été rotaté mais est en grace period
|
||||
* @throws InvalidArgumentException si le token est invalide ou expiré
|
||||
* @throws TokenReplayDetectedException if a replay attack is detected
|
||||
* @throws TokenAlreadyRotatedException if the token has already been rotated but is in grace period
|
||||
* @throws InvalidArgumentException if the token is invalid or expired
|
||||
*
|
||||
* @return RefreshToken le nouveau token après rotation
|
||||
* @return RefreshToken the new token after rotation
|
||||
*/
|
||||
public function refresh(
|
||||
string $tokenString,
|
||||
@@ -85,53 +85,53 @@ final readonly class RefreshTokenManager
|
||||
throw new InvalidArgumentException('Token not found');
|
||||
}
|
||||
|
||||
// Vérifier l'expiration
|
||||
// Check expiration
|
||||
if ($token->isExpired($now)) {
|
||||
$this->repository->delete($tokenId);
|
||||
|
||||
throw new InvalidArgumentException('Token expired');
|
||||
}
|
||||
|
||||
// Vérifier le device fingerprint
|
||||
// Check device fingerprint
|
||||
if (!$token->matchesDevice($deviceFingerprint)) {
|
||||
// Potentielle tentative de vol de token - invalider toute la famille
|
||||
// Potential token theft attempt - invalidate the entire family
|
||||
$this->repository->invalidateFamily($token->familyId);
|
||||
|
||||
throw new TokenReplayDetectedException($token->familyId);
|
||||
}
|
||||
|
||||
// Détecter les replay attacks
|
||||
// Detect replay attacks
|
||||
if ($token->isRotated) {
|
||||
// Token déjà utilisé !
|
||||
// Token already used!
|
||||
if ($token->isInGracePeriod($now)) {
|
||||
// Dans la grace period - probablement une race condition légitime
|
||||
// On laisse passer mais on ne génère pas de nouveau token
|
||||
// Le client devrait utiliser le token le plus récent
|
||||
// Exception dédiée pour ne PAS supprimer le cookie lors d'une race condition légitime
|
||||
// In grace period - probably a legitimate race condition
|
||||
// We let it pass but don't generate a new token
|
||||
// The client should use the most recent token
|
||||
// Dedicated exception to NOT delete the cookie during a legitimate race condition
|
||||
throw new TokenAlreadyRotatedException();
|
||||
}
|
||||
|
||||
// Replay attack confirmé - invalider toute la famille
|
||||
// Confirmed replay attack - invalidate the entire family
|
||||
$this->repository->invalidateFamily($token->familyId);
|
||||
|
||||
throw new TokenReplayDetectedException($token->familyId);
|
||||
}
|
||||
|
||||
// Rotation du token (préserve le TTL original)
|
||||
// Rotate the token (preserves original TTL)
|
||||
[$newToken, $rotatedOldToken] = $token->rotate($now);
|
||||
|
||||
// Sauvegarder le nouveau token EN PREMIER
|
||||
// Important: sauvegarder le nouveau token EN PREMIER pour que l'index famille garde le bon TTL
|
||||
// Save the new token FIRST
|
||||
// Important: save the new token FIRST so the family index keeps the correct TTL
|
||||
$this->repository->save($newToken);
|
||||
|
||||
// Mettre à jour l'ancien token comme rotaté (pour grace period)
|
||||
// Update the old token as rotated (for grace period)
|
||||
$this->repository->save($rotatedOldToken);
|
||||
|
||||
return $newToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Révoque un token (déconnexion).
|
||||
* Revokes a token (logout).
|
||||
*/
|
||||
public function revoke(string $tokenString): void
|
||||
{
|
||||
@@ -140,18 +140,18 @@ final readonly class RefreshTokenManager
|
||||
$token = $this->repository->find($tokenId);
|
||||
|
||||
if ($token !== null) {
|
||||
// Invalider toute la famille pour une déconnexion complète
|
||||
// Invalidate the entire family for a complete logout
|
||||
$this->repository->invalidateFamily($token->familyId);
|
||||
}
|
||||
} catch (InvalidArgumentException) {
|
||||
// Token invalide, rien à faire
|
||||
// Invalid token, nothing to do
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalide toute une famille de tokens.
|
||||
* Invalidates an entire token family.
|
||||
*
|
||||
* Utilisé quand un utilisateur est suspendu/archivé pour révoquer toutes ses sessions.
|
||||
* Used when a user is suspended/archived to revoke all their sessions.
|
||||
*/
|
||||
public function invalidateFamily(TokenFamilyId $familyId): void
|
||||
{
|
||||
|
||||
@@ -10,12 +10,12 @@ use Ramsey\Uuid\Uuid;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
/**
|
||||
* Événement émis lors d'une tentative de connexion échouée.
|
||||
* Event emitted when a login attempt fails.
|
||||
*
|
||||
* Note: L'email est enregistré pour le tracking mais ne révèle pas
|
||||
* si le compte existe (même message d'erreur dans tous les cas).
|
||||
* Note: The email is recorded for tracking but does not reveal
|
||||
* whether the account exists (same error message in all cases).
|
||||
*
|
||||
* @see Story 1.4 - AC2: Gestion erreurs d'authentification
|
||||
* @see Story 1.4 - AC2: Authentication error handling
|
||||
*/
|
||||
final readonly class ConnexionEchouee implements DomainEvent
|
||||
{
|
||||
@@ -35,7 +35,7 @@ final readonly class ConnexionEchouee implements DomainEvent
|
||||
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
// Pas d'aggregate associé, utiliser un UUID basé sur l'email
|
||||
// No associated aggregate, use a UUID based on the email
|
||||
return Uuid::uuid5(
|
||||
Uuid::NAMESPACE_DNS,
|
||||
'login_attempt:' . $this->email,
|
||||
|
||||
42
backend/src/Administration/Domain/Event/MotDePasseChange.php
Normal file
42
backend/src/Administration/Domain/Event/MotDePasseChange.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
/**
|
||||
* Event emitted when a user's password is changed.
|
||||
*
|
||||
* This event triggers:
|
||||
* - Sending a confirmation email
|
||||
* - Audit logging
|
||||
*/
|
||||
final readonly class MotDePasseChange implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public string $userId,
|
||||
public string $email,
|
||||
public TenantId $tenantId,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return Uuid::fromString($this->userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetTokenId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
final readonly class PasswordResetTokenGenerated implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public PasswordResetTokenId $tokenId,
|
||||
public string $tokenValue,
|
||||
public string $userId,
|
||||
public string $email,
|
||||
public TenantId $tenantId,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->tokenId->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetTokenId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
final readonly class PasswordResetTokenUsed implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public PasswordResetTokenId $tokenId,
|
||||
public string $userId,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->tokenId->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetTokenId;
|
||||
use RuntimeException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class PasswordResetTokenAlreadyUsedException extends RuntimeException
|
||||
{
|
||||
public static function forToken(PasswordResetTokenId $tokenId): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Password reset token "%s" has already been used.',
|
||||
$tokenId,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetTokenId;
|
||||
use RuntimeException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class PasswordResetTokenExpiredException extends RuntimeException
|
||||
{
|
||||
public static function forToken(PasswordResetTokenId $tokenId): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Password reset token "%s" has expired.',
|
||||
$tokenId,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetTokenId;
|
||||
use RuntimeException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class PasswordResetTokenNotFoundException extends RuntimeException
|
||||
{
|
||||
public static function withId(PasswordResetTokenId $tokenId): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Password reset token with ID "%s" not found.',
|
||||
$tokenId,
|
||||
));
|
||||
}
|
||||
|
||||
public static function withTokenValue(string $tokenValue): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Password reset token with value "%s" not found.',
|
||||
$tokenValue,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
/**
|
||||
* Thrown when a token consumption is already in progress.
|
||||
*
|
||||
* This indicates a concurrent request is processing the same token,
|
||||
* and the client should retry after a short delay.
|
||||
*/
|
||||
final class TokenConsumptionInProgressException extends RuntimeException
|
||||
{
|
||||
public function __construct(string $tokenValue)
|
||||
{
|
||||
parent::__construct(
|
||||
sprintf('Token consumption in progress for token "%s". Please retry.', $tokenValue)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ final class ActivationToken extends AggregateRoot
|
||||
): self {
|
||||
$token = new self(
|
||||
id: ActivationTokenId::generate(),
|
||||
tokenValue: Uuid::uuid4()->toString(),
|
||||
tokenValue: Uuid::uuid7()->toString(),
|
||||
userId: $userId,
|
||||
email: $email,
|
||||
tenantId: $tenantId,
|
||||
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -10,39 +10,39 @@ use DateTimeImmutable;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Représente un refresh token pour le renouvellement silencieux des sessions.
|
||||
* Represents a refresh token for silent session renewal.
|
||||
*
|
||||
* Stratégie de sécurité :
|
||||
* - Rotation : chaque utilisation génère un nouveau token et invalide l'ancien
|
||||
* - Family tracking : tous les tokens d'une session partagent un family_id
|
||||
* - Replay detection : si un token déjà utilisé est présenté, toute la famille est invalidée
|
||||
* - Device binding : le token est lié à un device fingerprint
|
||||
* - Grace period : 30s de tolérance pour les race conditions multi-onglets
|
||||
* Security strategy:
|
||||
* - Rotation: each use generates a new token and invalidates the old one
|
||||
* - Family tracking: all tokens from a session share a family_id
|
||||
* - Replay detection: if an already-used token is presented, the entire family is invalidated
|
||||
* - Device binding: the token is bound to a device fingerprint
|
||||
* - Grace period: 30s tolerance for multi-tab race conditions
|
||||
*
|
||||
* Note sur les méthodes statiques :
|
||||
* Cette classe utilise des factory methods statiques (create(), reconstitute()) conformément
|
||||
* aux patterns DDD standards pour la création d'Aggregates. Bien que le projet suive les
|
||||
* principes Elegant Objects "No Static", les factory methods pour les Aggregates sont une
|
||||
* exception documentée car elles encapsulent la logique d'instanciation et rendent le
|
||||
* constructeur privé, préservant ainsi l'invariant du domain.
|
||||
* Note on static methods:
|
||||
* This class uses static factory methods (create(), reconstitute()) following standard
|
||||
* DDD patterns for Aggregate creation. Although the project follows Elegant Objects
|
||||
* "No Static" principles, factory methods for Aggregates are a documented exception
|
||||
* as they encapsulate instantiation logic and keep the constructor private, thus
|
||||
* preserving domain invariants.
|
||||
*
|
||||
* @see Story 1.4 - Connexion utilisateur
|
||||
* @see Story 1.4 - User login
|
||||
*/
|
||||
final readonly class RefreshToken
|
||||
{
|
||||
/**
|
||||
* TTL par défaut : 7 jours (604800 secondes).
|
||||
* Default TTL: 7 days (604800 seconds).
|
||||
*
|
||||
* Ce TTL est utilisé si aucun TTL n'est spécifié à la création du token.
|
||||
* RefreshTokenManager utilise 1 jour (86400s) pour les sessions web afin
|
||||
* de limiter l'exposition en cas de vol de cookie sur navigateur.
|
||||
* This TTL is used if none is specified at token creation.
|
||||
* RefreshTokenManager uses 1 day (86400s) for web sessions to
|
||||
* limit exposure in case of cookie theft on browsers.
|
||||
*/
|
||||
private const int DEFAULT_TTL_SECONDS = 604800;
|
||||
|
||||
/**
|
||||
* Période de grâce après rotation pour gérer les race conditions multi-onglets.
|
||||
* Si deux onglets rafraîchissent simultanément, le second recevra une erreur
|
||||
* bénigne au lieu d'invalider toute la famille de tokens.
|
||||
* Grace period after rotation to handle multi-tab race conditions.
|
||||
* If two tabs refresh simultaneously, the second will receive a benign
|
||||
* error instead of invalidating the entire token family.
|
||||
*/
|
||||
private const int GRACE_PERIOD_SECONDS = 30;
|
||||
|
||||
@@ -61,7 +61,7 @@ final readonly class RefreshToken
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un nouveau refresh token pour une nouvelle session.
|
||||
* Creates a new refresh token for a new session.
|
||||
*/
|
||||
public static function create(
|
||||
UserId $userId,
|
||||
@@ -85,27 +85,27 @@ final readonly class RefreshToken
|
||||
}
|
||||
|
||||
/**
|
||||
* Effectue une rotation du token (génère un nouveau token, marque l'ancien comme rotaté).
|
||||
* Performs token rotation (generates a new token, marks the old one as rotated).
|
||||
*
|
||||
* Le nouveau token conserve le même TTL que l'original pour respecter la politique de session
|
||||
* (web = 1 jour, mobile = 7 jours). L'ancien token est marqué avec rotatedAt pour la grace period.
|
||||
* The new token preserves the same TTL as the original to respect the session policy
|
||||
* (web = 1 day, mobile = 7 days). The old token is marked with rotatedAt for the grace period.
|
||||
*
|
||||
* @return array{0: self, 1: self} Le nouveau token et l'ancien token marqué comme rotaté
|
||||
* @return array{0: self, 1: self} The new token and the old token marked as rotated
|
||||
*/
|
||||
public function rotate(DateTimeImmutable $at): array
|
||||
{
|
||||
// Préserver le TTL original pour respecter la politique de session (web = 1 jour, mobile = 7 jours)
|
||||
// Preserve original TTL to respect session policy (web = 1 day, mobile = 7 days)
|
||||
$originalTtlSeconds = $this->expiresAt->getTimestamp() - $this->issuedAt->getTimestamp();
|
||||
|
||||
$newToken = new self(
|
||||
id: RefreshTokenId::generate(),
|
||||
familyId: $this->familyId, // Même famille
|
||||
familyId: $this->familyId, // Same family
|
||||
userId: $this->userId,
|
||||
tenantId: $this->tenantId,
|
||||
deviceFingerprint: $this->deviceFingerprint,
|
||||
issuedAt: $at,
|
||||
expiresAt: $at->modify("+{$originalTtlSeconds} seconds"),
|
||||
rotatedFrom: $this->id, // Traçabilité
|
||||
rotatedFrom: $this->id, // Traceability
|
||||
isRotated: false,
|
||||
rotatedAt: null,
|
||||
);
|
||||
@@ -120,14 +120,14 @@ final readonly class RefreshToken
|
||||
expiresAt: $this->expiresAt,
|
||||
rotatedFrom: $this->rotatedFrom,
|
||||
isRotated: true,
|
||||
rotatedAt: $at, // Pour la grace period
|
||||
rotatedAt: $at, // For the grace period
|
||||
);
|
||||
|
||||
return [$newToken, $rotatedOldToken];
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si le token est expiré.
|
||||
* Checks if the token is expired.
|
||||
*/
|
||||
public function isExpired(DateTimeImmutable $at): bool
|
||||
{
|
||||
@@ -135,11 +135,11 @@ final readonly class RefreshToken
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si le token est dans la période de grâce après rotation.
|
||||
* Checks if the token is in the grace period after rotation.
|
||||
*
|
||||
* La grace period permet de gérer les race conditions quand plusieurs onglets
|
||||
* tentent de rafraîchir le token simultanément. Elle est basée sur le moment
|
||||
* de la rotation, pas sur l'émission initiale du token.
|
||||
* The grace period handles race conditions when multiple tabs attempt to
|
||||
* refresh the token simultaneously. It is based on the rotation time,
|
||||
* not the initial token issuance.
|
||||
*/
|
||||
public function isInGracePeriod(DateTimeImmutable $at): bool
|
||||
{
|
||||
@@ -153,7 +153,7 @@ final readonly class RefreshToken
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si l'empreinte du device correspond.
|
||||
* Checks if the device fingerprint matches.
|
||||
*/
|
||||
public function matchesDevice(DeviceFingerprint $fingerprint): bool
|
||||
{
|
||||
@@ -161,9 +161,9 @@ final readonly class RefreshToken
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère le token string à stocker dans le cookie.
|
||||
* Generates the token string to store in the cookie.
|
||||
*
|
||||
* Le format est opaque pour le client : base64(id)
|
||||
* The format is opaque to the client: base64(id)
|
||||
*/
|
||||
public function toTokenString(): string
|
||||
{
|
||||
@@ -171,7 +171,7 @@ final readonly class RefreshToken
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait l'ID depuis un token string.
|
||||
* Extracts the ID from a token string.
|
||||
*/
|
||||
public static function extractIdFromTokenString(string $tokenString): RefreshTokenId
|
||||
{
|
||||
@@ -185,9 +185,9 @@ final readonly class RefreshToken
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstitue un RefreshToken depuis le stockage.
|
||||
* Reconstitutes a RefreshToken from storage.
|
||||
*
|
||||
* @internal Pour usage par l'Infrastructure uniquement
|
||||
* @internal For Infrastructure use only
|
||||
*/
|
||||
public static function reconstitute(
|
||||
RefreshTokenId $id,
|
||||
|
||||
@@ -5,18 +5,18 @@ declare(strict_types=1);
|
||||
namespace App\Administration\Domain\Model\User;
|
||||
|
||||
/**
|
||||
* Enum représentant le statut d'activation d'un compte utilisateur.
|
||||
* Enum representing the activation status of a user account.
|
||||
*/
|
||||
enum StatutCompte: string
|
||||
{
|
||||
case EN_ATTENTE = 'pending'; // Compte créé, en attente d'activation
|
||||
case CONSENTEMENT_REQUIS = 'consent'; // Mineur < 15 ans, en attente consentement parental
|
||||
case ACTIF = 'active'; // Compte activé et utilisable
|
||||
case SUSPENDU = 'suspended'; // Compte temporairement désactivé
|
||||
case ARCHIVE = 'archived'; // Compte archivé (fin de scolarité)
|
||||
case EN_ATTENTE = 'pending'; // Account created, awaiting activation
|
||||
case CONSENTEMENT_REQUIS = 'consent'; // Minor < 15 years, awaiting parental consent
|
||||
case ACTIF = 'active'; // Account activated and usable
|
||||
case SUSPENDU = 'suspended'; // Account temporarily disabled
|
||||
case ARCHIVE = 'archived'; // Account archived (end of schooling)
|
||||
|
||||
/**
|
||||
* Vérifie si l'utilisateur peut se connecter avec ce statut.
|
||||
* Checks if the user can log in with this status.
|
||||
*/
|
||||
public function peutSeConnecter(): bool
|
||||
{
|
||||
@@ -24,7 +24,7 @@ enum StatutCompte: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si l'utilisateur peut activer son compte.
|
||||
* Checks if the user can activate their account.
|
||||
*/
|
||||
public function peutActiver(): bool
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Administration\Domain\Model\User;
|
||||
|
||||
use App\Administration\Domain\Event\CompteActive;
|
||||
use App\Administration\Domain\Event\CompteCreated;
|
||||
use App\Administration\Domain\Event\MotDePasseChange;
|
||||
use App\Administration\Domain\Exception\CompteNonActivableException;
|
||||
use App\Administration\Domain\Model\ConsentementParental\ConsentementParental;
|
||||
use App\Administration\Domain\Policy\ConsentementParentalPolicy;
|
||||
@@ -14,11 +15,11 @@ use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Aggregate Root représentant un utilisateur dans Classeo.
|
||||
* Aggregate Root representing a user in Classeo.
|
||||
*
|
||||
* Un utilisateur appartient à un établissement (tenant) et possède un rôle.
|
||||
* Le cycle de vie du compte passe par plusieurs statuts : création → activation.
|
||||
* Les mineurs (< 15 ans) nécessitent un consentement parental avant activation.
|
||||
* A user belongs to a school (tenant) and has a role.
|
||||
* The account lifecycle goes through multiple statuses: creation → activation.
|
||||
* Minors (< 15 years) require parental consent before activation.
|
||||
*/
|
||||
final class User extends AggregateRoot
|
||||
{
|
||||
@@ -39,7 +40,7 @@ final class User extends AggregateRoot
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un nouveau compte utilisateur en attente d'activation.
|
||||
* Creates a new user account awaiting activation.
|
||||
*/
|
||||
public static function creer(
|
||||
Email $email,
|
||||
@@ -72,9 +73,9 @@ final class User extends AggregateRoot
|
||||
}
|
||||
|
||||
/**
|
||||
* Active le compte avec le mot de passe hashé.
|
||||
* Activates the account with the hashed password.
|
||||
*
|
||||
* @throws CompteNonActivableException si le compte ne peut pas être activé
|
||||
* @throws CompteNonActivableException if the account cannot be activated
|
||||
*/
|
||||
public function activer(
|
||||
string $hashedPassword,
|
||||
@@ -85,7 +86,7 @@ final class User extends AggregateRoot
|
||||
throw CompteNonActivableException::carStatutIncompatible($this->id, $this->statut);
|
||||
}
|
||||
|
||||
// Vérifier si le consentement parental est requis
|
||||
// Check if parental consent is required
|
||||
if ($consentementPolicy->estRequis($this->dateNaissance)) {
|
||||
if ($this->consentementParental === null) {
|
||||
throw CompteNonActivableException::carConsentementManquant($this->id);
|
||||
@@ -107,20 +108,20 @@ final class User extends AggregateRoot
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre le consentement parental donné par le parent.
|
||||
* Records the parental consent given by the parent.
|
||||
*/
|
||||
public function enregistrerConsentementParental(ConsentementParental $consentement): void
|
||||
{
|
||||
$this->consentementParental = $consentement;
|
||||
|
||||
// Si le compte était en attente de consentement, passer en attente d'activation
|
||||
// If the account was awaiting consent, move to awaiting activation
|
||||
if ($this->statut === StatutCompte::CONSENTEMENT_REQUIS) {
|
||||
$this->statut = StatutCompte::EN_ATTENTE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si cet utilisateur est mineur et nécessite un consentement parental.
|
||||
* Checks if this user is a minor and requires parental consent.
|
||||
*/
|
||||
public function necessiteConsentementParental(ConsentementParentalPolicy $policy): bool
|
||||
{
|
||||
@@ -128,7 +129,7 @@ final class User extends AggregateRoot
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si le compte est actif et peut se connecter.
|
||||
* Checks if the account is active and can log in.
|
||||
*/
|
||||
public function peutSeConnecter(): bool
|
||||
{
|
||||
@@ -136,9 +137,26 @@ final class User extends AggregateRoot
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstitue un User depuis le stockage.
|
||||
* Changes the user's password.
|
||||
*
|
||||
* @internal Pour usage par l'Infrastructure uniquement
|
||||
* Used during password reset.
|
||||
*/
|
||||
public function changerMotDePasse(string $hashedPassword, DateTimeImmutable $at): void
|
||||
{
|
||||
$this->hashedPassword = $hashedPassword;
|
||||
|
||||
$this->recordEvent(new MotDePasseChange(
|
||||
userId: (string) $this->id,
|
||||
email: (string) $this->email,
|
||||
tenantId: $this->tenantId,
|
||||
occurredOn: $at,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstitutes a User from storage.
|
||||
*
|
||||
* @internal For Infrastructure use only
|
||||
*/
|
||||
public static function reconstitute(
|
||||
UserId $id,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Repository;
|
||||
|
||||
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetToken;
|
||||
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetTokenId;
|
||||
use DateTimeImmutable;
|
||||
|
||||
interface PasswordResetTokenRepository
|
||||
{
|
||||
public function save(PasswordResetToken $token): void;
|
||||
|
||||
/**
|
||||
* Find a token by its unique token value.
|
||||
* Use getByTokenValue() when you expect the token to exist.
|
||||
*/
|
||||
public function findByTokenValue(string $tokenValue): ?PasswordResetToken;
|
||||
|
||||
/**
|
||||
* Get a token by its unique token value.
|
||||
*
|
||||
* @throws \App\Administration\Domain\Exception\PasswordResetTokenNotFoundException if token does not exist
|
||||
*/
|
||||
public function getByTokenValue(string $tokenValue): PasswordResetToken;
|
||||
|
||||
/**
|
||||
* Get a token by its ID.
|
||||
*
|
||||
* @throws \App\Administration\Domain\Exception\PasswordResetTokenNotFoundException if token does not exist
|
||||
*/
|
||||
public function get(PasswordResetTokenId $id): PasswordResetToken;
|
||||
|
||||
/**
|
||||
* Delete a token (after use or for cleanup).
|
||||
*/
|
||||
public function delete(PasswordResetTokenId $id): void;
|
||||
|
||||
/**
|
||||
* Delete a token by its token value.
|
||||
*/
|
||||
public function deleteByTokenValue(string $tokenValue): void;
|
||||
|
||||
/**
|
||||
* Find an existing valid (not used, not expired) token for a user.
|
||||
* Returns null if no valid token exists.
|
||||
*/
|
||||
public function findValidTokenForUser(string $userId): ?PasswordResetToken;
|
||||
|
||||
/**
|
||||
* Atomically consume a token: validate it and mark it as used.
|
||||
*
|
||||
* This operation is protected against concurrent double-use by using a lock.
|
||||
* If two requests try to consume the same token simultaneously, only one
|
||||
* will succeed; the other will see the token as already used.
|
||||
*
|
||||
* @throws \App\Administration\Domain\Exception\PasswordResetTokenNotFoundException if token does not exist
|
||||
* @throws \App\Administration\Domain\Exception\PasswordResetTokenExpiredException if token has expired
|
||||
* @throws \App\Administration\Domain\Exception\PasswordResetTokenAlreadyUsedException if token was already used
|
||||
*/
|
||||
public function consumeIfValid(string $tokenValue, DateTimeImmutable $at): PasswordResetToken;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace App\Administration\Domain\Repository;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Repository pour la gestion des refresh tokens.
|
||||
@@ -39,4 +40,9 @@ interface RefreshTokenRepository
|
||||
* Invalide tous les tokens d'une famille (en cas de replay attack détectée).
|
||||
*/
|
||||
public function invalidateFamily(TokenFamilyId $familyId): void;
|
||||
|
||||
/**
|
||||
* Invalide tous les tokens d'un utilisateur (après changement de mot de passe).
|
||||
*/
|
||||
public function invalidateAllForUser(UserId $userId): void;
|
||||
}
|
||||
|
||||
@@ -15,11 +15,11 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* Endpoint de déconnexion.
|
||||
* Logout endpoint.
|
||||
*
|
||||
* Invalide le refresh token et supprime le cookie.
|
||||
* Invalidates the refresh token and deletes the cookie.
|
||||
*
|
||||
* @see Story 1.4 - Connexion utilisateur
|
||||
* @see Story 1.4 - User login
|
||||
*/
|
||||
final readonly class LogoutController
|
||||
{
|
||||
@@ -33,25 +33,25 @@ final readonly class LogoutController
|
||||
{
|
||||
$refreshTokenValue = $request->cookies->get('refresh_token');
|
||||
|
||||
// Invalider toute la famille de tokens pour une déconnexion complète
|
||||
// Invalidate the entire token family for a complete logout
|
||||
if ($refreshTokenValue !== null) {
|
||||
try {
|
||||
$tokenId = RefreshToken::extractIdFromTokenString($refreshTokenValue);
|
||||
$refreshToken = $this->refreshTokenRepository->find($tokenId);
|
||||
|
||||
if ($refreshToken !== null) {
|
||||
// Invalider toute la famille (déconnecte tous les devices)
|
||||
// Invalidate the entire family (disconnects all devices)
|
||||
$this->refreshTokenRepository->invalidateFamily($refreshToken->familyId);
|
||||
}
|
||||
} catch (InvalidArgumentException) {
|
||||
// Token malformé, ignorer
|
||||
// Malformed token, ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Créer la réponse avec suppression du cookie
|
||||
$response = new JsonResponse(['message' => 'Déconnexion réussie'], Response::HTTP_OK);
|
||||
// Create the response with cookie deletion
|
||||
$response = new JsonResponse(['message' => 'Logout successful'], Response::HTTP_OK);
|
||||
|
||||
// Supprimer le cookie refresh_token (même path que celui utilisé lors du login)
|
||||
// Delete the refresh_token cookie (same path as used during login)
|
||||
$response->headers->setCookie(
|
||||
Cookie::create('refresh_token')
|
||||
->withValue('')
|
||||
|
||||
@@ -29,18 +29,18 @@ use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/**
|
||||
* Processor pour le rafraîchissement de token.
|
||||
* Processor for token refresh.
|
||||
*
|
||||
* Flow :
|
||||
* 1. Lire le refresh token depuis le cookie HttpOnly
|
||||
* 2. Valider le token et le device fingerprint
|
||||
* 3. Détecter les replay attacks
|
||||
* 4. Générer un nouveau JWT et faire la rotation du refresh token
|
||||
* 5. Mettre à jour le cookie
|
||||
* Flow:
|
||||
* 1. Read the refresh token from the HttpOnly cookie
|
||||
* 2. Validate the token and device fingerprint
|
||||
* 3. Detect replay attacks
|
||||
* 4. Generate a new JWT and rotate the refresh token
|
||||
* 5. Update the cookie
|
||||
*
|
||||
* @implements ProcessorInterface<RefreshTokenInput, RefreshTokenOutput>
|
||||
*
|
||||
* @see Story 1.4 - T6: Endpoint Refresh Token
|
||||
* @see Story 1.4 - T6: Refresh Token Endpoint
|
||||
*/
|
||||
final readonly class RefreshTokenProcessor implements ProcessorInterface
|
||||
{
|
||||
@@ -67,24 +67,24 @@ final readonly class RefreshTokenProcessor implements ProcessorInterface
|
||||
throw new UnauthorizedHttpException('Bearer', 'Request not available');
|
||||
}
|
||||
|
||||
// Lire le refresh token depuis le cookie
|
||||
// Read the refresh token from the cookie
|
||||
$refreshTokenString = $request->cookies->get('refresh_token');
|
||||
|
||||
if ($refreshTokenString === null) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Refresh token not found');
|
||||
}
|
||||
|
||||
// Créer le device fingerprint pour validation
|
||||
// Create the device fingerprint for validation
|
||||
$ipAddress = $request->getClientIp() ?? 'unknown';
|
||||
$userAgent = $request->headers->get('User-Agent', 'unknown');
|
||||
$fingerprint = DeviceFingerprint::fromRequest($userAgent, $ipAddress);
|
||||
|
||||
try {
|
||||
// Valider et faire la rotation du refresh token
|
||||
// Validate and rotate the refresh token
|
||||
$newRefreshToken = $this->refreshTokenManager->refresh($refreshTokenString, $fingerprint);
|
||||
|
||||
// Sécurité: vérifier que le tenant du refresh token correspond au tenant de la requête
|
||||
// Empêche l'utilisation d'un token d'un tenant pour accéder à un autre
|
||||
// Security: verify that the refresh token's tenant matches the request tenant
|
||||
// Prevents using a token from one tenant to access another
|
||||
$currentTenantId = $this->resolveCurrentTenant($request->getHost());
|
||||
if ($currentTenantId !== null && (string) $newRefreshToken->tenantId !== (string) $currentTenantId) {
|
||||
$this->clearRefreshTokenCookie();
|
||||
@@ -92,12 +92,12 @@ final readonly class RefreshTokenProcessor implements ProcessorInterface
|
||||
throw new AccessDeniedHttpException('Invalid token for this tenant');
|
||||
}
|
||||
|
||||
// Charger l'utilisateur pour générer le JWT
|
||||
// Load the user to generate the JWT
|
||||
$user = $this->userRepository->get($newRefreshToken->userId);
|
||||
|
||||
// Vérifier que l'utilisateur peut toujours se connecter (pas suspendu/archivé)
|
||||
// Verify the user can still log in (not suspended/archived)
|
||||
if (!$user->peutSeConnecter()) {
|
||||
// Invalider toute la famille et supprimer le cookie
|
||||
// Invalidate the entire family and delete the cookie
|
||||
$this->refreshTokenManager->invalidateFamily($newRefreshToken->familyId);
|
||||
$this->clearRefreshTokenCookie();
|
||||
|
||||
@@ -106,11 +106,11 @@ final readonly class RefreshTokenProcessor implements ProcessorInterface
|
||||
|
||||
$securityUser = $this->securityUserFactory->fromDomainUser($user);
|
||||
|
||||
// Générer le nouveau JWT
|
||||
// Generate the new JWT
|
||||
$jwt = $this->jwtManager->create($securityUser);
|
||||
|
||||
// Stocker le cookie dans les attributs de requête pour le listener
|
||||
// Le RefreshTokenCookieListener l'ajoutera à la réponse
|
||||
// Store the cookie in request attributes for the listener
|
||||
// The RefreshTokenCookieListener will add it to the response
|
||||
$cookie = Cookie::create('refresh_token')
|
||||
->withValue($newRefreshToken->toTokenString())
|
||||
->withExpires($newRefreshToken->expiresAt)
|
||||
@@ -123,8 +123,8 @@ final readonly class RefreshTokenProcessor implements ProcessorInterface
|
||||
|
||||
return new RefreshTokenOutput(token: $jwt);
|
||||
} catch (TokenReplayDetectedException $e) {
|
||||
// Replay attack détecté - la famille a été invalidée
|
||||
// Dispatcher l'événement de sécurité pour alertes/audit
|
||||
// Replay attack detected - the family has been invalidated
|
||||
// Dispatch the security event for alerts/audit
|
||||
$this->eventBus->dispatch(new TokenReplayDetecte(
|
||||
familyId: $e->familyId,
|
||||
ipAddress: $ipAddress,
|
||||
@@ -132,19 +132,19 @@ final readonly class RefreshTokenProcessor implements ProcessorInterface
|
||||
occurredOn: $this->clock->now(),
|
||||
));
|
||||
|
||||
// Supprimer le cookie côté client
|
||||
// Delete the cookie on client side
|
||||
$this->clearRefreshTokenCookie();
|
||||
|
||||
throw new AccessDeniedHttpException(
|
||||
'Session compromise detected. All sessions have been invalidated. Please log in again.',
|
||||
);
|
||||
} catch (TokenAlreadyRotatedException) {
|
||||
// Token déjà rotaté mais en grace period - race condition légitime
|
||||
// NE PAS supprimer le cookie ! Le client a probablement déjà le nouveau token
|
||||
// d'une requête concurrente. Retourner 409 Conflict pour que le client réessaie.
|
||||
// Token already rotated but in grace period - legitimate race condition
|
||||
// DO NOT delete the cookie! The client probably already has the new token
|
||||
// from a concurrent request. Return 409 Conflict so the client retries.
|
||||
throw new ConflictHttpException('Token already rotated, retry with current cookie');
|
||||
} catch (InvalidArgumentException $e) {
|
||||
// Token invalide ou expiré
|
||||
// Invalid or expired token
|
||||
$this->clearRefreshTokenCookie();
|
||||
|
||||
throw new UnauthorizedHttpException('Bearer', $e->getMessage());
|
||||
@@ -171,7 +171,9 @@ final readonly class RefreshTokenProcessor implements ProcessorInterface
|
||||
/**
|
||||
* Resolves the current tenant from the request host.
|
||||
*
|
||||
* Returns null for localhost (dev environment uses default tenant).
|
||||
* Returns null only for localhost (dev environment).
|
||||
* Throws AccessDeniedHttpException for unknown hosts in production to prevent
|
||||
* cross-tenant token exchange via direct IP or base domain access.
|
||||
*/
|
||||
private function resolveCurrentTenant(string $host): ?\App\Shared\Domain\Tenant\TenantId
|
||||
{
|
||||
@@ -183,7 +185,10 @@ final readonly class RefreshTokenProcessor implements ProcessorInterface
|
||||
try {
|
||||
return $this->tenantResolver->resolve($host)->tenantId;
|
||||
} catch (TenantNotFoundException) {
|
||||
return null;
|
||||
// Security: reject requests from unknown hosts to prevent cross-tenant attacks
|
||||
// An attacker with a valid refresh token from tenant A could try to use it
|
||||
// via direct IP access or base domain to bypass tenant isolation
|
||||
throw new AccessDeniedHttpException('Invalid host for token refresh');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Administration\Application\Command\RequestPasswordReset\RequestPasswordResetCommand;
|
||||
use App\Administration\Application\Command\RequestPasswordReset\RequestPasswordResetHandler;
|
||||
use App\Administration\Infrastructure\Api\Resource\RequestPasswordResetInput;
|
||||
use App\Administration\Infrastructure\Api\Resource\RequestPasswordResetOutput;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantNotFoundException;
|
||||
use App\Shared\Infrastructure\Tenant\TenantResolver;
|
||||
use Override;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
|
||||
use Symfony\Component\RateLimiter\RateLimiterFactory;
|
||||
|
||||
/**
|
||||
* API Platform processor for password reset request.
|
||||
*
|
||||
* Security:
|
||||
* - Always returns success to prevent email enumeration
|
||||
* - Rate limited: 3 requests/hour per email, 10 requests/hour per IP
|
||||
*
|
||||
* @implements ProcessorInterface<RequestPasswordResetInput, RequestPasswordResetOutput>
|
||||
*/
|
||||
final readonly class RequestPasswordResetProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private RequestPasswordResetHandler $handler,
|
||||
private RequestStack $requestStack,
|
||||
private TenantResolver $tenantResolver,
|
||||
private RateLimiterFactory $passwordResetByEmailLimiter,
|
||||
private RateLimiterFactory $passwordResetByIpLimiter,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param RequestPasswordResetInput $data
|
||||
*/
|
||||
#[Override]
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): RequestPasswordResetOutput
|
||||
{
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
|
||||
if ($request === null) {
|
||||
throw new BadRequestHttpException('Request not available');
|
||||
}
|
||||
|
||||
$email = strtolower(trim($data->email));
|
||||
$ip = $request->getClientIp() ?? 'unknown';
|
||||
$tenantId = $this->resolveCurrentTenant();
|
||||
|
||||
// Check rate limits - returns false if email limit exceeded (skip processing silently)
|
||||
// Tenant is included in email key to isolate rate limits per establishment
|
||||
if (!$this->checkRateLimits($tenantId, $email, $ip)) {
|
||||
// Email rate limit exceeded - return success without processing
|
||||
// This prevents email enumeration while stopping token flooding
|
||||
return new RequestPasswordResetOutput();
|
||||
}
|
||||
|
||||
$command = new RequestPasswordResetCommand(
|
||||
email: $email,
|
||||
tenantId: $tenantId,
|
||||
);
|
||||
|
||||
// Handler always succeeds (no exceptions) - this is by design
|
||||
($this->handler)($command);
|
||||
|
||||
// Always return success message (prevents email enumeration)
|
||||
return new RequestPasswordResetOutput();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check rate limits for email and IP.
|
||||
*
|
||||
* @throws TooManyRequestsHttpException if IP rate limit exceeded
|
||||
*
|
||||
* @return bool true if processing should continue, false if email limit exceeded
|
||||
*/
|
||||
private function checkRateLimits(TenantId $tenantId, string $email, string $ip): bool
|
||||
{
|
||||
// Check IP rate limit first (throws exception - visible to user)
|
||||
$ipLimiter = $this->passwordResetByIpLimiter->create($ip);
|
||||
$ipLimit = $ipLimiter->consume();
|
||||
|
||||
if (!$ipLimit->isAccepted()) {
|
||||
throw new TooManyRequestsHttpException(
|
||||
$ipLimit->getRetryAfter()->getTimestamp() - time(),
|
||||
'Trop de demandes. Veuillez réessayer plus tard.',
|
||||
);
|
||||
}
|
||||
|
||||
// Check email rate limit (silent - prevents enumeration)
|
||||
// Key includes tenant to isolate rate limits per establishment
|
||||
$emailLimiter = $this->passwordResetByEmailLimiter->create("$tenantId:$email");
|
||||
$emailLimit = $emailLimiter->consume();
|
||||
|
||||
if (!$emailLimit->isAccepted()) {
|
||||
// Return false to skip processing silently
|
||||
// User sees success but no token is generated (prevents flooding)
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the current tenant from the request host.
|
||||
*
|
||||
* For localhost (dev), uses a default tenant.
|
||||
*
|
||||
* @throws BadRequestHttpException if tenant cannot be resolved
|
||||
*/
|
||||
private function resolveCurrentTenant(): TenantId
|
||||
{
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
|
||||
if ($request === null) {
|
||||
throw new BadRequestHttpException('Request not available');
|
||||
}
|
||||
|
||||
$host = $request->getHost();
|
||||
|
||||
// Skip validation for localhost (dev environment uses ecole-alpha tenant)
|
||||
if ($host === 'localhost' || $host === '127.0.0.1') {
|
||||
// In dev mode, use ecole-alpha tenant
|
||||
return TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->tenantResolver->resolve($host)->tenantId;
|
||||
} catch (TenantNotFoundException) {
|
||||
throw new BadRequestHttpException('Établissement non reconnu.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Administration\Application\Command\ResetPassword\ResetPasswordCommand;
|
||||
use App\Administration\Application\Command\ResetPassword\ResetPasswordHandler;
|
||||
use App\Administration\Domain\Exception\PasswordResetTokenAlreadyUsedException;
|
||||
use App\Administration\Domain\Exception\PasswordResetTokenExpiredException;
|
||||
use App\Administration\Domain\Exception\PasswordResetTokenNotFoundException;
|
||||
use App\Administration\Domain\Exception\TokenConsumptionInProgressException;
|
||||
use App\Administration\Infrastructure\Api\Resource\ResetPasswordInput;
|
||||
use App\Administration\Infrastructure\Api\Resource\ResetPasswordOutput;
|
||||
use Override;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\GoneHttpException;
|
||||
|
||||
/**
|
||||
* API Platform processor for password reset.
|
||||
*
|
||||
* Handles errors:
|
||||
* - Token not found → 400 Bad Request
|
||||
* - Token expired → 410 Gone
|
||||
* - Token already used → 410 Gone
|
||||
* - Concurrent consumption → 409 Conflict (client should retry)
|
||||
*
|
||||
* @implements ProcessorInterface<ResetPasswordInput, ResetPasswordOutput>
|
||||
*/
|
||||
final readonly class ResetPasswordProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private ResetPasswordHandler $handler,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ResetPasswordInput $data
|
||||
*/
|
||||
#[Override]
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ResetPasswordOutput
|
||||
{
|
||||
$command = new ResetPasswordCommand(
|
||||
token: $data->token,
|
||||
newPassword: $data->password,
|
||||
);
|
||||
|
||||
try {
|
||||
($this->handler)($command);
|
||||
} catch (PasswordResetTokenNotFoundException) {
|
||||
throw new BadRequestHttpException('Le lien de réinitialisation est invalide.');
|
||||
} catch (PasswordResetTokenExpiredException) {
|
||||
throw new GoneHttpException('Le lien de réinitialisation a expiré. Veuillez faire une nouvelle demande.');
|
||||
} catch (PasswordResetTokenAlreadyUsedException) {
|
||||
throw new GoneHttpException('Ce lien a déjà été utilisé. Veuillez faire une nouvelle demande si nécessaire.');
|
||||
} catch (TokenConsumptionInProgressException) {
|
||||
throw new ConflictHttpException('Requête en cours de traitement. Veuillez réessayer.');
|
||||
}
|
||||
|
||||
return new ResetPasswordOutput();
|
||||
}
|
||||
}
|
||||
@@ -41,9 +41,17 @@ final class ActivateAccountInput
|
||||
pattern: '/[A-Z]/',
|
||||
message: 'Le mot de passe doit contenir au moins une majuscule.',
|
||||
)]
|
||||
#[Assert\Regex(
|
||||
pattern: '/[a-z]/',
|
||||
message: 'Le mot de passe doit contenir au moins une minuscule.',
|
||||
)]
|
||||
#[Assert\Regex(
|
||||
pattern: '/[0-9]/',
|
||||
message: 'Le mot de passe doit contenir au moins un chiffre.',
|
||||
)]
|
||||
#[Assert\Regex(
|
||||
pattern: '/[^A-Za-z0-9]/',
|
||||
message: 'Le mot de passe doit contenir au moins un caractère spécial.',
|
||||
)]
|
||||
public string $password = '';
|
||||
}
|
||||
|
||||
@@ -9,11 +9,11 @@ use ApiPlatform\Metadata\Post;
|
||||
use App\Administration\Infrastructure\Api\Processor\RefreshTokenProcessor;
|
||||
|
||||
/**
|
||||
* Resource API Platform pour le rafraîchissement de token.
|
||||
* API Platform resource for token refresh.
|
||||
*
|
||||
* Le refresh token est lu depuis le cookie HttpOnly, pas du body.
|
||||
* The refresh token is read from the HttpOnly cookie, not from the body.
|
||||
*
|
||||
* @see Story 1.4 - T6: Endpoint Refresh Token
|
||||
* @see Story 1.4 - T6: Refresh Token Endpoint
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
@@ -22,11 +22,11 @@ use App\Administration\Infrastructure\Api\Processor\RefreshTokenProcessor;
|
||||
processor: RefreshTokenProcessor::class,
|
||||
output: RefreshTokenOutput::class,
|
||||
name: 'refresh_token',
|
||||
description: 'Utilise le refresh token (cookie HttpOnly) pour obtenir un nouveau JWT. Le refresh token est automatiquement rotaté.',
|
||||
description: 'Uses the refresh token (HttpOnly cookie) to obtain a new JWT. The refresh token is automatically rotated.',
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class RefreshTokenInput
|
||||
{
|
||||
// Pas de propriétés - le refresh token vient du cookie
|
||||
// No properties - the refresh token comes from the cookie
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Administration\Infrastructure\Api\Processor\RequestPasswordResetProcessor;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* API Resource for password reset request.
|
||||
*
|
||||
* This endpoint accepts an email and generates a reset token.
|
||||
* Always returns success to prevent email enumeration.
|
||||
*/
|
||||
#[ApiResource(
|
||||
shortName: 'PasswordResetRequest',
|
||||
operations: [
|
||||
new Post(
|
||||
uriTemplate: '/password/forgot',
|
||||
processor: RequestPasswordResetProcessor::class,
|
||||
output: RequestPasswordResetOutput::class,
|
||||
name: 'request_password_reset',
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class RequestPasswordResetInput
|
||||
{
|
||||
#[Assert\NotBlank(message: 'L\'adresse email est requise.')]
|
||||
#[Assert\Email(message: 'L\'adresse email n\'est pas valide.')]
|
||||
public string $email = '';
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Resource;
|
||||
|
||||
/**
|
||||
* Output for password reset request.
|
||||
*
|
||||
* Always returns a generic success message to prevent email enumeration.
|
||||
*/
|
||||
final readonly class RequestPasswordResetOutput
|
||||
{
|
||||
public function __construct(
|
||||
public string $message = 'Si cette adresse email est associée à un compte, un email de réinitialisation a été envoyé.',
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Administration\Infrastructure\Api\Processor\ResetPasswordProcessor;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* Input DTO for password reset.
|
||||
*
|
||||
* Endpoint: POST /api/password/reset
|
||||
*/
|
||||
#[ApiResource(
|
||||
shortName: 'PasswordReset',
|
||||
operations: [
|
||||
new Post(
|
||||
uriTemplate: '/password/reset',
|
||||
processor: ResetPasswordProcessor::class,
|
||||
output: ResetPasswordOutput::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class ResetPasswordInput
|
||||
{
|
||||
#[Assert\NotBlank(message: 'Le token est requis.')]
|
||||
public string $token = '';
|
||||
|
||||
#[Assert\NotBlank(message: 'Le mot de passe est requis.')]
|
||||
#[Assert\Length(
|
||||
min: 8,
|
||||
minMessage: 'Le mot de passe doit contenir au moins {{ limit }} caractères.',
|
||||
)]
|
||||
#[Assert\Regex(
|
||||
pattern: '/[A-Z]/',
|
||||
message: 'Le mot de passe doit contenir au moins une majuscule.',
|
||||
)]
|
||||
#[Assert\Regex(
|
||||
pattern: '/[a-z]/',
|
||||
message: 'Le mot de passe doit contenir au moins une minuscule.',
|
||||
)]
|
||||
#[Assert\Regex(
|
||||
pattern: '/[0-9]/',
|
||||
message: 'Le mot de passe doit contenir au moins un chiffre.',
|
||||
)]
|
||||
#[Assert\Regex(
|
||||
pattern: '/[^A-Za-z0-9]/',
|
||||
message: 'Le mot de passe doit contenir au moins un caractère spécial.',
|
||||
)]
|
||||
public string $password = '';
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Resource;
|
||||
|
||||
/**
|
||||
* Output DTO for password reset.
|
||||
*
|
||||
* Returns a success message after password is successfully reset.
|
||||
*/
|
||||
final readonly class ResetPasswordOutput
|
||||
{
|
||||
public string $message;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->message = 'Votre mot de passe a été réinitialisé avec succès. Vous pouvez maintenant vous connecter.';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Console;
|
||||
|
||||
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetToken;
|
||||
use App\Administration\Domain\Model\User\Email;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\StatutCompte;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Domain\Policy\ConsentementParentalPolicy;
|
||||
use App\Administration\Domain\Repository\PasswordResetTokenRepository;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Infrastructure\Tenant\TenantNotFoundException;
|
||||
use App\Shared\Infrastructure\Tenant\TenantRegistry;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
/**
|
||||
* Creates a test user with an activated account and a password reset token.
|
||||
*
|
||||
* This command is for E2E testing only. It creates:
|
||||
* - An activated user (so they can use the reset password flow)
|
||||
* - A password reset token for that user
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:dev:create-test-password-reset-token',
|
||||
description: 'Creates a test user and password reset token for E2E testing',
|
||||
)]
|
||||
final class CreateTestPasswordResetTokenCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PasswordResetTokenRepository $passwordResetTokenRepository,
|
||||
private readonly UserRepository $userRepository,
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
private readonly TenantRegistry $tenantRegistry,
|
||||
private readonly ConsentementParentalPolicy $consentementPolicy,
|
||||
private readonly Clock $clock,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption('email', null, InputOption::VALUE_OPTIONAL, 'Email address', 'reset-test@example.com')
|
||||
->addOption('tenant', null, InputOption::VALUE_OPTIONAL, 'Tenant subdomain', 'ecole-alpha')
|
||||
->addOption('base-url', null, InputOption::VALUE_OPTIONAL, 'Frontend base URL', 'http://localhost:5174')
|
||||
->addOption('expired', null, InputOption::VALUE_NONE, 'Create an expired token (for testing expired flow)');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
/** @var non-empty-string $email */
|
||||
$email = $input->getOption('email');
|
||||
/** @var string $tenantSubdomain */
|
||||
$tenantSubdomain = $input->getOption('tenant');
|
||||
/** @var string $baseUrlOption */
|
||||
$baseUrlOption = $input->getOption('base-url');
|
||||
$baseUrl = rtrim($baseUrlOption, '/');
|
||||
$expired = $input->getOption('expired');
|
||||
|
||||
// Resolve tenant
|
||||
try {
|
||||
$tenantConfig = $this->tenantRegistry->getBySubdomain($tenantSubdomain);
|
||||
$tenantId = $tenantConfig->tenantId;
|
||||
} catch (TenantNotFoundException) {
|
||||
$io->error(sprintf('Tenant "%s" not found.', $tenantSubdomain));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$now = $this->clock->now();
|
||||
|
||||
// Check if user already exists
|
||||
$user = $this->userRepository->findByEmail(new Email($email), $tenantId);
|
||||
|
||||
if ($user === null) {
|
||||
// Create an activated user (password reset requires an existing activated account)
|
||||
$user = User::creer(
|
||||
email: new Email($email),
|
||||
role: Role::PARENT,
|
||||
tenantId: $tenantId,
|
||||
schoolName: 'École de Test E2E',
|
||||
dateNaissance: null,
|
||||
createdAt: $now,
|
||||
);
|
||||
|
||||
// Create SecurityUser adapter for password hashing
|
||||
$securityUser = new SecurityUser(
|
||||
userId: $user->id,
|
||||
email: $email,
|
||||
hashedPassword: '',
|
||||
tenantId: $tenantId,
|
||||
roles: [$user->role->value],
|
||||
);
|
||||
|
||||
// Activate the user with a password
|
||||
$hashedPassword = $this->passwordHasher->hashPassword($securityUser, 'OldPassword123!');
|
||||
$user->activer($hashedPassword, $now, $this->consentementPolicy);
|
||||
|
||||
$this->userRepository->save($user);
|
||||
$io->note('Created new activated user');
|
||||
} elseif ($user->statut !== StatutCompte::ACTIF) {
|
||||
// Create SecurityUser adapter for password hashing
|
||||
$securityUser = new SecurityUser(
|
||||
userId: $user->id,
|
||||
email: $email,
|
||||
hashedPassword: '',
|
||||
tenantId: $tenantId,
|
||||
roles: [$user->role->value],
|
||||
);
|
||||
|
||||
// Activate existing user if not active
|
||||
$hashedPassword = $this->passwordHasher->hashPassword($securityUser, 'OldPassword123!');
|
||||
$user->activer($hashedPassword, $now, $this->consentementPolicy);
|
||||
$this->userRepository->save($user);
|
||||
$io->note('Activated existing user');
|
||||
}
|
||||
|
||||
// Create password reset token
|
||||
$createdAt = $expired
|
||||
? $now->modify('-2 hours') // Expired: created 2 hours ago (tokens expire after 1 hour)
|
||||
: $now;
|
||||
|
||||
$token = PasswordResetToken::generate(
|
||||
userId: (string) $user->id,
|
||||
email: $email,
|
||||
tenantId: $tenantId,
|
||||
createdAt: $createdAt,
|
||||
);
|
||||
|
||||
$this->passwordResetTokenRepository->save($token);
|
||||
|
||||
$resetUrl = sprintf('%s/reset-password/%s', $baseUrl, $token->tokenValue);
|
||||
|
||||
$io->success('Test password reset token created!');
|
||||
|
||||
$io->table(
|
||||
['Property', 'Value'],
|
||||
[
|
||||
['User ID', (string) $user->id],
|
||||
['Email', $email],
|
||||
['Tenant', $tenantSubdomain],
|
||||
['Token', $token->tokenValue],
|
||||
['Created', $token->createdAt->format('Y-m-d H:i:s')],
|
||||
['Expires', $token->expiresAt->format('Y-m-d H:i:s')],
|
||||
['Expired', $expired ? 'Yes' : 'No'],
|
||||
]
|
||||
);
|
||||
|
||||
$io->writeln('');
|
||||
$io->writeln(sprintf('<info>Reset URL:</info> <href=%s>%s</>', $resetUrl, $resetUrl));
|
||||
$io->writeln('');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Messaging;
|
||||
|
||||
use App\Administration\Domain\Event\MotDePasseChange;
|
||||
use Symfony\Component\Mailer\MailerInterface;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
use Symfony\Component\Mime\Email;
|
||||
use Twig\Environment;
|
||||
|
||||
/**
|
||||
* Sends a confirmation email when a password is changed.
|
||||
*
|
||||
* This handler listens for MotDePasseChange events and sends an email
|
||||
* to the user confirming their password has been reset.
|
||||
*/
|
||||
#[AsMessageHandler(bus: 'event.bus')]
|
||||
final readonly class SendPasswordResetConfirmationHandler
|
||||
{
|
||||
public function __construct(
|
||||
private MailerInterface $mailer,
|
||||
private Environment $twig,
|
||||
private string $appUrl,
|
||||
private string $fromEmail = 'noreply@classeo.fr',
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(MotDePasseChange $event): void
|
||||
{
|
||||
$html = $this->twig->render('emails/password_reset_confirmation.html.twig', [
|
||||
'email' => $event->email,
|
||||
'changedAt' => $event->occurredOn()->format('d/m/Y à H:i'),
|
||||
'loginUrl' => rtrim($this->appUrl, '/') . '/login',
|
||||
]);
|
||||
|
||||
$email = (new Email())
|
||||
->from($this->fromEmail)
|
||||
->to($event->email)
|
||||
->subject('Votre mot de passe Classeo a été modifié')
|
||||
->html($html);
|
||||
|
||||
$this->mailer->send($email);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Messaging;
|
||||
|
||||
use App\Administration\Domain\Event\PasswordResetTokenGenerated;
|
||||
use Symfony\Component\Mailer\MailerInterface;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
use Symfony\Component\Mime\Email;
|
||||
use Twig\Environment;
|
||||
|
||||
/**
|
||||
* Sends a password reset email when a reset token is generated.
|
||||
*
|
||||
* This handler listens for PasswordResetTokenGenerated events and sends
|
||||
* an email to the user with a link to reset their password.
|
||||
*/
|
||||
#[AsMessageHandler(bus: 'event.bus')]
|
||||
final readonly class SendPasswordResetEmailHandler
|
||||
{
|
||||
public function __construct(
|
||||
private MailerInterface $mailer,
|
||||
private Environment $twig,
|
||||
private string $appUrl,
|
||||
private string $fromEmail = 'noreply@classeo.fr',
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(PasswordResetTokenGenerated $event): void
|
||||
{
|
||||
$resetUrl = rtrim($this->appUrl, '/') . '/reset-password/' . $event->tokenValue;
|
||||
|
||||
$html = $this->twig->render('emails/password_reset.html.twig', [
|
||||
'email' => $event->email,
|
||||
'resetUrl' => $resetUrl,
|
||||
]);
|
||||
|
||||
$email = (new Email())
|
||||
->from($this->fromEmail)
|
||||
->to($event->email)
|
||||
->subject('Réinitialisation de votre mot de passe Classeo')
|
||||
->html($html);
|
||||
|
||||
$this->mailer->send($email);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
*/
|
||||
|
||||
@@ -17,15 +17,15 @@ use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Symfony\Component\Security\Core\User\UserProviderInterface;
|
||||
|
||||
/**
|
||||
* Charge les utilisateurs depuis le domaine pour l'authentification Symfony.
|
||||
* Loads users from the domain for Symfony authentication.
|
||||
*
|
||||
* Ce provider fait le pont entre Symfony Security et notre Domain Layer.
|
||||
* Il ne révèle jamais si un utilisateur existe ou non pour des raisons de sécurité.
|
||||
* Les utilisateurs sont isolés par tenant (établissement).
|
||||
* This provider bridges Symfony Security with our Domain Layer.
|
||||
* It never reveals whether a user exists or not for security reasons.
|
||||
* Users are isolated by tenant (school).
|
||||
*
|
||||
* @implements UserProviderInterface<SecurityUser>
|
||||
*
|
||||
* @see Story 1.4 - Connexion utilisateur (AC2: pas de révélation d'existence du compte)
|
||||
* @see Story 1.4 - User login (AC2: no account existence disclosure)
|
||||
*/
|
||||
final readonly class DatabaseUserProvider implements UserProviderInterface
|
||||
{
|
||||
@@ -50,12 +50,12 @@ final readonly class DatabaseUserProvider implements UserProviderInterface
|
||||
|
||||
$user = $this->userRepository->findByEmail($email, $tenantId);
|
||||
|
||||
// Message générique pour ne pas révéler l'existence du compte
|
||||
// Generic message to not reveal account existence
|
||||
if ($user === null) {
|
||||
throw new SymfonyUserNotFoundException();
|
||||
}
|
||||
|
||||
// Ne pas permettre la connexion si le compte n'est pas actif
|
||||
// Do not allow login if the account is not active
|
||||
if (!$user->peutSeConnecter()) {
|
||||
throw new SymfonyUserNotFoundException();
|
||||
}
|
||||
|
||||
@@ -7,15 +7,15 @@ namespace App\Administration\Infrastructure\Security;
|
||||
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTCreatedEvent;
|
||||
|
||||
/**
|
||||
* Enrichit le payload JWT avec les claims métier.
|
||||
* Enriches the JWT payload with business claims.
|
||||
*
|
||||
* Claims ajoutés:
|
||||
* - sub: Email de l'utilisateur (identifiant Symfony Security)
|
||||
* - user_id: UUID de l'utilisateur (pour les consommateurs d'API)
|
||||
* - tenant_id: UUID du tenant pour l'isolation multi-tenant
|
||||
* - roles: Liste des rôles Symfony pour l'autorisation
|
||||
* Added claims:
|
||||
* - sub: User email (Symfony Security identifier)
|
||||
* - user_id: User UUID (for API consumers)
|
||||
* - tenant_id: Tenant UUID for multi-tenant isolation
|
||||
* - roles: List of Symfony roles for authorization
|
||||
*
|
||||
* @see Story 1.4 - Connexion utilisateur
|
||||
* @see Story 1.4 - User login
|
||||
*/
|
||||
final readonly class JwtPayloadEnricher
|
||||
{
|
||||
@@ -29,7 +29,7 @@ final readonly class JwtPayloadEnricher
|
||||
|
||||
$payload = $event->getData();
|
||||
|
||||
// Claims métier pour l'isolation multi-tenant et l'autorisation
|
||||
// Business claims for multi-tenant isolation and authorization
|
||||
$payload['user_id'] = $user->userId();
|
||||
$payload['tenant_id'] = $user->tenantId();
|
||||
$payload['roles'] = $user->getRoles();
|
||||
|
||||
@@ -22,11 +22,11 @@ use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||
use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface;
|
||||
|
||||
/**
|
||||
* Gère les échecs de login : rate limiting Fibonacci, audit, messages user-friendly.
|
||||
* Handles login failures: Fibonacci rate limiting, audit, user-friendly messages.
|
||||
*
|
||||
* Important: Ne jamais révéler si l'email existe ou non (AC2).
|
||||
* Important: Never reveal whether the email exists or not (AC2).
|
||||
*
|
||||
* @see Story 1.4 - T5: Endpoint Login Backend
|
||||
* @see Story 1.4 - T5: Backend Login Endpoint
|
||||
*/
|
||||
final readonly class LoginFailureHandler implements AuthenticationFailureHandlerInterface
|
||||
{
|
||||
@@ -46,10 +46,10 @@ final readonly class LoginFailureHandler implements AuthenticationFailureHandler
|
||||
$ipAddress = $request->getClientIp() ?? 'unknown';
|
||||
$userAgent = $request->headers->get('User-Agent', 'unknown');
|
||||
|
||||
// Enregistrer l'échec et obtenir le nouvel état
|
||||
// Record the failure and get the new state
|
||||
$result = $this->rateLimiter->recordFailure($request, $email);
|
||||
|
||||
// Émettre l'événement d'échec
|
||||
// Dispatch the failure event
|
||||
$this->eventBus->dispatch(new ConnexionEchouee(
|
||||
email: $email,
|
||||
ipAddress: $ipAddress,
|
||||
@@ -58,7 +58,7 @@ final readonly class LoginFailureHandler implements AuthenticationFailureHandler
|
||||
occurredOn: $this->clock->now(),
|
||||
));
|
||||
|
||||
// Si l'IP vient d'être bloquée
|
||||
// If the IP was just blocked
|
||||
if ($result->ipBlocked) {
|
||||
$this->eventBus->dispatch(new CompteBloqueTemporairement(
|
||||
email: $email,
|
||||
@@ -72,7 +72,7 @@ final readonly class LoginFailureHandler implements AuthenticationFailureHandler
|
||||
return $this->createBlockedResponse($result);
|
||||
}
|
||||
|
||||
// Réponse standard d'échec avec infos sur le délai et CAPTCHA
|
||||
// Standard failure response with delay and CAPTCHA info
|
||||
return $this->createFailureResponse($result);
|
||||
}
|
||||
|
||||
@@ -106,13 +106,13 @@ final readonly class LoginFailureHandler implements AuthenticationFailureHandler
|
||||
'attempts' => $result->attempts,
|
||||
];
|
||||
|
||||
// Ajouter le délai si applicable
|
||||
// Add delay if applicable
|
||||
if ($result->delaySeconds > 0) {
|
||||
$data['delay'] = $result->delaySeconds;
|
||||
$data['delayFormatted'] = $result->getFormattedDelay();
|
||||
}
|
||||
|
||||
// Indiquer si CAPTCHA requis pour la prochaine tentative
|
||||
// Indicate if CAPTCHA is required for the next attempt
|
||||
if ($result->requiresCaptcha) {
|
||||
$data['captchaRequired'] = true;
|
||||
}
|
||||
|
||||
@@ -17,9 +17,9 @@ use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/**
|
||||
* Gère les actions post-login réussi : refresh token, reset rate limit, audit.
|
||||
* Handles post-login success actions: refresh token, reset rate limit, audit.
|
||||
*
|
||||
* @see Story 1.4 - T5: Endpoint Login Backend
|
||||
* @see Story 1.4 - T5: Backend Login Endpoint
|
||||
*/
|
||||
final readonly class LoginSuccessHandler
|
||||
{
|
||||
@@ -48,13 +48,13 @@ final readonly class LoginSuccessHandler
|
||||
$ipAddress = $request->getClientIp() ?? 'unknown';
|
||||
$userAgent = $request->headers->get('User-Agent', 'unknown');
|
||||
|
||||
// Créer le device fingerprint
|
||||
// Create the device fingerprint
|
||||
$fingerprint = DeviceFingerprint::fromRequest($userAgent, $ipAddress);
|
||||
|
||||
// Détecter si c'est un mobile (pour le TTL du refresh token)
|
||||
// Detect if this is a mobile device (for refresh token TTL)
|
||||
$isMobile = str_contains(strtolower($userAgent), 'mobile');
|
||||
|
||||
// Créer le refresh token
|
||||
// Create the refresh token
|
||||
$refreshToken = $this->refreshTokenManager->create(
|
||||
$userId,
|
||||
$tenantId,
|
||||
@@ -62,7 +62,7 @@ final readonly class LoginSuccessHandler
|
||||
$isMobile,
|
||||
);
|
||||
|
||||
// Ajouter le refresh token en cookie HttpOnly
|
||||
// Add the refresh token as HttpOnly cookie
|
||||
$cookie = Cookie::create('refresh_token')
|
||||
->withValue($refreshToken->toTokenString())
|
||||
->withExpires($refreshToken->expiresAt)
|
||||
@@ -73,10 +73,10 @@ final readonly class LoginSuccessHandler
|
||||
|
||||
$response->headers->setCookie($cookie);
|
||||
|
||||
// Reset le rate limiter pour cet email
|
||||
// Reset the rate limiter for this email
|
||||
$this->rateLimiter->reset($email);
|
||||
|
||||
// Émettre l'événement de connexion réussie
|
||||
// Dispatch the successful login event
|
||||
$this->eventBus->dispatch(new ConnexionReussie(
|
||||
userId: $user->userId(),
|
||||
email: $email,
|
||||
|
||||
@@ -10,12 +10,12 @@ use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
/**
|
||||
* Adapter entre le Domain User et Symfony Security.
|
||||
* Adapter between the Domain User and Symfony Security.
|
||||
*
|
||||
* Ce DTO est utilisé par le système d'authentification Symfony.
|
||||
* Il ne contient pas de logique métier - c'est un simple transporteur de données.
|
||||
* This DTO is used by the Symfony authentication system.
|
||||
* It contains no business logic - it's a simple data carrier.
|
||||
*
|
||||
* @see Story 1.4 - Connexion utilisateur
|
||||
* @see Story 1.4 - User login
|
||||
*/
|
||||
final readonly class SecurityUser implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
{
|
||||
@@ -24,7 +24,7 @@ final readonly class SecurityUser implements UserInterface, PasswordAuthenticate
|
||||
|
||||
/**
|
||||
* @param non-empty-string $email
|
||||
* @param list<string> $roles Les rôles Symfony (ROLE_*)
|
||||
* @param list<string> $roles Symfony roles (ROLE_*)
|
||||
*/
|
||||
public function __construct(
|
||||
private UserId $userId,
|
||||
@@ -74,6 +74,6 @@ final readonly class SecurityUser implements UserInterface, PasswordAuthenticate
|
||||
|
||||
public function eraseCredentials(): void
|
||||
{
|
||||
// Rien à effacer, les données sont immutables
|
||||
// Nothing to erase, data is immutable
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ final readonly class CorrelationId
|
||||
|
||||
public static function generate(): self
|
||||
{
|
||||
return new self(Uuid::uuid4()->toString());
|
||||
return new self(Uuid::uuid7()->toString());
|
||||
}
|
||||
|
||||
public static function fromString(string $value): self
|
||||
|
||||
@@ -19,7 +19,7 @@ abstract readonly class EntityId
|
||||
|
||||
public static function generate(): static
|
||||
{
|
||||
return new static(Uuid::uuid4());
|
||||
return new static(Uuid::uuid7());
|
||||
}
|
||||
|
||||
public static function fromString(string $value): static
|
||||
|
||||
@@ -34,6 +34,8 @@ final readonly class TenantMiddleware implements EventSubscriberInterface
|
||||
'/api/activation-tokens',
|
||||
'/api/activate',
|
||||
'/api/login',
|
||||
'/api/password',
|
||||
'/api/token',
|
||||
'/_profiler',
|
||||
'/_wdt',
|
||||
'/_error',
|
||||
|
||||
Reference in New Issue
Block a user