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:
20
backend/src/Shared/Domain/Tenant/TenantId.php
Normal file
20
backend/src/Shared/Domain/Tenant/TenantId.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Tenant;
|
||||
|
||||
use App\Shared\Domain\EntityId;
|
||||
|
||||
/**
|
||||
* Identifiant unique d'un tenant (établissement scolaire).
|
||||
*
|
||||
* Value Object du Domain - représente l'identité d'un tenant dans le système multi-tenant.
|
||||
* Chaque tenant isole ses données (utilisateurs, notes, etc.) des autres.
|
||||
*
|
||||
* Note: Cette classe n'est pas `final` pour permettre l'alias Infrastructure
|
||||
* durant la période de migration. L'alias sera supprimé dans une version future.
|
||||
*/
|
||||
readonly class TenantId extends EntityId
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Captcha;
|
||||
|
||||
/**
|
||||
* Résultat de la validation Turnstile.
|
||||
*/
|
||||
final readonly class TurnstileResult
|
||||
{
|
||||
private function __construct(
|
||||
public bool $isValid,
|
||||
public ?string $errorMessage,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function valid(): self
|
||||
{
|
||||
return new self(isValid: true, errorMessage: null);
|
||||
}
|
||||
|
||||
public static function invalid(string $errorMessage): self
|
||||
{
|
||||
return new self(isValid: false, errorMessage: $errorMessage);
|
||||
}
|
||||
}
|
||||
115
backend/src/Shared/Infrastructure/Captcha/TurnstileValidator.php
Normal file
115
backend/src/Shared/Infrastructure/Captcha/TurnstileValidator.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Captcha;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Valide les tokens Cloudflare Turnstile.
|
||||
*
|
||||
* @see https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
|
||||
* @see Story 1.4 - T8: CAPTCHA anti-bot
|
||||
*/
|
||||
final readonly class TurnstileValidator implements TurnstileValidatorInterface
|
||||
{
|
||||
private const string VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||||
private const float TIMEOUT_SECONDS = 5.0;
|
||||
|
||||
/**
|
||||
* @param bool $failOpen Si true, les erreurs API laissent passer (dev). Si false, elles bloquent (prod).
|
||||
*/
|
||||
public function __construct(
|
||||
private HttpClientInterface $httpClient,
|
||||
private LoggerInterface $logger,
|
||||
private string $secretKey,
|
||||
private bool $failOpen = false,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide un token Turnstile.
|
||||
*
|
||||
* @param string $token Le token fourni par le widget Turnstile
|
||||
* @param string|null $remoteIp L'IP du client (optionnel, mais recommandé)
|
||||
*/
|
||||
public function validate(string $token, ?string $remoteIp = null): TurnstileResult
|
||||
{
|
||||
if ($token === '') {
|
||||
return TurnstileResult::invalid('Token vide');
|
||||
}
|
||||
|
||||
try {
|
||||
$formData = [
|
||||
'secret' => $this->secretKey,
|
||||
'response' => $token,
|
||||
];
|
||||
|
||||
if ($remoteIp !== null) {
|
||||
$formData['remoteip'] = $remoteIp;
|
||||
}
|
||||
|
||||
$response = $this->httpClient->request('POST', self::VERIFY_URL, [
|
||||
'body' => $formData,
|
||||
'timeout' => self::TIMEOUT_SECONDS,
|
||||
]);
|
||||
|
||||
$data = $response->toArray();
|
||||
|
||||
if ($data['success'] === true) {
|
||||
return TurnstileResult::valid();
|
||||
}
|
||||
|
||||
// Erreurs possibles : https://developers.cloudflare.com/turnstile/get-started/server-side-validation/#error-codes
|
||||
$errorCodes = $data['error-codes'] ?? [];
|
||||
$errorMessage = $this->translateErrorCodes($errorCodes);
|
||||
|
||||
$this->logger->warning('Turnstile validation failed', [
|
||||
'error_codes' => $errorCodes,
|
||||
]);
|
||||
|
||||
return TurnstileResult::invalid($errorMessage);
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error('Turnstile API error', [
|
||||
'exception' => $e->getMessage(),
|
||||
'fail_open' => $this->failOpen,
|
||||
]);
|
||||
|
||||
// Comportement configurable en cas d'erreur API
|
||||
// - failOpen=true (dev): laisse passer pour ne pas bloquer le développement
|
||||
// - failOpen=false (prod): bloque pour maintenir la sécurité
|
||||
if ($this->failOpen) {
|
||||
return TurnstileResult::valid();
|
||||
}
|
||||
|
||||
return TurnstileResult::invalid('Service de vérification temporairement indisponible');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string> $errorCodes
|
||||
*/
|
||||
private function translateErrorCodes(array $errorCodes): string
|
||||
{
|
||||
$translations = [
|
||||
'missing-input-secret' => 'Configuration serveur invalide',
|
||||
'invalid-input-secret' => 'Configuration serveur invalide',
|
||||
'missing-input-response' => 'Token manquant',
|
||||
'invalid-input-response' => 'Token invalide ou expiré',
|
||||
'bad-request' => 'Requête invalide',
|
||||
'timeout-or-duplicate' => 'Token expiré ou déjà utilisé',
|
||||
'internal-error' => 'Erreur serveur Cloudflare',
|
||||
];
|
||||
|
||||
foreach ($errorCodes as $code) {
|
||||
if (isset($translations[$code])) {
|
||||
return $translations[$code];
|
||||
}
|
||||
}
|
||||
|
||||
return 'Vérification échouée';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Captcha;
|
||||
|
||||
/**
|
||||
* Interface pour la validation des tokens CAPTCHA.
|
||||
*/
|
||||
interface TurnstileValidatorInterface
|
||||
{
|
||||
/**
|
||||
* Valide un token CAPTCHA.
|
||||
*
|
||||
* @param string $token Le token fourni par le widget CAPTCHA
|
||||
* @param string|null $remoteIp L'IP du client (optionnel)
|
||||
*/
|
||||
public function validate(string $token, ?string $remoteIp = null): TurnstileResult;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Console;
|
||||
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Réinitialise le cache du rate limiter pour les tests.
|
||||
*
|
||||
* Cette commande est uniquement destinée aux environnements de développement et de test.
|
||||
* Elle vide tous les compteurs de tentatives de login et les blocages IP.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:dev:reset-rate-limit',
|
||||
description: 'Reset the login rate limiter cache (dev/test only)',
|
||||
)]
|
||||
final class ResetRateLimitCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CacheItemPoolInterface $rateLimiterCache,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
// Clear the entire rate limiter cache pool
|
||||
$this->rateLimiterCache->clear();
|
||||
|
||||
$io->success('Rate limiter cache has been cleared.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\RateLimit;
|
||||
|
||||
use App\Shared\Infrastructure\Captcha\TurnstileValidatorInterface;
|
||||
|
||||
use function is_array;
|
||||
use function is_int;
|
||||
use function is_string;
|
||||
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
|
||||
/**
|
||||
* Vérifie le rate limit AVANT l'authentification.
|
||||
*
|
||||
* Ce listener intercepte les requêtes de login et :
|
||||
* - Bloque immédiatement si l'IP est bloquée
|
||||
* - Exige un CAPTCHA après 5 échecs et le valide via Cloudflare Turnstile
|
||||
* - Bloque l'IP si le CAPTCHA échoue 3 fois
|
||||
*
|
||||
* @see Story 1.4 - AC3: Protection contre brute force
|
||||
*/
|
||||
#[AsEventListener(event: KernelEvents::REQUEST, priority: 10)]
|
||||
final readonly class LoginRateLimitListener
|
||||
{
|
||||
private const int MAX_CAPTCHA_FAILURES = 3;
|
||||
private const int CAPTCHA_FAILURES_TTL = 900; // 15 minutes
|
||||
|
||||
public function __construct(
|
||||
private LoginRateLimiterInterface $rateLimiter,
|
||||
private TurnstileValidatorInterface $turnstileValidator,
|
||||
private CacheItemPoolInterface $rateLimiterCache,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(RequestEvent $event): void
|
||||
{
|
||||
$request = $event->getRequest();
|
||||
|
||||
// Seulement pour la route de login
|
||||
if ($request->getPathInfo() !== '/api/login' || $request->getMethod() !== 'POST') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extraire l'email du body JSON (avec guards contre JSON invalide)
|
||||
$content = json_decode($request->getContent(), true);
|
||||
|
||||
if (!is_array($content)) {
|
||||
return; // JSON invalide, laisser le validator gérer
|
||||
}
|
||||
|
||||
$email = isset($content['email']) && is_string($content['email']) ? $content['email'] : null;
|
||||
|
||||
if ($email === null) {
|
||||
return; // Laisser le validator gérer
|
||||
}
|
||||
|
||||
// Vérifier l'état du rate limit
|
||||
$result = $this->rateLimiter->check($request, $email);
|
||||
|
||||
// IP bloquée → 429 immédiat
|
||||
if ($result->ipBlocked) {
|
||||
$event->setResponse($this->createBlockedResponse($result));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Délai Fibonacci en cours (enforcement serveur) → 429
|
||||
if (!$result->isAllowed && $result->retryAfter !== null && $result->retryAfter > 0) {
|
||||
$event->setResponse($this->createDelayedResponse($result));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// CAPTCHA requis (après 5 échecs)
|
||||
if ($result->requiresCaptcha) {
|
||||
$captchaToken = isset($content['captcha_token']) && is_string($content['captcha_token'])
|
||||
? $content['captcha_token']
|
||||
: null;
|
||||
|
||||
// Pas de token fourni → demander le CAPTCHA
|
||||
if ($captchaToken === null || $captchaToken === '') {
|
||||
$event->setResponse($this->createCaptchaRequiredResponse($result));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Valider le token via Cloudflare Turnstile
|
||||
$ip = $request->getClientIp();
|
||||
$turnstileResult = $this->turnstileValidator->validate($captchaToken, $ip);
|
||||
|
||||
if (!$turnstileResult->isValid) {
|
||||
// CAPTCHA invalide → incrémenter les échecs CAPTCHA par IP
|
||||
// Après 3 échecs CAPTCHA, bloquer l'IP
|
||||
$captchaFailures = $this->recordCaptchaFailure($ip ?? 'unknown');
|
||||
|
||||
if ($captchaFailures >= self::MAX_CAPTCHA_FAILURES) {
|
||||
$this->rateLimiter->blockIp($ip ?? 'unknown');
|
||||
|
||||
$event->setResponse($this->createBlockedResponse(
|
||||
LoginRateLimitResult::blocked(LoginRateLimiterInterface::IP_BLOCK_DURATION)
|
||||
));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$event->setResponse($this->createCaptchaInvalidResponse(
|
||||
$turnstileResult->errorMessage ?? 'Vérification échouée',
|
||||
$captchaFailures,
|
||||
));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// CAPTCHA valide → réinitialiser les échecs CAPTCHA pour cette IP
|
||||
$this->resetCaptchaFailures($ip ?? 'unknown');
|
||||
}
|
||||
|
||||
// Tout est OK, continuer vers l'authentification
|
||||
}
|
||||
|
||||
private function recordCaptchaFailure(string $ip): int
|
||||
{
|
||||
$key = 'captcha_failures_' . md5($ip);
|
||||
$item = $this->rateLimiterCache->getItem($key);
|
||||
|
||||
$cached = $item->get();
|
||||
$failures = $item->isHit() && is_int($cached) ? $cached + 1 : 1;
|
||||
|
||||
$item->set($failures);
|
||||
$item->expiresAfter(self::CAPTCHA_FAILURES_TTL);
|
||||
$this->rateLimiterCache->save($item);
|
||||
|
||||
return $failures;
|
||||
}
|
||||
|
||||
private function resetCaptchaFailures(string $ip): void
|
||||
{
|
||||
$key = 'captcha_failures_' . md5($ip);
|
||||
$this->rateLimiterCache->deleteItem($key);
|
||||
}
|
||||
|
||||
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 createDelayedResponse(LoginRateLimitResult $result): JsonResponse
|
||||
{
|
||||
$response = new JsonResponse([
|
||||
'type' => '/errors/rate-limited',
|
||||
'title' => 'Veuillez patienter',
|
||||
'status' => Response::HTTP_TOO_MANY_REQUESTS,
|
||||
'detail' => sprintf(
|
||||
'Veuillez patienter %s avant de réessayer.',
|
||||
$result->getFormattedDelay(),
|
||||
),
|
||||
'retryAfter' => $result->retryAfter,
|
||||
'attempts' => $result->attempts,
|
||||
], Response::HTTP_TOO_MANY_REQUESTS);
|
||||
|
||||
foreach ($result->toHeaders() as $name => $value) {
|
||||
$response->headers->set($name, $value);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function createCaptchaRequiredResponse(LoginRateLimitResult $result): JsonResponse
|
||||
{
|
||||
$response = new JsonResponse([
|
||||
'type' => '/errors/captcha-required',
|
||||
'title' => 'Vérification requise',
|
||||
'status' => Response::HTTP_PRECONDITION_REQUIRED,
|
||||
'detail' => 'Veuillez compléter la vérification de sécurité pour continuer.',
|
||||
'attempts' => $result->attempts,
|
||||
], Response::HTTP_PRECONDITION_REQUIRED);
|
||||
|
||||
foreach ($result->toHeaders() as $name => $value) {
|
||||
$response->headers->set($name, $value);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function createCaptchaInvalidResponse(string $errorMessage, int $failures): JsonResponse
|
||||
{
|
||||
return new JsonResponse([
|
||||
'type' => '/errors/captcha-invalid',
|
||||
'title' => 'Vérification échouée',
|
||||
'status' => Response::HTTP_BAD_REQUEST,
|
||||
'detail' => $errorMessage,
|
||||
'captchaFailures' => $failures,
|
||||
'maxFailures' => self::MAX_CAPTCHA_FAILURES,
|
||||
], Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\RateLimit;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
/**
|
||||
* Résultat de la vérification du rate limit pour le login.
|
||||
*
|
||||
* Stratégie de protection :
|
||||
* - Délai progressif Fibonacci après chaque échec (1s, 1s, 2s, 3s, 5s, 8s, 13s...)
|
||||
* - CAPTCHA requis après 5 échecs
|
||||
* - Blocage IP après échec CAPTCHA répété
|
||||
*/
|
||||
final readonly class LoginRateLimitResult
|
||||
{
|
||||
private function __construct(
|
||||
public bool $isAllowed,
|
||||
public int $attempts,
|
||||
public int $delaySeconds,
|
||||
public bool $requiresCaptcha,
|
||||
public bool $ipBlocked,
|
||||
public ?int $retryAfter,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Tentative autorisée (éventuellement avec délai).
|
||||
*/
|
||||
public static function allowed(int $attempts, int $delaySeconds, bool $requiresCaptcha): self
|
||||
{
|
||||
return new self(
|
||||
isAllowed: true,
|
||||
attempts: $attempts,
|
||||
delaySeconds: $delaySeconds,
|
||||
requiresCaptcha: $requiresCaptcha,
|
||||
ipBlocked: false,
|
||||
retryAfter: $delaySeconds > 0 ? $delaySeconds : null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* IP bloquée (trop de tentatives ou échec CAPTCHA).
|
||||
*/
|
||||
public static function blocked(int $retryAfter): self
|
||||
{
|
||||
return new self(
|
||||
isAllowed: false,
|
||||
attempts: 0,
|
||||
delaySeconds: $retryAfter,
|
||||
requiresCaptcha: false,
|
||||
ipBlocked: true,
|
||||
retryAfter: $retryAfter,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tentative refusée temporairement (délai Fibonacci en cours).
|
||||
*/
|
||||
public static function delayed(int $attempts, int $retryAfter): self
|
||||
{
|
||||
return new self(
|
||||
isAllowed: false,
|
||||
attempts: $attempts,
|
||||
delaySeconds: $retryAfter,
|
||||
requiresCaptcha: $attempts >= 5, // CAPTCHA_THRESHOLD
|
||||
ipBlocked: false,
|
||||
retryAfter: $retryAfter,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule le délai Fibonacci pour un nombre de tentatives donné.
|
||||
*
|
||||
* Suite: 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89... (max 89s)
|
||||
*
|
||||
* Mapping:
|
||||
* - 1 tentative = pas de délai
|
||||
* - 2 tentatives = 1s (F0)
|
||||
* - 3 tentatives = 1s (F1)
|
||||
* - 4 tentatives = 2s (F2)
|
||||
* - 5 tentatives = 3s (F3)
|
||||
* - etc.
|
||||
*/
|
||||
public static function fibonacciDelay(int $attempts): int
|
||||
{
|
||||
if ($attempts <= 1) {
|
||||
return 0; // Première tentative sans délai
|
||||
}
|
||||
|
||||
// Index dans la suite Fibonacci: attempts - 2
|
||||
// Cap à F(10) = 89 secondes (index 10 dans la suite 1,1,2,3,5,8,13,21,34,55,89)
|
||||
$n = min($attempts - 2, 10);
|
||||
|
||||
return self::fibonacci($n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule le n-ième nombre de Fibonacci.
|
||||
*
|
||||
* F(0)=1, F(1)=1, F(2)=2, F(3)=3, F(4)=5, F(5)=8, F(6)=13, F(7)=21, F(8)=34, F(9)=55, F(10)=89
|
||||
*/
|
||||
private static function fibonacci(int $n): int
|
||||
{
|
||||
if ($n <= 1) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$prev = 1;
|
||||
$curr = 1;
|
||||
|
||||
for ($i = 2; $i <= $n; ++$i) {
|
||||
$next = $prev + $curr;
|
||||
$prev = $curr;
|
||||
$curr = $next;
|
||||
}
|
||||
|
||||
return $curr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère les headers pour la réponse HTTP.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function toHeaders(): array
|
||||
{
|
||||
$headers = [
|
||||
'X-Login-Attempts' => (string) $this->attempts,
|
||||
];
|
||||
|
||||
if ($this->delaySeconds > 0) {
|
||||
$headers['X-Login-Delay'] = (string) $this->delaySeconds;
|
||||
$headers['Retry-After'] = (string) $this->delaySeconds;
|
||||
}
|
||||
|
||||
if ($this->requiresCaptcha) {
|
||||
$headers['X-Captcha-Required'] = 'true';
|
||||
}
|
||||
|
||||
if ($this->ipBlocked) {
|
||||
$headers['X-IP-Blocked'] = 'true';
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne le temps d'attente formaté pour l'utilisateur.
|
||||
*/
|
||||
public function getFormattedDelay(): string
|
||||
{
|
||||
if ($this->delaySeconds <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ($this->delaySeconds < 60) {
|
||||
return sprintf('%d seconde%s', $this->delaySeconds, $this->delaySeconds > 1 ? 's' : '');
|
||||
}
|
||||
|
||||
$minutes = (int) ceil($this->delaySeconds / 60);
|
||||
|
||||
return sprintf('%d minute%s', $minutes, $minutes > 1 ? 's' : '');
|
||||
}
|
||||
}
|
||||
206
backend/src/Shared/Infrastructure/RateLimit/LoginRateLimiter.php
Normal file
206
backend/src/Shared/Infrastructure/RateLimit/LoginRateLimiter.php
Normal file
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\RateLimit;
|
||||
|
||||
use function is_int;
|
||||
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* Service de rate limiting pour les tentatives de login.
|
||||
*
|
||||
* Stratégie de protection multi-niveaux :
|
||||
* - Délai progressif Fibonacci par email (1s, 1s, 2s, 3s, 5s, 8s, 13s, 21s, 34s, 55s, 89s max)
|
||||
* - CAPTCHA requis après 5 échecs sur le même email
|
||||
* - Blocage IP 15 min si trop de tentatives globales (20) ou échec CAPTCHA répété
|
||||
*
|
||||
* @see Story 1.4 - AC3: Protection contre brute force
|
||||
*/
|
||||
final readonly class LoginRateLimiter implements LoginRateLimiterInterface
|
||||
{
|
||||
private const string EMAIL_ATTEMPTS_PREFIX = 'login_attempts:';
|
||||
private const string EMAIL_DELAY_PREFIX = 'login_delay:';
|
||||
private const string IP_ATTEMPTS_PREFIX = 'login_ip:';
|
||||
private const string IP_BLOCKED_PREFIX = 'login_ip_blocked:';
|
||||
private const int EMAIL_ATTEMPTS_TTL = 900; // 15 minutes
|
||||
private const int IP_ATTEMPTS_LIMIT = 20;
|
||||
|
||||
public function __construct(
|
||||
private CacheItemPoolInterface $cache,
|
||||
) {
|
||||
}
|
||||
|
||||
public function check(Request $request, string $email): LoginRateLimitResult
|
||||
{
|
||||
$ip = $request->getClientIp() ?? 'unknown';
|
||||
|
||||
// Vérifier si l'IP est bloquée
|
||||
if ($this->isIpBlocked($ip)) {
|
||||
$blockedItem = $this->cache->getItem($this->ipBlockedKey($ip));
|
||||
$blockedUntil = $blockedItem->get();
|
||||
$retryAfter = is_int($blockedUntil) ? max(0, $blockedUntil - time()) : 0;
|
||||
|
||||
return LoginRateLimitResult::blocked($retryAfter);
|
||||
}
|
||||
|
||||
// Vérifier si l'email est en période de délai (enforcement Fibonacci)
|
||||
$delayedUntil = $this->getDelayedUntil($email);
|
||||
if ($delayedUntil > time()) {
|
||||
$retryAfter = $delayedUntil - time();
|
||||
$attempts = $this->getAttempts($email);
|
||||
|
||||
return LoginRateLimitResult::delayed($attempts, $retryAfter);
|
||||
}
|
||||
|
||||
// Récupérer le nombre de tentatives pour cet email
|
||||
$attempts = $this->getAttempts($email);
|
||||
$delaySeconds = LoginRateLimitResult::fibonacciDelay($attempts);
|
||||
$requiresCaptcha = $attempts >= self::CAPTCHA_THRESHOLD;
|
||||
|
||||
return LoginRateLimitResult::allowed($attempts, $delaySeconds, $requiresCaptcha);
|
||||
}
|
||||
|
||||
public function recordFailure(Request $request, string $email): LoginRateLimitResult
|
||||
{
|
||||
$ip = $request->getClientIp() ?? 'unknown';
|
||||
|
||||
// Incrémenter les tentatives pour l'email
|
||||
$emailAttempts = $this->incrementAttempts($email);
|
||||
|
||||
// Incrémenter les tentatives pour l'IP
|
||||
$ipAttempts = $this->incrementIpAttempts($ip);
|
||||
|
||||
// Bloquer l'IP si trop de tentatives globales
|
||||
if ($ipAttempts >= self::IP_ATTEMPTS_LIMIT) {
|
||||
$this->blockIp($ip);
|
||||
|
||||
return LoginRateLimitResult::blocked(self::IP_BLOCK_DURATION);
|
||||
}
|
||||
|
||||
$delaySeconds = LoginRateLimitResult::fibonacciDelay($emailAttempts);
|
||||
$requiresCaptcha = $emailAttempts >= self::CAPTCHA_THRESHOLD;
|
||||
|
||||
// Enregistrer le timestamp de prochaine tentative autorisée
|
||||
// Cela permet d'enforcer le délai côté serveur
|
||||
if ($delaySeconds > 0) {
|
||||
$this->setDelayedUntil($email, time() + $delaySeconds);
|
||||
}
|
||||
|
||||
return LoginRateLimitResult::allowed($emailAttempts, $delaySeconds, $requiresCaptcha);
|
||||
}
|
||||
|
||||
public function reset(string $email): void
|
||||
{
|
||||
$key = self::EMAIL_ATTEMPTS_PREFIX . $this->normalizeEmail($email);
|
||||
$this->cache->deleteItem($key);
|
||||
}
|
||||
|
||||
public function blockIp(string $ip): void
|
||||
{
|
||||
$item = $this->cache->getItem($this->ipBlockedKey($ip));
|
||||
$item->set(time() + self::IP_BLOCK_DURATION);
|
||||
$item->expiresAfter(self::IP_BLOCK_DURATION);
|
||||
$this->cache->save($item);
|
||||
}
|
||||
|
||||
public function isIpBlocked(string $ip): bool
|
||||
{
|
||||
$item = $this->cache->getItem($this->ipBlockedKey($ip));
|
||||
|
||||
if (!$item->isHit()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$blockedUntil = $item->get();
|
||||
|
||||
return $blockedUntil > time();
|
||||
}
|
||||
|
||||
private function getAttempts(string $email): int
|
||||
{
|
||||
$key = self::EMAIL_ATTEMPTS_PREFIX . $this->normalizeEmail($email);
|
||||
$item = $this->cache->getItem($key);
|
||||
|
||||
if (!$item->isHit()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$cached = $item->get();
|
||||
|
||||
return is_int($cached) ? $cached : 0;
|
||||
}
|
||||
|
||||
private function incrementAttempts(string $email): int
|
||||
{
|
||||
$key = self::EMAIL_ATTEMPTS_PREFIX . $this->normalizeEmail($email);
|
||||
$item = $this->cache->getItem($key);
|
||||
|
||||
$cached = $item->get();
|
||||
$attempts = $item->isHit() && is_int($cached) ? $cached : 0;
|
||||
++$attempts;
|
||||
|
||||
$item->set($attempts);
|
||||
$item->expiresAfter(self::EMAIL_ATTEMPTS_TTL);
|
||||
$this->cache->save($item);
|
||||
|
||||
return $attempts;
|
||||
}
|
||||
|
||||
private function incrementIpAttempts(string $ip): int
|
||||
{
|
||||
$key = self::IP_ATTEMPTS_PREFIX . $this->hashIp($ip);
|
||||
$item = $this->cache->getItem($key);
|
||||
|
||||
$cached = $item->get();
|
||||
$attempts = $item->isHit() && is_int($cached) ? $cached : 0;
|
||||
++$attempts;
|
||||
|
||||
$item->set($attempts);
|
||||
$item->expiresAfter(self::EMAIL_ATTEMPTS_TTL);
|
||||
$this->cache->save($item);
|
||||
|
||||
return $attempts;
|
||||
}
|
||||
|
||||
private function ipBlockedKey(string $ip): string
|
||||
{
|
||||
return self::IP_BLOCKED_PREFIX . $this->hashIp($ip);
|
||||
}
|
||||
|
||||
private function getDelayedUntil(string $email): int
|
||||
{
|
||||
$key = self::EMAIL_DELAY_PREFIX . $this->normalizeEmail($email);
|
||||
$item = $this->cache->getItem($key);
|
||||
|
||||
if (!$item->isHit()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$cached = $item->get();
|
||||
|
||||
return is_int($cached) ? $cached : 0;
|
||||
}
|
||||
|
||||
private function setDelayedUntil(string $email, int $timestamp): void
|
||||
{
|
||||
$key = self::EMAIL_DELAY_PREFIX . $this->normalizeEmail($email);
|
||||
$item = $this->cache->getItem($key);
|
||||
$item->set($timestamp);
|
||||
// TTL = délai + marge de sécurité
|
||||
$item->expiresAfter(max(0, $timestamp - time()) + 10);
|
||||
$this->cache->save($item);
|
||||
}
|
||||
|
||||
private function normalizeEmail(string $email): string
|
||||
{
|
||||
return strtolower(trim($email));
|
||||
}
|
||||
|
||||
private function hashIp(string $ip): string
|
||||
{
|
||||
return hash('sha256', $ip);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\RateLimit;
|
||||
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* Interface pour le rate limiting des tentatives de login.
|
||||
*
|
||||
* Stratégie de protection multi-niveaux :
|
||||
* - Délai progressif Fibonacci par email (1s, 1s, 2s, 3s, 5s, 8s...)
|
||||
* - CAPTCHA requis après 5 échecs
|
||||
* - Blocage IP après trop de tentatives globales ou échec CAPTCHA
|
||||
*/
|
||||
interface LoginRateLimiterInterface
|
||||
{
|
||||
public const int CAPTCHA_THRESHOLD = 5;
|
||||
public const int IP_BLOCK_DURATION = 900; // 15 minutes
|
||||
|
||||
/**
|
||||
* Vérifie l'état du rate limit pour une tentative de login.
|
||||
*
|
||||
* Retourne le nombre de tentatives, le délai à appliquer, et si CAPTCHA est requis.
|
||||
*/
|
||||
public function check(Request $request, string $email): LoginRateLimitResult;
|
||||
|
||||
/**
|
||||
* Enregistre un échec de login (incrémente le compteur, calcule le nouveau délai).
|
||||
*/
|
||||
public function recordFailure(Request $request, string $email): LoginRateLimitResult;
|
||||
|
||||
/**
|
||||
* Réinitialise le compteur pour un email (après login réussi).
|
||||
*/
|
||||
public function reset(string $email): void;
|
||||
|
||||
/**
|
||||
* Bloque une IP (après échec CAPTCHA répété).
|
||||
*/
|
||||
public function blockIp(string $ip): void;
|
||||
|
||||
/**
|
||||
* Vérifie si une IP est bloquée.
|
||||
*/
|
||||
public function isIpBlocked(string $ip): bool;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\RateLimit;
|
||||
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* Implémentation "null" du rate limiter pour les environnements de test.
|
||||
*
|
||||
* Cette implémentation ne fait rien - elle permet de bypasser le rate limiting
|
||||
* pour les tests E2E où l'IP est partagée entre tous les tests.
|
||||
*/
|
||||
final readonly class NullLoginRateLimiter implements LoginRateLimiterInterface
|
||||
{
|
||||
public function check(Request $request, string $email): LoginRateLimitResult
|
||||
{
|
||||
return LoginRateLimitResult::allowed(attempts: 0, delaySeconds: 0, requiresCaptcha: false);
|
||||
}
|
||||
|
||||
public function recordFailure(Request $request, string $email): LoginRateLimitResult
|
||||
{
|
||||
return LoginRateLimitResult::allowed(attempts: 1, delaySeconds: 0, requiresCaptcha: false);
|
||||
}
|
||||
|
||||
public function reset(string $email): void
|
||||
{
|
||||
// No-op
|
||||
}
|
||||
|
||||
public function blockIp(string $ip): void
|
||||
{
|
||||
// No-op
|
||||
}
|
||||
|
||||
public function isIpBlocked(string $ip): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Tenant;
|
||||
|
||||
use App\Shared\Domain\EntityId;
|
||||
use App\Shared\Domain\Tenant\TenantId as DomainTenantId;
|
||||
|
||||
final readonly class TenantId extends EntityId
|
||||
/**
|
||||
* Infrastructure alias for Domain TenantId.
|
||||
*
|
||||
* @deprecated Use App\Shared\Domain\Tenant\TenantId instead in Domain layer code.
|
||||
* This alias exists for backwards compatibility in Infrastructure layer.
|
||||
*/
|
||||
final readonly class TenantId extends DomainTenantId
|
||||
{
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user