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:
2026-02-01 10:25:25 +01:00
parent 6889c67a44
commit b9d9f48305
93 changed files with 6850 additions and 155 deletions

View File

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

View File

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