feat: Connexion utilisateur avec sécurité renforcée
Implémente la Story 1.4 du système d'authentification avec plusieurs couches de protection contre les attaques par force brute. Sécurité backend : - Authentification JWT avec access token (15min) + refresh token (7j) - Rotation automatique des refresh tokens avec détection de replay - Rate limiting progressif par IP (délai Fibonacci après échecs) - Intégration Cloudflare Turnstile CAPTCHA après 5 tentatives - Alerte email à l'utilisateur après blocage temporaire - Isolation multi-tenant (un utilisateur ne peut se connecter que sur son établissement) Frontend : - Page de connexion avec feedback visuel des délais et erreurs - Composant TurnstileCaptcha réutilisable - Gestion d'état auth avec stockage sécurisé des tokens - Tests E2E Playwright pour login, tenant isolation, et activation Infrastructure : - Configuration Symfony Security avec json_login + jwt - Cache pools séparés (filesystem en test, Redis en prod) - NullLoginRateLimiter pour environnement de test (évite blocage CI) - Génération des clés JWT en CI après démarrage du backend
This commit is contained in:
@@ -11,7 +11,8 @@ 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 App\Shared\Infrastructure\Tenant\TenantNotFoundException;
|
||||
use App\Shared\Infrastructure\Tenant\TenantRegistry;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
@@ -31,6 +32,7 @@ final class CreateTestActivationTokenCommand extends Command
|
||||
public function __construct(
|
||||
private readonly ActivationTokenRepository $activationTokenRepository,
|
||||
private readonly UserRepository $userRepository,
|
||||
private readonly TenantRegistry $tenantRegistry,
|
||||
private readonly Clock $clock,
|
||||
) {
|
||||
parent::__construct();
|
||||
@@ -43,21 +45,65 @@ final class CreateTestActivationTokenCommand extends Command
|
||||
->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');
|
||||
->addOption('tenant', null, InputOption::VALUE_OPTIONAL, 'Tenant subdomain (ecole-alpha, ecole-beta)', 'ecole-alpha')
|
||||
->addOption('base-url', null, InputOption::VALUE_OPTIONAL, 'Frontend base URL', 'http://localhost:5174');
|
||||
}
|
||||
|
||||
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');
|
||||
// Interactive mode only if:
|
||||
// 1. Input is interactive (not -n flag, has TTY)
|
||||
// 2. Using all default values (no explicit options provided)
|
||||
$usingDefaults = $input->getOption('email') === 'test@example.com'
|
||||
&& $input->getOption('role') === 'PARENT'
|
||||
&& $input->getOption('tenant') === 'ecole-alpha';
|
||||
|
||||
if ($input->isInteractive() && $usingDefaults) {
|
||||
$io->title('Création d\'un token d\'activation de test');
|
||||
|
||||
/** @var string $tenantSubdomain */
|
||||
$tenantSubdomain = $io->choice(
|
||||
'Tenant (établissement)',
|
||||
['ecole-alpha', 'ecole-beta'],
|
||||
'ecole-alpha'
|
||||
);
|
||||
|
||||
/** @var string $roleChoice */
|
||||
$roleChoice = $io->choice(
|
||||
'Rôle',
|
||||
['PARENT', 'ELEVE', 'PROF', 'ADMIN'],
|
||||
'PARENT'
|
||||
);
|
||||
|
||||
$defaultEmail = match ($roleChoice) {
|
||||
'PARENT' => 'parent@test.com',
|
||||
'ELEVE' => 'eleve@test.com',
|
||||
'PROF' => 'prof@test.com',
|
||||
'ADMIN' => 'admin@test.com',
|
||||
default => 'test@example.com',
|
||||
};
|
||||
|
||||
/** @var string $email */
|
||||
$email = $io->ask('Email', $defaultEmail);
|
||||
$roleInput = strtoupper($roleChoice);
|
||||
/** @var string $schoolName */
|
||||
$schoolName = $io->ask('Nom de l\'école', 'École de Test');
|
||||
$isMinor = $io->confirm('Utilisateur mineur (nécessite consentement parental) ?', false);
|
||||
} else {
|
||||
/** @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 $tenantSubdomain */
|
||||
$tenantSubdomain = $input->getOption('tenant');
|
||||
}
|
||||
|
||||
/** @var string $baseUrlOption */
|
||||
$baseUrlOption = $input->getOption('base-url');
|
||||
$baseUrl = rtrim($baseUrlOption, '/');
|
||||
@@ -77,8 +123,25 @@ final class CreateTestActivationTokenCommand extends Command
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Resolve tenant from subdomain
|
||||
try {
|
||||
$tenantConfig = $this->tenantRegistry->getBySubdomain($tenantSubdomain);
|
||||
$tenantId = $tenantConfig->tenantId;
|
||||
} catch (TenantNotFoundException) {
|
||||
$availableTenants = array_map(
|
||||
static fn ($config) => $config->subdomain,
|
||||
$this->tenantRegistry->getAllConfigs()
|
||||
);
|
||||
$io->error(sprintf(
|
||||
'Tenant "%s" not found. Available tenants: %s',
|
||||
$tenantSubdomain,
|
||||
implode(', ', $availableTenants)
|
||||
));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$now = $this->clock->now();
|
||||
$tenantId = TenantId::fromString('550e8400-e29b-41d4-a716-446655440001');
|
||||
|
||||
// Create user
|
||||
$dateNaissance = $isMinor
|
||||
@@ -118,6 +181,7 @@ final class CreateTestActivationTokenCommand extends Command
|
||||
['User ID', (string) $user->id],
|
||||
['Email', $email],
|
||||
['Role', $role->value],
|
||||
['Tenant', $tenantSubdomain],
|
||||
['School', $schoolName],
|
||||
['Minor', $isMinor ? 'Yes (requires parental consent)' : 'No'],
|
||||
['Token', $token->tokenValue],
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Console;
|
||||
|
||||
use App\Administration\Application\Port\PasswordHasher;
|
||||
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\Model\User\UserId;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Creates an already-activated test user for E2E login tests.
|
||||
*
|
||||
* Unlike the activation token command, this creates a user that can
|
||||
* immediately log in with the provided password.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:dev:create-test-user',
|
||||
description: 'Creates an already-activated test user for E2E login tests',
|
||||
)]
|
||||
final class CreateTestUserCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRepository $userRepository,
|
||||
private readonly PasswordHasher $passwordHasher,
|
||||
private readonly TenantRegistry $tenantRegistry,
|
||||
private readonly Clock $clock,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption('email', null, InputOption::VALUE_OPTIONAL, 'Email address', 'e2e-login@example.com')
|
||||
->addOption('password', null, InputOption::VALUE_OPTIONAL, 'Password (plain text)', 'TestPassword123')
|
||||
->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('tenant', null, InputOption::VALUE_OPTIONAL, 'Tenant subdomain (ecole-alpha, ecole-beta)', 'ecole-alpha');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
/** @var string $email */
|
||||
$email = $input->getOption('email');
|
||||
/** @var string $password */
|
||||
$password = $input->getOption('password');
|
||||
/** @var string $roleOption */
|
||||
$roleOption = $input->getOption('role');
|
||||
$roleInput = strtoupper($roleOption);
|
||||
/** @var string $schoolName */
|
||||
$schoolName = $input->getOption('school');
|
||||
/** @var string $tenantSubdomain */
|
||||
$tenantSubdomain = $input->getOption('tenant');
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Resolve tenant from subdomain
|
||||
try {
|
||||
$tenantConfig = $this->tenantRegistry->getBySubdomain($tenantSubdomain);
|
||||
$tenantId = $tenantConfig->tenantId;
|
||||
} catch (TenantNotFoundException) {
|
||||
$availableTenants = array_map(
|
||||
static fn ($config) => $config->subdomain,
|
||||
$this->tenantRegistry->getAllConfigs()
|
||||
);
|
||||
$io->error(sprintf(
|
||||
'Tenant "%s" not found. Available tenants: %s',
|
||||
$tenantSubdomain,
|
||||
implode(', ', $availableTenants)
|
||||
));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$now = $this->clock->now();
|
||||
|
||||
// Check if user already exists
|
||||
$existingUser = $this->userRepository->findByEmail(new Email($email), $tenantId);
|
||||
if ($existingUser !== null) {
|
||||
$io->warning(sprintf('User with email "%s" already exists. Returning existing user.', $email));
|
||||
|
||||
$io->table(
|
||||
['Property', 'Value'],
|
||||
[
|
||||
['User ID', (string) $existingUser->id],
|
||||
['Email', $email],
|
||||
['Password', $password],
|
||||
['Role', $existingUser->role->value],
|
||||
['Status', $existingUser->statut->value],
|
||||
]
|
||||
);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// Create activated user using reconstitute to bypass domain validation
|
||||
$hashedPassword = $this->passwordHasher->hash($password);
|
||||
|
||||
$user = User::reconstitute(
|
||||
id: UserId::generate(),
|
||||
email: new Email($email),
|
||||
role: $role,
|
||||
tenantId: $tenantId,
|
||||
schoolName: $schoolName,
|
||||
statut: StatutCompte::ACTIF,
|
||||
dateNaissance: null,
|
||||
createdAt: $now,
|
||||
hashedPassword: $hashedPassword,
|
||||
activatedAt: $now,
|
||||
consentementParental: null,
|
||||
);
|
||||
|
||||
$this->userRepository->save($user);
|
||||
|
||||
$io->success('Test user created successfully!');
|
||||
|
||||
$io->table(
|
||||
['Property', 'Value'],
|
||||
[
|
||||
['User ID', (string) $user->id],
|
||||
['Email', $email],
|
||||
['Password', $password],
|
||||
['Role', $role->value],
|
||||
['Tenant', $tenantSubdomain],
|
||||
['School', $schoolName],
|
||||
['Status', StatutCompte::ACTIF->value],
|
||||
]
|
||||
);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user