feat: Activation de compte utilisateur avec validation token
L'inscription Classeo se fait via invitation : un admin crée un compte, l'utilisateur reçoit un lien d'activation par email pour définir son mot de passe. Ce flow sécurisé évite les inscriptions non autorisées et garantit que seuls les utilisateurs légitimes accèdent au système. Points clés de l'implémentation : - Tokens d'activation à usage unique stockés en cache (Redis/filesystem) - Validation du consentement parental pour les mineurs < 15 ans (RGPD) - L'échec d'activation ne consume pas le token (retry possible) - Users dans un cache séparé sans TTL (pas d'expiration) - Hot reload en dev (FrankenPHP sans mode worker) Story: 1.3 - Inscription et activation de compte
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Console;
|
||||
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationToken;
|
||||
use App\Administration\Domain\Model\User\Email;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Domain\Repository\ActivationTokenRepository;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
|
||||
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;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:dev:create-test-activation-token',
|
||||
description: 'Creates a test user and activation token for development',
|
||||
)]
|
||||
final class CreateTestActivationTokenCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ActivationTokenRepository $activationTokenRepository,
|
||||
private readonly UserRepository $userRepository,
|
||||
private readonly Clock $clock,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption('email', null, InputOption::VALUE_OPTIONAL, 'Email address', 'test@example.com')
|
||||
->addOption('role', null, InputOption::VALUE_OPTIONAL, 'User role (PARENT, ELEVE, PROF, ADMIN)', 'PARENT')
|
||||
->addOption('school', null, InputOption::VALUE_OPTIONAL, 'School name', 'École de Test')
|
||||
->addOption('minor', null, InputOption::VALUE_NONE, 'Create a minor user (requires parental consent)')
|
||||
->addOption('base-url', null, InputOption::VALUE_OPTIONAL, 'Frontend base URL', 'http://localhost:5173');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
/** @var string $email */
|
||||
$email = $input->getOption('email');
|
||||
/** @var string $roleOption */
|
||||
$roleOption = $input->getOption('role');
|
||||
$roleInput = strtoupper($roleOption);
|
||||
/** @var string $schoolName */
|
||||
$schoolName = $input->getOption('school');
|
||||
$isMinor = $input->getOption('minor');
|
||||
/** @var string $baseUrlOption */
|
||||
$baseUrlOption = $input->getOption('base-url');
|
||||
$baseUrl = rtrim($baseUrlOption, '/');
|
||||
|
||||
// Convert short role name to full Symfony role format
|
||||
$roleName = str_starts_with($roleInput, 'ROLE_') ? $roleInput : 'ROLE_' . $roleInput;
|
||||
|
||||
$role = Role::tryFrom($roleName);
|
||||
if ($role === null) {
|
||||
$validRoles = array_map(static fn (Role $r) => str_replace('ROLE_', '', $r->value), Role::cases());
|
||||
$io->error(sprintf(
|
||||
'Invalid role "%s". Valid roles: %s',
|
||||
$roleInput,
|
||||
implode(', ', $validRoles)
|
||||
));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$now = $this->clock->now();
|
||||
$tenantId = TenantId::fromString('550e8400-e29b-41d4-a716-446655440001');
|
||||
|
||||
// Create user
|
||||
$dateNaissance = $isMinor
|
||||
? $now->modify('-13 years') // 13 ans = mineur
|
||||
: null;
|
||||
|
||||
$user = User::creer(
|
||||
email: new Email($email),
|
||||
role: $role,
|
||||
tenantId: $tenantId,
|
||||
schoolName: $schoolName,
|
||||
dateNaissance: $dateNaissance,
|
||||
createdAt: $now,
|
||||
);
|
||||
|
||||
$this->userRepository->save($user);
|
||||
|
||||
// Create activation token
|
||||
$token = ActivationToken::generate(
|
||||
userId: (string) $user->id,
|
||||
email: $email,
|
||||
tenantId: $tenantId,
|
||||
role: $role->value,
|
||||
schoolName: $schoolName,
|
||||
createdAt: $now,
|
||||
);
|
||||
|
||||
$this->activationTokenRepository->save($token);
|
||||
|
||||
$activationUrl = sprintf('%s/activate/%s', $baseUrl, $token->tokenValue);
|
||||
|
||||
$io->success('Test activation token created successfully!');
|
||||
|
||||
$io->table(
|
||||
['Property', 'Value'],
|
||||
[
|
||||
['User ID', (string) $user->id],
|
||||
['Email', $email],
|
||||
['Role', $role->value],
|
||||
['School', $schoolName],
|
||||
['Minor', $isMinor ? 'Yes (requires parental consent)' : 'No'],
|
||||
['Token', $token->tokenValue],
|
||||
['Expires', $token->expiresAt->format('Y-m-d H:i:s')],
|
||||
]
|
||||
);
|
||||
|
||||
$io->writeln('');
|
||||
$io->writeln(sprintf('<info>Activation URL:</info> <href=%s>%s</>', $activationUrl, $activationUrl));
|
||||
$io->writeln('');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user