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

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

View File

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

View File

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

View File

@@ -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(),
));
}
}

View File

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

View File

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

View File

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