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