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,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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user