feat: Réinitialisation de mot de passe avec tokens sécurisés

Implémentation complète du flux de réinitialisation de mot de passe (Story 1.5):

Backend:
- Aggregate PasswordResetToken avec TTL 1h, UUID v7, usage unique
- Endpoint POST /api/password/forgot avec rate limiting (3/h par email, 10/h par IP)
- Endpoint POST /api/password/reset avec validation token
- Templates email (demande + confirmation)
- Repository Redis avec TTL 2h pour distinguer expiré/invalide

Frontend:
- Page /mot-de-passe-oublie avec message générique (anti-énumération)
- Page /reset-password/[token] avec validation temps réel des critères
- Gestion erreurs: token invalide, expiré, déjà utilisé

Tests:
- 14 tests unitaires PasswordResetToken
- 7 tests unitaires RequestPasswordResetHandler
- 7 tests unitaires ResetPasswordHandler
- Tests E2E Playwright pour le flux complet
This commit is contained in:
2026-02-01 23:15:01 +01:00
parent b7354b8448
commit affad287f9
71 changed files with 4829 additions and 222 deletions

View File

@@ -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('')

View File

@@ -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');
}
}
}

View File

@@ -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.');
}
}
}

View File

@@ -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();
}
}

View File

@@ -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 = '';
}

View File

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

View File

@@ -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 = '';
}

View File

@@ -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é.',
) {
}
}

View File

@@ -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 = '';
}

View File

@@ -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.';
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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();
}

View File

@@ -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();

View File

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

View File

@@ -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,

View File

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