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:
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Controller;
|
||||
|
||||
use App\Administration\Domain\Model\RefreshToken\RefreshToken;
|
||||
use App\Administration\Domain\Repository\RefreshTokenRepository;
|
||||
use DateTimeImmutable;
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Component\HttpFoundation\Cookie;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* Endpoint de déconnexion.
|
||||
*
|
||||
* Invalide le refresh token et supprime le cookie.
|
||||
*
|
||||
* @see Story 1.4 - Connexion utilisateur
|
||||
*/
|
||||
final readonly class LogoutController
|
||||
{
|
||||
public function __construct(
|
||||
private RefreshTokenRepository $refreshTokenRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/api/token/logout', name: 'api_logout', methods: ['POST'])]
|
||||
public function __invoke(Request $request): Response
|
||||
{
|
||||
$refreshTokenValue = $request->cookies->get('refresh_token');
|
||||
|
||||
// Invalider toute la famille de tokens pour une déconnexion complète
|
||||
if ($refreshTokenValue !== null) {
|
||||
try {
|
||||
$tokenId = RefreshToken::extractIdFromTokenString($refreshTokenValue);
|
||||
$refreshToken = $this->refreshTokenRepository->find($tokenId);
|
||||
|
||||
if ($refreshToken !== null) {
|
||||
// Invalider toute la famille (déconnecte tous les devices)
|
||||
$this->refreshTokenRepository->invalidateFamily($refreshToken->familyId);
|
||||
}
|
||||
} catch (InvalidArgumentException) {
|
||||
// Token malformé, ignorer
|
||||
}
|
||||
}
|
||||
|
||||
// Créer la réponse avec suppression du cookie
|
||||
$response = new JsonResponse(['message' => 'Déconnexion réussie'], Response::HTTP_OK);
|
||||
|
||||
// Supprimer le cookie refresh_token (même path que celui utilisé lors du login)
|
||||
$response->headers->setCookie(
|
||||
Cookie::create('refresh_token')
|
||||
->withValue('')
|
||||
->withExpires(new DateTimeImmutable('-1 hour'))
|
||||
->withPath('/api/token')
|
||||
->withHttpOnly(true)
|
||||
->withSecure(true)
|
||||
->withSameSite('strict'),
|
||||
);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Administration\Application\Service\RefreshTokenManager;
|
||||
use App\Administration\Domain\Event\TokenReplayDetecte;
|
||||
use App\Administration\Domain\Exception\TokenAlreadyRotatedException;
|
||||
use App\Administration\Domain\Exception\TokenReplayDetectedException;
|
||||
use App\Administration\Domain\Model\RefreshToken\DeviceFingerprint;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use App\Administration\Infrastructure\Api\Resource\RefreshTokenInput;
|
||||
use App\Administration\Infrastructure\Api\Resource\RefreshTokenOutput;
|
||||
use App\Administration\Infrastructure\Security\SecurityUserFactory;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Infrastructure\Tenant\TenantNotFoundException;
|
||||
use App\Shared\Infrastructure\Tenant\TenantResolver;
|
||||
use DateTimeImmutable;
|
||||
use InvalidArgumentException;
|
||||
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
|
||||
use Symfony\Component\HttpFoundation\Cookie;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/**
|
||||
* Processor pour le rafraîchissement de token.
|
||||
*
|
||||
* Flow :
|
||||
* 1. Lire le refresh token depuis le cookie HttpOnly
|
||||
* 2. Valider le token et le device fingerprint
|
||||
* 3. Détecter les replay attacks
|
||||
* 4. Générer un nouveau JWT et faire la rotation du refresh token
|
||||
* 5. Mettre à jour le cookie
|
||||
*
|
||||
* @implements ProcessorInterface<RefreshTokenInput, RefreshTokenOutput>
|
||||
*
|
||||
* @see Story 1.4 - T6: Endpoint Refresh Token
|
||||
*/
|
||||
final readonly class RefreshTokenProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private RefreshTokenManager $refreshTokenManager,
|
||||
private JWTTokenManagerInterface $jwtManager,
|
||||
private UserRepository $userRepository,
|
||||
private RequestStack $requestStack,
|
||||
private SecurityUserFactory $securityUserFactory,
|
||||
private TenantResolver $tenantResolver,
|
||||
private MessageBusInterface $eventBus,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param RefreshTokenInput $data
|
||||
*/
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): RefreshTokenOutput
|
||||
{
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
|
||||
if ($request === null) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Request not available');
|
||||
}
|
||||
|
||||
// Lire le refresh token depuis le cookie
|
||||
$refreshTokenString = $request->cookies->get('refresh_token');
|
||||
|
||||
if ($refreshTokenString === null) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Refresh token not found');
|
||||
}
|
||||
|
||||
// Créer le device fingerprint pour validation
|
||||
$ipAddress = $request->getClientIp() ?? 'unknown';
|
||||
$userAgent = $request->headers->get('User-Agent', 'unknown');
|
||||
$fingerprint = DeviceFingerprint::fromRequest($userAgent, $ipAddress);
|
||||
|
||||
try {
|
||||
// Valider et faire la rotation du refresh token
|
||||
$newRefreshToken = $this->refreshTokenManager->refresh($refreshTokenString, $fingerprint);
|
||||
|
||||
// Sécurité: vérifier que le tenant du refresh token correspond au tenant de la requête
|
||||
// Empêche l'utilisation d'un token d'un tenant pour accéder à un autre
|
||||
$currentTenantId = $this->resolveCurrentTenant($request->getHost());
|
||||
if ($currentTenantId !== null && (string) $newRefreshToken->tenantId !== (string) $currentTenantId) {
|
||||
$this->clearRefreshTokenCookie();
|
||||
|
||||
throw new AccessDeniedHttpException('Invalid token for this tenant');
|
||||
}
|
||||
|
||||
// Charger l'utilisateur pour générer le JWT
|
||||
$user = $this->userRepository->get($newRefreshToken->userId);
|
||||
|
||||
// Vérifier que l'utilisateur peut toujours se connecter (pas suspendu/archivé)
|
||||
if (!$user->peutSeConnecter()) {
|
||||
// Invalider toute la famille et supprimer le cookie
|
||||
$this->refreshTokenManager->invalidateFamily($newRefreshToken->familyId);
|
||||
$this->clearRefreshTokenCookie();
|
||||
|
||||
throw new AccessDeniedHttpException('Account is no longer active');
|
||||
}
|
||||
|
||||
$securityUser = $this->securityUserFactory->fromDomainUser($user);
|
||||
|
||||
// Générer le nouveau JWT
|
||||
$jwt = $this->jwtManager->create($securityUser);
|
||||
|
||||
// Stocker le cookie dans les attributs de requête pour le listener
|
||||
// Le RefreshTokenCookieListener l'ajoutera à la réponse
|
||||
$cookie = Cookie::create('refresh_token')
|
||||
->withValue($newRefreshToken->toTokenString())
|
||||
->withExpires($newRefreshToken->expiresAt)
|
||||
->withPath('/api/token')
|
||||
->withSecure(true)
|
||||
->withHttpOnly(true)
|
||||
->withSameSite('strict');
|
||||
|
||||
$request->attributes->set('_refresh_token_cookie', $cookie);
|
||||
|
||||
return new RefreshTokenOutput(token: $jwt);
|
||||
} catch (TokenReplayDetectedException $e) {
|
||||
// Replay attack détecté - la famille a été invalidée
|
||||
// Dispatcher l'événement de sécurité pour alertes/audit
|
||||
$this->eventBus->dispatch(new TokenReplayDetecte(
|
||||
familyId: $e->familyId,
|
||||
ipAddress: $ipAddress,
|
||||
userAgent: $userAgent,
|
||||
occurredOn: $this->clock->now(),
|
||||
));
|
||||
|
||||
// Supprimer le cookie côté client
|
||||
$this->clearRefreshTokenCookie();
|
||||
|
||||
throw new AccessDeniedHttpException(
|
||||
'Session compromise detected. All sessions have been invalidated. Please log in again.',
|
||||
);
|
||||
} catch (TokenAlreadyRotatedException) {
|
||||
// Token déjà rotaté mais en grace period - race condition légitime
|
||||
// NE PAS supprimer le cookie ! Le client a probablement déjà le nouveau token
|
||||
// d'une requête concurrente. Retourner 409 Conflict pour que le client réessaie.
|
||||
throw new ConflictHttpException('Token already rotated, retry with current cookie');
|
||||
} catch (InvalidArgumentException $e) {
|
||||
// Token invalide ou expiré
|
||||
$this->clearRefreshTokenCookie();
|
||||
|
||||
throw new UnauthorizedHttpException('Bearer', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function clearRefreshTokenCookie(): void
|
||||
{
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
|
||||
if ($request !== null) {
|
||||
$cookie = Cookie::create('refresh_token')
|
||||
->withValue('')
|
||||
->withExpires(new DateTimeImmutable('-1 day'))
|
||||
->withPath('/api/token')
|
||||
->withSecure(true)
|
||||
->withHttpOnly(true)
|
||||
->withSameSite('strict');
|
||||
|
||||
$request->attributes->set('_refresh_token_cookie', $cookie);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the current tenant from the request host.
|
||||
*
|
||||
* Returns null for localhost (dev environment uses default tenant).
|
||||
*/
|
||||
private function resolveCurrentTenant(string $host): ?\App\Shared\Domain\Tenant\TenantId
|
||||
{
|
||||
// Skip validation for localhost (dev environment)
|
||||
if ($host === 'localhost' || $host === '127.0.0.1') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->tenantResolver->resolve($host)->tenantId;
|
||||
} catch (TenantNotFoundException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Administration\Infrastructure\Api\Processor\RefreshTokenProcessor;
|
||||
|
||||
/**
|
||||
* Resource API Platform pour le rafraîchissement de token.
|
||||
*
|
||||
* Le refresh token est lu depuis le cookie HttpOnly, pas du body.
|
||||
*
|
||||
* @see Story 1.4 - T6: Endpoint Refresh Token
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Post(
|
||||
uriTemplate: '/token/refresh',
|
||||
processor: RefreshTokenProcessor::class,
|
||||
output: RefreshTokenOutput::class,
|
||||
name: 'refresh_token',
|
||||
description: 'Utilise le refresh token (cookie HttpOnly) pour obtenir un nouveau JWT. Le refresh token est automatiquement rotaté.',
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class RefreshTokenInput
|
||||
{
|
||||
// Pas de propriétés - le refresh token vient du cookie
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Resource;
|
||||
|
||||
/**
|
||||
* Output pour le rafraîchissement de token.
|
||||
*/
|
||||
final readonly class RefreshTokenOutput
|
||||
{
|
||||
public function __construct(
|
||||
public string $token,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Messaging;
|
||||
|
||||
use App\Administration\Domain\Event\ConnexionEchouee;
|
||||
use App\Administration\Domain\Event\ConnexionReussie;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
/**
|
||||
* Enregistre les événements de connexion dans l'audit log.
|
||||
*
|
||||
* Important: Les IP sont hashées pour respecter NFR-S3 (pas de PII dans les logs).
|
||||
*
|
||||
* @see Story 1.4 - T5.5: Tracer dans audit log
|
||||
* @see AC3: Événement tracé dans audit log
|
||||
*/
|
||||
final readonly class AuditLoginEventsHandler
|
||||
{
|
||||
public function __construct(
|
||||
private LoggerInterface $auditLogger,
|
||||
private string $appSecret,
|
||||
) {
|
||||
}
|
||||
|
||||
#[AsMessageHandler]
|
||||
public function handleConnexionReussie(ConnexionReussie $event): void
|
||||
{
|
||||
$this->auditLogger->info('login.success', [
|
||||
'user_id' => $event->userId,
|
||||
'tenant_id' => (string) $event->tenantId,
|
||||
'ip_hash' => $this->hashIp($event->ipAddress),
|
||||
'user_agent_hash' => $this->hashUserAgent($event->userAgent),
|
||||
'occurred_on' => $event->occurredOn->format('c'),
|
||||
]);
|
||||
}
|
||||
|
||||
#[AsMessageHandler]
|
||||
public function handleConnexionEchouee(ConnexionEchouee $event): void
|
||||
{
|
||||
$this->auditLogger->warning('login.failure', [
|
||||
'email_hash' => $this->hashEmail($event->email),
|
||||
'reason' => $event->reason,
|
||||
'ip_hash' => $this->hashIp($event->ipAddress),
|
||||
'user_agent_hash' => $this->hashUserAgent($event->userAgent),
|
||||
'occurred_on' => $event->occurredOn->format('c'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash l'IP pour éviter de stocker des PII.
|
||||
* Le hash permet toujours de corréler les événements d'une même IP.
|
||||
*/
|
||||
private function hashIp(string $ip): string
|
||||
{
|
||||
return hash('sha256', $ip . $this->appSecret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash l'email pour éviter de stocker des PII.
|
||||
*/
|
||||
private function hashEmail(string $email): string
|
||||
{
|
||||
return hash('sha256', strtolower($email) . $this->appSecret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash le User-Agent (généralement pas PII mais peut être très long).
|
||||
*/
|
||||
private function hashUserAgent(string $userAgent): string
|
||||
{
|
||||
return hash('sha256', $userAgent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Messaging;
|
||||
|
||||
use App\Administration\Domain\Event\CompteBloqueTemporairement;
|
||||
use Symfony\Component\Mailer\MailerInterface;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
use Symfony\Component\Mime\Email;
|
||||
use Twig\Environment;
|
||||
|
||||
/**
|
||||
* Envoie un email d'alerte quand un compte est bloqué temporairement.
|
||||
*
|
||||
* @see Story 1.4 - T4: Email alerte lockout
|
||||
*/
|
||||
#[AsMessageHandler]
|
||||
final readonly class SendLockoutAlertHandler
|
||||
{
|
||||
public function __construct(
|
||||
private MailerInterface $mailer,
|
||||
private Environment $twig,
|
||||
private string $fromEmail = 'noreply@classeo.fr',
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(CompteBloqueTemporairement $event): void
|
||||
{
|
||||
$blockedForMinutes = (int) ceil($event->blockedForSeconds / 60);
|
||||
|
||||
$htmlContent = $this->twig->render('email/lockout_alert.html.twig', [
|
||||
'email' => $event->email,
|
||||
'ipAddress' => $event->ipAddress,
|
||||
'failedAttempts' => $event->failedAttempts,
|
||||
'blockedForMinutes' => $blockedForMinutes,
|
||||
'occurredOn' => $event->occurredOn,
|
||||
]);
|
||||
|
||||
$textContent = $this->twig->render('email/lockout_alert.txt.twig', [
|
||||
'email' => $event->email,
|
||||
'ipAddress' => $event->ipAddress,
|
||||
'failedAttempts' => $event->failedAttempts,
|
||||
'blockedForMinutes' => $blockedForMinutes,
|
||||
'occurredOn' => $event->occurredOn,
|
||||
]);
|
||||
|
||||
$email = (new Email())
|
||||
->from($this->fromEmail)
|
||||
->to($event->email)
|
||||
->subject('🔒 Alerte de sécurité - Tentatives de connexion suspectes')
|
||||
->html($htmlContent)
|
||||
->text($textContent)
|
||||
->priority(Email::PRIORITY_HIGH);
|
||||
|
||||
$this->mailer->send($email);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ 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\Infrastructure\Tenant\TenantId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
|
||||
@@ -40,8 +40,9 @@ final readonly class CacheUserRepository implements UserRepository
|
||||
$item->set($this->serialize($user));
|
||||
$this->usersCache->save($item);
|
||||
|
||||
// Save email index for lookup
|
||||
$emailItem = $this->usersCache->getItem(self::EMAIL_INDEX_PREFIX . $this->normalizeEmail($user->email));
|
||||
// Save email index for lookup (scoped to tenant)
|
||||
$emailKey = $this->emailIndexKey($user->email, $user->tenantId);
|
||||
$emailItem = $this->usersCache->getItem($emailKey);
|
||||
$emailItem->set((string) $user->id);
|
||||
$this->usersCache->save($emailItem);
|
||||
}
|
||||
@@ -60,9 +61,10 @@ final readonly class CacheUserRepository implements UserRepository
|
||||
return $this->deserialize($data);
|
||||
}
|
||||
|
||||
public function findByEmail(Email $email): ?User
|
||||
public function findByEmail(Email $email, TenantId $tenantId): ?User
|
||||
{
|
||||
$emailItem = $this->usersCache->getItem(self::EMAIL_INDEX_PREFIX . $this->normalizeEmail($email));
|
||||
$emailKey = $this->emailIndexKey($email, $tenantId);
|
||||
$emailItem = $this->usersCache->getItem($emailKey);
|
||||
|
||||
if (!$emailItem->isHit()) {
|
||||
return null;
|
||||
@@ -159,4 +161,12 @@ final readonly class CacheUserRepository implements UserRepository
|
||||
{
|
||||
return strtolower(str_replace(['@', '.'], ['_at_', '_dot_'], (string) $email));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a cache key for email lookup scoped to a tenant.
|
||||
*/
|
||||
private function emailIndexKey(Email $email, TenantId $tenantId): string
|
||||
{
|
||||
return self::EMAIL_INDEX_PREFIX . $tenantId . ':' . $this->normalizeEmail($email);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Administration\Domain\Model\User\Email;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Override;
|
||||
|
||||
final class InMemoryUserRepository implements UserRepository
|
||||
@@ -16,14 +17,14 @@ final class InMemoryUserRepository implements UserRepository
|
||||
/** @var array<string, User> Indexed by ID */
|
||||
private array $byId = [];
|
||||
|
||||
/** @var array<string, User> Indexed by email (lowercase) */
|
||||
private array $byEmail = [];
|
||||
/** @var array<string, User> Indexed by tenant:email (lowercase) */
|
||||
private array $byTenantEmail = [];
|
||||
|
||||
#[Override]
|
||||
public function save(User $user): void
|
||||
{
|
||||
$this->byId[(string) $user->id] = $user;
|
||||
$this->byEmail[strtolower((string) $user->email)] = $user;
|
||||
$this->byTenantEmail[$this->emailKey($user->email, $user->tenantId)] = $user;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
@@ -39,8 +40,13 @@ final class InMemoryUserRepository implements UserRepository
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findByEmail(Email $email): ?User
|
||||
public function findByEmail(Email $email, TenantId $tenantId): ?User
|
||||
{
|
||||
return $this->byEmail[strtolower((string) $email)] ?? null;
|
||||
return $this->byTenantEmail[$this->emailKey($email, $tenantId)] ?? null;
|
||||
}
|
||||
|
||||
private function emailKey(Email $email, TenantId $tenantId): string
|
||||
{
|
||||
return $tenantId . ':' . strtolower((string) $email);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use App\Administration\Domain\Exception\ActivationTokenNotFoundException;
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationToken;
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
|
||||
use App\Administration\Domain\Repository\ActivationTokenRepository;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Persistence\Redis;
|
||||
|
||||
use App\Administration\Domain\Model\RefreshToken\DeviceFingerprint;
|
||||
use App\Administration\Domain\Model\RefreshToken\RefreshToken;
|
||||
use App\Administration\Domain\Model\RefreshToken\RefreshTokenId;
|
||||
use App\Administration\Domain\Model\RefreshToken\TokenFamilyId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Repository\RefreshTokenRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeInterface;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
|
||||
/**
|
||||
* Implémentation Redis du repository de refresh tokens.
|
||||
*
|
||||
* Structure de stockage :
|
||||
* - Token individuel : refresh:{token_id} → données JSON du token
|
||||
* - Index famille : refresh_family:{family_id} → set des token_ids de la famille
|
||||
*
|
||||
* @see Story 1.4 - Connexion utilisateur
|
||||
*/
|
||||
final readonly class RedisRefreshTokenRepository implements RefreshTokenRepository
|
||||
{
|
||||
private const string TOKEN_PREFIX = 'refresh:';
|
||||
private const string FAMILY_PREFIX = 'refresh_family:';
|
||||
|
||||
public function __construct(
|
||||
private CacheItemPoolInterface $refreshTokensCache,
|
||||
) {
|
||||
}
|
||||
|
||||
public function save(RefreshToken $token): void
|
||||
{
|
||||
// Sauvegarder le token
|
||||
$tokenItem = $this->refreshTokensCache->getItem(self::TOKEN_PREFIX . $token->id);
|
||||
$tokenItem->set($this->serialize($token));
|
||||
|
||||
// Calculer le TTL restant
|
||||
$now = new DateTimeImmutable();
|
||||
$ttl = $token->expiresAt->getTimestamp() - $now->getTimestamp();
|
||||
if ($ttl > 0) {
|
||||
$tokenItem->expiresAfter($ttl);
|
||||
}
|
||||
|
||||
$this->refreshTokensCache->save($tokenItem);
|
||||
|
||||
// Ajouter à l'index famille
|
||||
// Ne jamais réduire le TTL de l'index famille
|
||||
// L'index doit survivre aussi longtemps que le token le plus récent de la famille
|
||||
$familyItem = $this->refreshTokensCache->getItem(self::FAMILY_PREFIX . $token->familyId);
|
||||
|
||||
/** @var list<string> $familyTokenIds */
|
||||
$familyTokenIds = $familyItem->isHit() ? $familyItem->get() : [];
|
||||
$familyTokenIds[] = (string) $token->id;
|
||||
$familyItem->set(array_unique($familyTokenIds));
|
||||
|
||||
// Seulement étendre le TTL, jamais le réduire
|
||||
// Pour les tokens rotated (ancien), on ne change pas le TTL de l'index
|
||||
if (!$token->isRotated && $ttl > 0) {
|
||||
$familyItem->expiresAfter($ttl);
|
||||
} elseif (!$familyItem->isHit()) {
|
||||
// Nouveau index - définir le TTL initial
|
||||
$familyItem->expiresAfter($ttl > 0 ? $ttl : 604800);
|
||||
}
|
||||
// Si c'est un token rotaté et l'index existe déjà, on garde le TTL existant
|
||||
|
||||
$this->refreshTokensCache->save($familyItem);
|
||||
}
|
||||
|
||||
public function find(RefreshTokenId $id): ?RefreshToken
|
||||
{
|
||||
$item = $this->refreshTokensCache->getItem(self::TOKEN_PREFIX . $id);
|
||||
|
||||
if (!$item->isHit()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var array{id: string, family_id: string, user_id: string, tenant_id: string, device_fingerprint: string, issued_at: string, expires_at: string, rotated_from: string|null, is_rotated: bool, rotated_at?: string|null} $data */
|
||||
$data = $item->get();
|
||||
|
||||
return $this->deserialize($data);
|
||||
}
|
||||
|
||||
public function findByToken(string $tokenValue): ?RefreshToken
|
||||
{
|
||||
return $this->find(RefreshTokenId::fromString($tokenValue));
|
||||
}
|
||||
|
||||
public function delete(RefreshTokenId $id): void
|
||||
{
|
||||
$this->refreshTokensCache->deleteItem(self::TOKEN_PREFIX . $id);
|
||||
}
|
||||
|
||||
public function invalidateFamily(TokenFamilyId $familyId): void
|
||||
{
|
||||
$familyItem = $this->refreshTokensCache->getItem(self::FAMILY_PREFIX . $familyId);
|
||||
|
||||
if (!$familyItem->isHit()) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var list<string> $tokenIds */
|
||||
$tokenIds = $familyItem->get();
|
||||
|
||||
// Supprimer tous les tokens de la famille
|
||||
foreach ($tokenIds as $tokenId) {
|
||||
$this->refreshTokensCache->deleteItem(self::TOKEN_PREFIX . $tokenId);
|
||||
}
|
||||
|
||||
// Supprimer l'index famille
|
||||
$this->refreshTokensCache->deleteItem(self::FAMILY_PREFIX . $familyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function serialize(RefreshToken $token): array
|
||||
{
|
||||
return [
|
||||
'id' => (string) $token->id,
|
||||
'family_id' => (string) $token->familyId,
|
||||
'user_id' => (string) $token->userId,
|
||||
'tenant_id' => (string) $token->tenantId,
|
||||
'device_fingerprint' => (string) $token->deviceFingerprint,
|
||||
'issued_at' => $token->issuedAt->format(DateTimeInterface::ATOM),
|
||||
'expires_at' => $token->expiresAt->format(DateTimeInterface::ATOM),
|
||||
'rotated_from' => $token->rotatedFrom !== null ? (string) $token->rotatedFrom : null,
|
||||
'is_rotated' => $token->isRotated,
|
||||
'rotated_at' => $token->rotatedAt?->format(DateTimeInterface::ATOM),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* id: string,
|
||||
* family_id: string,
|
||||
* user_id: string,
|
||||
* tenant_id: string,
|
||||
* device_fingerprint: string,
|
||||
* issued_at: string,
|
||||
* expires_at: string,
|
||||
* rotated_from: string|null,
|
||||
* is_rotated: bool,
|
||||
* rotated_at?: string|null
|
||||
* } $data
|
||||
*/
|
||||
private function deserialize(array $data): RefreshToken
|
||||
{
|
||||
$rotatedAt = $data['rotated_at'] ?? null;
|
||||
|
||||
return RefreshToken::reconstitute(
|
||||
id: RefreshTokenId::fromString($data['id']),
|
||||
familyId: TokenFamilyId::fromString($data['family_id']),
|
||||
userId: UserId::fromString($data['user_id']),
|
||||
tenantId: TenantId::fromString($data['tenant_id']),
|
||||
deviceFingerprint: DeviceFingerprint::fromString($data['device_fingerprint']),
|
||||
issuedAt: new DateTimeImmutable($data['issued_at']),
|
||||
expiresAt: new DateTimeImmutable($data['expires_at']),
|
||||
rotatedFrom: $data['rotated_from'] !== null ? RefreshTokenId::fromString($data['rotated_from']) : null,
|
||||
isRotated: $data['is_rotated'],
|
||||
rotatedAt: $rotatedAt !== null ? new DateTimeImmutable($rotatedAt) : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Security;
|
||||
|
||||
use App\Administration\Domain\Exception\EmailInvalideException;
|
||||
use App\Administration\Domain\Model\User\Email;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantNotFoundException;
|
||||
use App\Shared\Infrastructure\Tenant\TenantResolver;
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\Security\Core\Exception\UserNotFoundException as SymfonyUserNotFoundException;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Symfony\Component\Security\Core\User\UserProviderInterface;
|
||||
|
||||
/**
|
||||
* Charge les utilisateurs depuis le domaine pour l'authentification Symfony.
|
||||
*
|
||||
* Ce provider fait le pont entre Symfony Security et notre Domain Layer.
|
||||
* Il ne révèle jamais si un utilisateur existe ou non pour des raisons de sécurité.
|
||||
* Les utilisateurs sont isolés par tenant (établissement).
|
||||
*
|
||||
* @implements UserProviderInterface<SecurityUser>
|
||||
*
|
||||
* @see Story 1.4 - Connexion utilisateur (AC2: pas de révélation d'existence du compte)
|
||||
*/
|
||||
final readonly class DatabaseUserProvider implements UserProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private UserRepository $userRepository,
|
||||
private TenantResolver $tenantResolver,
|
||||
private RequestStack $requestStack,
|
||||
private SecurityUserFactory $securityUserFactory,
|
||||
) {
|
||||
}
|
||||
|
||||
public function loadUserByIdentifier(string $identifier): UserInterface
|
||||
{
|
||||
$tenantId = $this->getCurrentTenantId();
|
||||
|
||||
try {
|
||||
$email = new Email($identifier);
|
||||
} catch (EmailInvalideException) {
|
||||
// Malformed email = treat as user not found (security: generic error)
|
||||
throw new SymfonyUserNotFoundException();
|
||||
}
|
||||
|
||||
$user = $this->userRepository->findByEmail($email, $tenantId);
|
||||
|
||||
// Message générique pour ne pas révéler l'existence du compte
|
||||
if ($user === null) {
|
||||
throw new SymfonyUserNotFoundException();
|
||||
}
|
||||
|
||||
// Ne pas permettre la connexion si le compte n'est pas actif
|
||||
if (!$user->peutSeConnecter()) {
|
||||
throw new SymfonyUserNotFoundException();
|
||||
}
|
||||
|
||||
return $this->securityUserFactory->fromDomainUser($user);
|
||||
}
|
||||
|
||||
public function refreshUser(UserInterface $user): UserInterface
|
||||
{
|
||||
if (!$user instanceof SecurityUser) {
|
||||
throw new InvalidArgumentException('Expected instance of ' . SecurityUser::class);
|
||||
}
|
||||
|
||||
return $this->loadUserByIdentifier($user->email());
|
||||
}
|
||||
|
||||
public function supportsClass(string $class): bool
|
||||
{
|
||||
return $class === SecurityUser::class;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the current tenant from the request host.
|
||||
*
|
||||
* @throws SymfonyUserNotFoundException if tenant cannot be resolved (security: generic error)
|
||||
*/
|
||||
private function getCurrentTenantId(): TenantId
|
||||
{
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
|
||||
if ($request === null) {
|
||||
throw new SymfonyUserNotFoundException();
|
||||
}
|
||||
|
||||
$host = $request->getHost();
|
||||
|
||||
// Dev/test fallback: localhost uses ecole-alpha tenant
|
||||
if ($host === 'localhost' || $host === '127.0.0.1') {
|
||||
try {
|
||||
return $this->tenantResolver->resolve('ecole-alpha.classeo.local')->tenantId;
|
||||
} catch (TenantNotFoundException) {
|
||||
throw new SymfonyUserNotFoundException();
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$tenantConfig = $this->tenantResolver->resolve($host);
|
||||
|
||||
return $tenantConfig->tenantId;
|
||||
} catch (TenantNotFoundException) {
|
||||
// Don't reveal tenant doesn't exist - use same error as invalid credentials
|
||||
throw new SymfonyUserNotFoundException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Security;
|
||||
|
||||
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTCreatedEvent;
|
||||
|
||||
/**
|
||||
* Enrichit le payload JWT avec les claims métier.
|
||||
*
|
||||
* Claims ajoutés:
|
||||
* - sub: Email de l'utilisateur (identifiant Symfony Security)
|
||||
* - user_id: UUID de l'utilisateur (pour les consommateurs d'API)
|
||||
* - tenant_id: UUID du tenant pour l'isolation multi-tenant
|
||||
* - roles: Liste des rôles Symfony pour l'autorisation
|
||||
*
|
||||
* @see Story 1.4 - Connexion utilisateur
|
||||
*/
|
||||
final readonly class JwtPayloadEnricher
|
||||
{
|
||||
public function onJWTCreated(JWTCreatedEvent $event): void
|
||||
{
|
||||
$user = $event->getUser();
|
||||
|
||||
if (!$user instanceof SecurityUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = $event->getData();
|
||||
|
||||
// Claims métier pour l'isolation multi-tenant et l'autorisation
|
||||
$payload['user_id'] = $user->userId();
|
||||
$payload['tenant_id'] = $user->tenantId();
|
||||
$payload['roles'] = $user->getRoles();
|
||||
|
||||
$event->setData($payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Security;
|
||||
|
||||
use App\Administration\Domain\Event\CompteBloqueTemporairement;
|
||||
use App\Administration\Domain\Event\ConnexionEchouee;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Infrastructure\RateLimit\LoginRateLimiterInterface;
|
||||
use App\Shared\Infrastructure\RateLimit\LoginRateLimitResult;
|
||||
|
||||
use function is_array;
|
||||
use function is_string;
|
||||
use function sprintf;
|
||||
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||
use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface;
|
||||
|
||||
/**
|
||||
* Gère les échecs de login : rate limiting Fibonacci, audit, messages user-friendly.
|
||||
*
|
||||
* Important: Ne jamais révéler si l'email existe ou non (AC2).
|
||||
*
|
||||
* @see Story 1.4 - T5: Endpoint Login Backend
|
||||
*/
|
||||
final readonly class LoginFailureHandler implements AuthenticationFailureHandlerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private LoginRateLimiterInterface $rateLimiter,
|
||||
private MessageBusInterface $eventBus,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
|
||||
{
|
||||
$content = json_decode($request->getContent(), true);
|
||||
$email = is_array($content) && isset($content['email']) && is_string($content['email'])
|
||||
? $content['email']
|
||||
: 'unknown';
|
||||
$ipAddress = $request->getClientIp() ?? 'unknown';
|
||||
$userAgent = $request->headers->get('User-Agent', 'unknown');
|
||||
|
||||
// Enregistrer l'échec et obtenir le nouvel état
|
||||
$result = $this->rateLimiter->recordFailure($request, $email);
|
||||
|
||||
// Émettre l'événement d'échec
|
||||
$this->eventBus->dispatch(new ConnexionEchouee(
|
||||
email: $email,
|
||||
ipAddress: $ipAddress,
|
||||
userAgent: $userAgent,
|
||||
reason: 'invalid_credentials',
|
||||
occurredOn: $this->clock->now(),
|
||||
));
|
||||
|
||||
// Si l'IP vient d'être bloquée
|
||||
if ($result->ipBlocked) {
|
||||
$this->eventBus->dispatch(new CompteBloqueTemporairement(
|
||||
email: $email,
|
||||
ipAddress: $ipAddress,
|
||||
userAgent: $userAgent,
|
||||
blockedForSeconds: $result->retryAfter ?? LoginRateLimiterInterface::IP_BLOCK_DURATION,
|
||||
failedAttempts: $result->attempts,
|
||||
occurredOn: $this->clock->now(),
|
||||
));
|
||||
|
||||
return $this->createBlockedResponse($result);
|
||||
}
|
||||
|
||||
// Réponse standard d'échec avec infos sur le délai et CAPTCHA
|
||||
return $this->createFailureResponse($result);
|
||||
}
|
||||
|
||||
private function createBlockedResponse(LoginRateLimitResult $result): JsonResponse
|
||||
{
|
||||
$response = new JsonResponse([
|
||||
'type' => '/errors/ip-blocked',
|
||||
'title' => 'Accès temporairement bloqué',
|
||||
'status' => Response::HTTP_TOO_MANY_REQUESTS,
|
||||
'detail' => sprintf(
|
||||
'Trop de tentatives de connexion. Réessayez dans %s.',
|
||||
$result->getFormattedDelay(),
|
||||
),
|
||||
'retryAfter' => $result->retryAfter,
|
||||
], Response::HTTP_TOO_MANY_REQUESTS);
|
||||
|
||||
foreach ($result->toHeaders() as $name => $value) {
|
||||
$response->headers->set($name, $value);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function createFailureResponse(LoginRateLimitResult $result): JsonResponse
|
||||
{
|
||||
$data = [
|
||||
'type' => '/errors/authentication-failed',
|
||||
'title' => 'Identifiants incorrects',
|
||||
'status' => Response::HTTP_UNAUTHORIZED,
|
||||
'detail' => 'L\'adresse email ou le mot de passe est incorrect.',
|
||||
'attempts' => $result->attempts,
|
||||
];
|
||||
|
||||
// Ajouter le délai si applicable
|
||||
if ($result->delaySeconds > 0) {
|
||||
$data['delay'] = $result->delaySeconds;
|
||||
$data['delayFormatted'] = $result->getFormattedDelay();
|
||||
}
|
||||
|
||||
// Indiquer si CAPTCHA requis pour la prochaine tentative
|
||||
if ($result->requiresCaptcha) {
|
||||
$data['captchaRequired'] = true;
|
||||
}
|
||||
|
||||
$response = new JsonResponse($data, Response::HTTP_UNAUTHORIZED);
|
||||
|
||||
foreach ($result->toHeaders() as $name => $value) {
|
||||
$response->headers->set($name, $value);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Security;
|
||||
|
||||
use App\Administration\Application\Service\RefreshTokenManager;
|
||||
use App\Administration\Domain\Event\ConnexionReussie;
|
||||
use App\Administration\Domain\Model\RefreshToken\DeviceFingerprint;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\RateLimit\LoginRateLimiterInterface;
|
||||
use Lexik\Bundle\JWTAuthenticationBundle\Event\AuthenticationSuccessEvent;
|
||||
use Symfony\Component\HttpFoundation\Cookie;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/**
|
||||
* Gère les actions post-login réussi : refresh token, reset rate limit, audit.
|
||||
*
|
||||
* @see Story 1.4 - T5: Endpoint Login Backend
|
||||
*/
|
||||
final readonly class LoginSuccessHandler
|
||||
{
|
||||
public function __construct(
|
||||
private RefreshTokenManager $refreshTokenManager,
|
||||
private LoginRateLimiterInterface $rateLimiter,
|
||||
private MessageBusInterface $eventBus,
|
||||
private Clock $clock,
|
||||
private RequestStack $requestStack,
|
||||
) {
|
||||
}
|
||||
|
||||
public function onAuthenticationSuccess(AuthenticationSuccessEvent $event): void
|
||||
{
|
||||
$user = $event->getUser();
|
||||
$response = $event->getResponse();
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
|
||||
if (!$user instanceof SecurityUser || $request === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$email = $user->email();
|
||||
$userId = UserId::fromString($user->userId());
|
||||
$tenantId = TenantId::fromString($user->tenantId());
|
||||
$ipAddress = $request->getClientIp() ?? 'unknown';
|
||||
$userAgent = $request->headers->get('User-Agent', 'unknown');
|
||||
|
||||
// Créer le device fingerprint
|
||||
$fingerprint = DeviceFingerprint::fromRequest($userAgent, $ipAddress);
|
||||
|
||||
// Détecter si c'est un mobile (pour le TTL du refresh token)
|
||||
$isMobile = str_contains(strtolower($userAgent), 'mobile');
|
||||
|
||||
// Créer le refresh token
|
||||
$refreshToken = $this->refreshTokenManager->create(
|
||||
$userId,
|
||||
$tenantId,
|
||||
$fingerprint,
|
||||
$isMobile,
|
||||
);
|
||||
|
||||
// Ajouter le refresh token en cookie HttpOnly
|
||||
$cookie = Cookie::create('refresh_token')
|
||||
->withValue($refreshToken->toTokenString())
|
||||
->withExpires($refreshToken->expiresAt)
|
||||
->withPath('/api/token')
|
||||
->withSecure(true)
|
||||
->withHttpOnly(true)
|
||||
->withSameSite('strict');
|
||||
|
||||
$response->headers->setCookie($cookie);
|
||||
|
||||
// Reset le rate limiter pour cet email
|
||||
$this->rateLimiter->reset($email);
|
||||
|
||||
// Émettre l'événement de connexion réussie
|
||||
$this->eventBus->dispatch(new ConnexionReussie(
|
||||
userId: $user->userId(),
|
||||
email: $email,
|
||||
tenantId: $tenantId,
|
||||
ipAddress: $ipAddress,
|
||||
userAgent: $userAgent,
|
||||
occurredOn: $this->clock->now(),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Security;
|
||||
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpFoundation\Cookie;
|
||||
use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
|
||||
/**
|
||||
* Ajoute le cookie refresh_token à la réponse HTTP.
|
||||
*
|
||||
* Ce listener est nécessaire car dans API Platform 4.x, la réponse n'est pas
|
||||
* disponible dans le context du processor. Le processor stocke le cookie dans
|
||||
* les attributs de la requête, et ce listener l'ajoute à la réponse.
|
||||
*
|
||||
* @see Story 1.4 - T6: Endpoint Refresh Token
|
||||
*/
|
||||
#[AsEventListener(event: KernelEvents::RESPONSE, priority: 0)]
|
||||
final readonly class RefreshTokenCookieListener
|
||||
{
|
||||
public function __invoke(ResponseEvent $event): void
|
||||
{
|
||||
$request = $event->getRequest();
|
||||
$cookie = $request->attributes->get('_refresh_token_cookie');
|
||||
|
||||
if ($cookie instanceof Cookie) {
|
||||
$event->getResponse()->headers->setCookie($cookie);
|
||||
$request->attributes->remove('_refresh_token_cookie');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Security;
|
||||
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
/**
|
||||
* Adapter entre le Domain User et Symfony Security.
|
||||
*
|
||||
* Ce DTO est utilisé par le système d'authentification Symfony.
|
||||
* Il ne contient pas de logique métier - c'est un simple transporteur de données.
|
||||
*
|
||||
* @see Story 1.4 - Connexion utilisateur
|
||||
*/
|
||||
final readonly class SecurityUser implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
{
|
||||
/** @var non-empty-string */
|
||||
private string $email;
|
||||
|
||||
/**
|
||||
* @param non-empty-string $email
|
||||
* @param list<string> $roles Les rôles Symfony (ROLE_*)
|
||||
*/
|
||||
public function __construct(
|
||||
private UserId $userId,
|
||||
string $email,
|
||||
private string $hashedPassword,
|
||||
private TenantId $tenantId,
|
||||
private array $roles,
|
||||
) {
|
||||
$this->email = $email;
|
||||
}
|
||||
|
||||
public function getUserIdentifier(): string
|
||||
{
|
||||
return $this->email;
|
||||
}
|
||||
|
||||
public function userId(): string
|
||||
{
|
||||
return (string) $this->userId;
|
||||
}
|
||||
|
||||
public function getPassword(): string
|
||||
{
|
||||
return $this->hashedPassword;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function getRoles(): array
|
||||
{
|
||||
return $this->roles;
|
||||
}
|
||||
|
||||
public function tenantId(): string
|
||||
{
|
||||
return (string) $this->tenantId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return non-empty-string
|
||||
*/
|
||||
public function email(): string
|
||||
{
|
||||
return $this->email;
|
||||
}
|
||||
|
||||
public function eraseCredentials(): void
|
||||
{
|
||||
// Rien à effacer, les données sont immutables
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Security;
|
||||
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\User as DomainUser;
|
||||
|
||||
/**
|
||||
* Factory pour créer des SecurityUser depuis des Domain Users.
|
||||
*
|
||||
* Respecte le principe "No Static" d'Elegant Objects.
|
||||
*
|
||||
* @see Story 1.4 - Connexion utilisateur
|
||||
*/
|
||||
final readonly class SecurityUserFactory
|
||||
{
|
||||
public function fromDomainUser(DomainUser $domainUser): SecurityUser
|
||||
{
|
||||
return new SecurityUser(
|
||||
userId: $domainUser->id,
|
||||
email: (string) $domainUser->email,
|
||||
hashedPassword: $domainUser->hashedPassword ?? '',
|
||||
tenantId: $domainUser->tenantId,
|
||||
roles: [$this->mapRoleToSymfony($domainUser->role)],
|
||||
);
|
||||
}
|
||||
|
||||
private function mapRoleToSymfony(Role $role): string
|
||||
{
|
||||
return $role->value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user