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