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

View File

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

View 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';
}
}

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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