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