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

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

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

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

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

View File

@@ -0,0 +1,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;
}
}