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,432 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\RateLimit;
|
||||
|
||||
use App\Shared\Infrastructure\Captcha\TurnstileResult;
|
||||
use App\Shared\Infrastructure\Captcha\TurnstileValidatorInterface;
|
||||
use App\Shared\Infrastructure\RateLimit\LoginRateLimiterInterface;
|
||||
use App\Shared\Infrastructure\RateLimit\LoginRateLimitListener;
|
||||
use App\Shared\Infrastructure\RateLimit\LoginRateLimitResult;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Cache\CacheItemInterface;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||
|
||||
final class LoginRateLimitListenerTest extends TestCase
|
||||
{
|
||||
private function createListener(
|
||||
?LoginRateLimiterInterface $rateLimiter = null,
|
||||
?TurnstileValidatorInterface $turnstile = null,
|
||||
?CacheItemPoolInterface $cache = null,
|
||||
): LoginRateLimitListener {
|
||||
return new LoginRateLimitListener(
|
||||
$rateLimiter ?? $this->createMock(LoginRateLimiterInterface::class),
|
||||
$turnstile ?? $this->createMock(TurnstileValidatorInterface::class),
|
||||
$cache ?? $this->createCacheMock(),
|
||||
);
|
||||
}
|
||||
|
||||
private function createCacheMock(int $captchaFailures = 0): CacheItemPoolInterface
|
||||
{
|
||||
$cacheItem = $this->createMock(CacheItemInterface::class);
|
||||
$cacheItem->method('isHit')->willReturn($captchaFailures > 0);
|
||||
$cacheItem->method('get')->willReturn($captchaFailures);
|
||||
$cacheItem->method('set')->willReturnSelf();
|
||||
$cacheItem->method('expiresAfter')->willReturnSelf();
|
||||
|
||||
$cache = $this->createMock(CacheItemPoolInterface::class);
|
||||
$cache->method('getItem')->willReturn($cacheItem);
|
||||
$cache->method('save')->willReturn(true);
|
||||
$cache->method('deleteItem')->willReturn(true);
|
||||
|
||||
return $cache;
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function blockedIpReturns429BeforeAuthentication(): void
|
||||
{
|
||||
$rateLimiter = $this->createMock(LoginRateLimiterInterface::class);
|
||||
$rateLimiter->method('check')
|
||||
->willReturn(LoginRateLimitResult::blocked(retryAfter: 600));
|
||||
|
||||
$listener = $this->createListener(rateLimiter: $rateLimiter);
|
||||
|
||||
$request = Request::create(
|
||||
'/api/login',
|
||||
'POST',
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
['CONTENT_TYPE' => 'application/json'],
|
||||
json_encode(['email' => 'blocked@example.com', 'password' => 'correct'])
|
||||
);
|
||||
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
$event = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
|
||||
|
||||
$listener($event);
|
||||
|
||||
self::assertTrue($event->hasResponse());
|
||||
self::assertSame(Response::HTTP_TOO_MANY_REQUESTS, $event->getResponse()->getStatusCode());
|
||||
|
||||
$content = json_decode($event->getResponse()->getContent(), true);
|
||||
self::assertSame('/errors/ip-blocked', $content['type']);
|
||||
self::assertSame(600, $content['retryAfter']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function allowedEmailProceedsToAuthentication(): void
|
||||
{
|
||||
$rateLimiter = $this->createMock(LoginRateLimiterInterface::class);
|
||||
$rateLimiter->method('check')
|
||||
->willReturn(LoginRateLimitResult::allowed(
|
||||
attempts: 2,
|
||||
delaySeconds: 1,
|
||||
requiresCaptcha: false,
|
||||
));
|
||||
|
||||
$listener = $this->createListener(rateLimiter: $rateLimiter);
|
||||
|
||||
$request = Request::create(
|
||||
'/api/login',
|
||||
'POST',
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
['CONTENT_TYPE' => 'application/json'],
|
||||
json_encode(['email' => 'user@example.com', 'password' => 'password'])
|
||||
);
|
||||
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
$event = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
|
||||
|
||||
$listener($event);
|
||||
|
||||
// No response set = request continues to authentication
|
||||
self::assertFalse($event->hasResponse());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function captchaRequiredWithoutTokenReturns428(): void
|
||||
{
|
||||
$rateLimiter = $this->createMock(LoginRateLimiterInterface::class);
|
||||
$rateLimiter->method('check')
|
||||
->willReturn(LoginRateLimitResult::allowed(
|
||||
attempts: 6,
|
||||
delaySeconds: 8,
|
||||
requiresCaptcha: true,
|
||||
));
|
||||
|
||||
$listener = $this->createListener(rateLimiter: $rateLimiter);
|
||||
|
||||
$request = Request::create(
|
||||
'/api/login',
|
||||
'POST',
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
['CONTENT_TYPE' => 'application/json'],
|
||||
json_encode(['email' => 'user@example.com', 'password' => 'password'])
|
||||
// No captcha_token
|
||||
);
|
||||
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
$event = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
|
||||
|
||||
$listener($event);
|
||||
|
||||
self::assertTrue($event->hasResponse());
|
||||
self::assertSame(Response::HTTP_PRECONDITION_REQUIRED, $event->getResponse()->getStatusCode());
|
||||
|
||||
$content = json_decode($event->getResponse()->getContent(), true);
|
||||
self::assertSame('/errors/captcha-required', $content['type']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function captchaRequiredWithValidTokenProceeds(): void
|
||||
{
|
||||
$rateLimiter = $this->createMock(LoginRateLimiterInterface::class);
|
||||
$rateLimiter->method('check')
|
||||
->willReturn(LoginRateLimitResult::allowed(
|
||||
attempts: 6,
|
||||
delaySeconds: 8,
|
||||
requiresCaptcha: true,
|
||||
));
|
||||
|
||||
$turnstile = $this->createMock(TurnstileValidatorInterface::class);
|
||||
$turnstile->method('validate')
|
||||
->willReturn(TurnstileResult::valid());
|
||||
|
||||
$listener = $this->createListener(rateLimiter: $rateLimiter, turnstile: $turnstile);
|
||||
|
||||
$request = Request::create(
|
||||
'/api/login',
|
||||
'POST',
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
['CONTENT_TYPE' => 'application/json'],
|
||||
json_encode([
|
||||
'email' => 'user@example.com',
|
||||
'password' => 'password',
|
||||
'captcha_token' => 'valid-token',
|
||||
])
|
||||
);
|
||||
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
$event = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
|
||||
|
||||
$listener($event);
|
||||
|
||||
// Should proceed to authentication
|
||||
self::assertFalse($event->hasResponse());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function captchaRequiredWithInvalidTokenReturns400(): void
|
||||
{
|
||||
$rateLimiter = $this->createMock(LoginRateLimiterInterface::class);
|
||||
$rateLimiter->method('check')
|
||||
->willReturn(LoginRateLimitResult::allowed(
|
||||
attempts: 6,
|
||||
delaySeconds: 8,
|
||||
requiresCaptcha: true,
|
||||
));
|
||||
|
||||
$turnstile = $this->createMock(TurnstileValidatorInterface::class);
|
||||
$turnstile->method('validate')
|
||||
->willReturn(TurnstileResult::invalid('Token invalide'));
|
||||
|
||||
$listener = $this->createListener(rateLimiter: $rateLimiter, turnstile: $turnstile);
|
||||
|
||||
$request = Request::create(
|
||||
'/api/login',
|
||||
'POST',
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
['CONTENT_TYPE' => 'application/json'],
|
||||
json_encode([
|
||||
'email' => 'user@example.com',
|
||||
'password' => 'password',
|
||||
'captcha_token' => 'invalid-token',
|
||||
])
|
||||
);
|
||||
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
$event = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
|
||||
|
||||
$listener($event);
|
||||
|
||||
self::assertTrue($event->hasResponse());
|
||||
self::assertSame(Response::HTTP_BAD_REQUEST, $event->getResponse()->getStatusCode());
|
||||
|
||||
$content = json_decode($event->getResponse()->getContent(), true);
|
||||
self::assertSame('/errors/captcha-invalid', $content['type']);
|
||||
self::assertSame('Token invalide', $content['detail']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function captchaFailuresPersistAcrossRequests(): void
|
||||
{
|
||||
$rateLimiter = $this->createMock(LoginRateLimiterInterface::class);
|
||||
$rateLimiter->method('check')
|
||||
->willReturn(LoginRateLimitResult::allowed(
|
||||
attempts: 6,
|
||||
delaySeconds: 8,
|
||||
requiresCaptcha: true,
|
||||
));
|
||||
|
||||
$turnstile = $this->createMock(TurnstileValidatorInterface::class);
|
||||
$turnstile->method('validate')
|
||||
->willReturn(TurnstileResult::invalid('Token invalide'));
|
||||
|
||||
// Simulate 2 previous failures (next failure = 3 = blocked)
|
||||
$cache = $this->createCacheMock(captchaFailures: 2);
|
||||
|
||||
$listener = $this->createListener(rateLimiter: $rateLimiter, turnstile: $turnstile, cache: $cache);
|
||||
|
||||
$request = Request::create(
|
||||
'/api/login',
|
||||
'POST',
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
['CONTENT_TYPE' => 'application/json'],
|
||||
json_encode([
|
||||
'email' => 'user@example.com',
|
||||
'password' => 'password',
|
||||
'captcha_token' => 'invalid-token',
|
||||
])
|
||||
);
|
||||
$request->server->set('REMOTE_ADDR', '192.168.1.100');
|
||||
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
$event = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
|
||||
|
||||
$listener($event);
|
||||
|
||||
// 3rd CAPTCHA failure should block the IP
|
||||
self::assertTrue($event->hasResponse());
|
||||
self::assertSame(Response::HTTP_TOO_MANY_REQUESTS, $event->getResponse()->getStatusCode());
|
||||
|
||||
$content = json_decode($event->getResponse()->getContent(), true);
|
||||
self::assertSame('/errors/ip-blocked', $content['type']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function cacheIsSavedOnCaptchaFailure(): void
|
||||
{
|
||||
$rateLimiter = $this->createMock(LoginRateLimiterInterface::class);
|
||||
$rateLimiter->method('check')
|
||||
->willReturn(LoginRateLimitResult::allowed(
|
||||
attempts: 6,
|
||||
delaySeconds: 8,
|
||||
requiresCaptcha: true,
|
||||
));
|
||||
|
||||
$turnstile = $this->createMock(TurnstileValidatorInterface::class);
|
||||
$turnstile->method('validate')
|
||||
->willReturn(TurnstileResult::invalid('Token invalide'));
|
||||
|
||||
$cacheItem = $this->createMock(CacheItemInterface::class);
|
||||
$cacheItem->method('isHit')->willReturn(false);
|
||||
$cacheItem->method('get')->willReturn(0);
|
||||
$cacheItem->expects(self::once())->method('set')->with(1)->willReturnSelf();
|
||||
$cacheItem->expects(self::once())->method('expiresAfter')->with(900)->willReturnSelf();
|
||||
|
||||
$cache = $this->createMock(CacheItemPoolInterface::class);
|
||||
$cache->method('getItem')->willReturn($cacheItem);
|
||||
$cache->expects(self::once())->method('save')->with($cacheItem)->willReturn(true);
|
||||
|
||||
$listener = $this->createListener(rateLimiter: $rateLimiter, turnstile: $turnstile, cache: $cache);
|
||||
|
||||
$request = Request::create(
|
||||
'/api/login',
|
||||
'POST',
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
['CONTENT_TYPE' => 'application/json'],
|
||||
json_encode([
|
||||
'email' => 'user@example.com',
|
||||
'password' => 'password',
|
||||
'captcha_token' => 'invalid-token',
|
||||
])
|
||||
);
|
||||
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
$event = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
|
||||
|
||||
$listener($event);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function cacheIsDeletedOnValidCaptcha(): void
|
||||
{
|
||||
$rateLimiter = $this->createMock(LoginRateLimiterInterface::class);
|
||||
$rateLimiter->method('check')
|
||||
->willReturn(LoginRateLimitResult::allowed(
|
||||
attempts: 6,
|
||||
delaySeconds: 8,
|
||||
requiresCaptcha: true,
|
||||
));
|
||||
|
||||
$turnstile = $this->createMock(TurnstileValidatorInterface::class);
|
||||
$turnstile->method('validate')
|
||||
->willReturn(TurnstileResult::valid());
|
||||
|
||||
$cache = $this->createMock(CacheItemPoolInterface::class);
|
||||
$cache->expects(self::once())->method('deleteItem');
|
||||
|
||||
$listener = $this->createListener(rateLimiter: $rateLimiter, turnstile: $turnstile, cache: $cache);
|
||||
|
||||
$request = Request::create(
|
||||
'/api/login',
|
||||
'POST',
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
['CONTENT_TYPE' => 'application/json'],
|
||||
json_encode([
|
||||
'email' => 'user@example.com',
|
||||
'password' => 'password',
|
||||
'captcha_token' => 'valid-token',
|
||||
])
|
||||
);
|
||||
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
$event = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
|
||||
|
||||
$listener($event);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function ignoresNonLoginRequests(): void
|
||||
{
|
||||
$rateLimiter = $this->createMock(LoginRateLimiterInterface::class);
|
||||
$rateLimiter->expects(self::never())->method('check');
|
||||
|
||||
$listener = $this->createListener(rateLimiter: $rateLimiter);
|
||||
|
||||
$request = Request::create('/api/users', 'GET');
|
||||
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
$event = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
|
||||
|
||||
$listener($event);
|
||||
|
||||
self::assertFalse($event->hasResponse());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function ignoresLoginGetRequests(): void
|
||||
{
|
||||
$rateLimiter = $this->createMock(LoginRateLimiterInterface::class);
|
||||
$rateLimiter->expects(self::never())->method('check');
|
||||
|
||||
$listener = $this->createListener(rateLimiter: $rateLimiter);
|
||||
|
||||
$request = Request::create('/api/login', 'GET');
|
||||
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
$event = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
|
||||
|
||||
$listener($event);
|
||||
|
||||
self::assertFalse($event->hasResponse());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function proceedsIfEmailMissingFromRequest(): void
|
||||
{
|
||||
$rateLimiter = $this->createMock(LoginRateLimiterInterface::class);
|
||||
$rateLimiter->expects(self::never())->method('check');
|
||||
|
||||
$listener = $this->createListener(rateLimiter: $rateLimiter);
|
||||
|
||||
$request = Request::create(
|
||||
'/api/login',
|
||||
'POST',
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
['CONTENT_TYPE' => 'application/json'],
|
||||
json_encode(['password' => 'password']) // No email
|
||||
);
|
||||
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
$event = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
|
||||
|
||||
$listener($event);
|
||||
|
||||
// Let the validator handle missing email
|
||||
self::assertFalse($event->hasResponse());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\RateLimit;
|
||||
|
||||
use App\Shared\Infrastructure\RateLimit\LoginRateLimitResult;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class LoginRateLimitResultTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
#[DataProvider('fibonacciDelayProvider')]
|
||||
public function fibonacciDelayCalculatesCorrectly(int $attempts, int $expectedDelay): void
|
||||
{
|
||||
self::assertSame($expectedDelay, LoginRateLimitResult::fibonacciDelay($attempts));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{int, int}>
|
||||
*/
|
||||
public static function fibonacciDelayProvider(): iterable
|
||||
{
|
||||
yield '0 attempts = no delay' => [0, 0];
|
||||
yield '1 attempt = no delay' => [1, 0];
|
||||
yield '2 attempts = 1s' => [2, 1];
|
||||
yield '3 attempts = 1s' => [3, 1];
|
||||
yield '4 attempts = 2s' => [4, 2];
|
||||
yield '5 attempts = 3s' => [5, 3];
|
||||
yield '6 attempts = 5s' => [6, 5];
|
||||
yield '7 attempts = 8s' => [7, 8];
|
||||
yield '8 attempts = 13s' => [8, 13];
|
||||
yield '9 attempts = 21s' => [9, 21];
|
||||
yield '10 attempts = 34s' => [10, 34];
|
||||
yield '11 attempts = 55s' => [11, 55];
|
||||
yield '12 attempts = 89s (max)' => [12, 89];
|
||||
yield '20 attempts = 89s (capped)' => [20, 89];
|
||||
yield '100 attempts = 89s (capped)' => [100, 89];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function allowedResultHasCorrectProperties(): void
|
||||
{
|
||||
$result = LoginRateLimitResult::allowed(
|
||||
attempts: 3,
|
||||
delaySeconds: 1,
|
||||
requiresCaptcha: false,
|
||||
);
|
||||
|
||||
self::assertTrue($result->isAllowed);
|
||||
self::assertSame(3, $result->attempts);
|
||||
self::assertSame(1, $result->delaySeconds);
|
||||
self::assertFalse($result->requiresCaptcha);
|
||||
self::assertFalse($result->ipBlocked);
|
||||
self::assertSame(1, $result->retryAfter);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function allowedWithZeroDelayHasNullRetryAfter(): void
|
||||
{
|
||||
$result = LoginRateLimitResult::allowed(
|
||||
attempts: 1,
|
||||
delaySeconds: 0,
|
||||
requiresCaptcha: false,
|
||||
);
|
||||
|
||||
self::assertNull($result->retryAfter);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function blockedResultHasCorrectProperties(): void
|
||||
{
|
||||
$result = LoginRateLimitResult::blocked(retryAfter: 900);
|
||||
|
||||
self::assertFalse($result->isAllowed);
|
||||
self::assertSame(0, $result->attempts);
|
||||
self::assertSame(900, $result->delaySeconds);
|
||||
self::assertFalse($result->requiresCaptcha);
|
||||
self::assertTrue($result->ipBlocked);
|
||||
self::assertSame(900, $result->retryAfter);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function toHeadersIncludesAllRelevantHeaders(): void
|
||||
{
|
||||
$result = LoginRateLimitResult::allowed(
|
||||
attempts: 6,
|
||||
delaySeconds: 5,
|
||||
requiresCaptcha: true,
|
||||
);
|
||||
|
||||
$headers = $result->toHeaders();
|
||||
|
||||
self::assertSame('6', $headers['X-Login-Attempts']);
|
||||
self::assertSame('5', $headers['X-Login-Delay']);
|
||||
self::assertSame('5', $headers['Retry-After']);
|
||||
self::assertSame('true', $headers['X-Captcha-Required']);
|
||||
self::assertArrayNotHasKey('X-IP-Blocked', $headers);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function toHeadersForBlockedIp(): void
|
||||
{
|
||||
$result = LoginRateLimitResult::blocked(retryAfter: 600);
|
||||
|
||||
$headers = $result->toHeaders();
|
||||
|
||||
self::assertSame('true', $headers['X-IP-Blocked']);
|
||||
self::assertSame('600', $headers['Retry-After']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getFormattedDelayFormatsSeconds(): void
|
||||
{
|
||||
$result = LoginRateLimitResult::allowed(attempts: 2, delaySeconds: 1, requiresCaptcha: false);
|
||||
self::assertSame('1 seconde', $result->getFormattedDelay());
|
||||
|
||||
$result = LoginRateLimitResult::allowed(attempts: 6, delaySeconds: 5, requiresCaptcha: false);
|
||||
self::assertSame('5 secondes', $result->getFormattedDelay());
|
||||
|
||||
$result = LoginRateLimitResult::allowed(attempts: 8, delaySeconds: 13, requiresCaptcha: false);
|
||||
self::assertSame('13 secondes', $result->getFormattedDelay());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getFormattedDelayFormatsMinutes(): void
|
||||
{
|
||||
$result = LoginRateLimitResult::blocked(retryAfter: 60);
|
||||
self::assertSame('1 minute', $result->getFormattedDelay());
|
||||
|
||||
$result = LoginRateLimitResult::blocked(retryAfter: 900);
|
||||
self::assertSame('15 minutes', $result->getFormattedDelay());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getFormattedDelayReturnsEmptyForZero(): void
|
||||
{
|
||||
$result = LoginRateLimitResult::allowed(attempts: 1, delaySeconds: 0, requiresCaptcha: false);
|
||||
self::assertSame('', $result->getFormattedDelay());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\RateLimit;
|
||||
|
||||
use App\Shared\Infrastructure\RateLimit\LoginRateLimiter;
|
||||
use App\Shared\Infrastructure\RateLimit\LoginRateLimiterInterface;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
final class LoginRateLimiterTest extends TestCase
|
||||
{
|
||||
private ArrayAdapter $cache;
|
||||
private LoginRateLimiter $rateLimiter;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->cache = new ArrayAdapter();
|
||||
$this->rateLimiter = new LoginRateLimiter($this->cache);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function checkReturnsAllowedForFirstAttempt(): void
|
||||
{
|
||||
$request = $this->createRequest('192.168.1.1');
|
||||
|
||||
$result = $this->rateLimiter->check($request, 'test@example.com');
|
||||
|
||||
self::assertTrue($result->isAllowed);
|
||||
self::assertFalse($result->ipBlocked);
|
||||
self::assertSame(0, $result->attempts);
|
||||
self::assertSame(0, $result->delaySeconds);
|
||||
self::assertFalse($result->requiresCaptcha);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function recordFailureIncrementsAttemptsAndCalculatesFibonacciDelay(): void
|
||||
{
|
||||
$request = $this->createRequest('192.168.1.1');
|
||||
$email = 'test@example.com';
|
||||
|
||||
// First failure - no delay (1 attempt = 0s)
|
||||
$result = $this->rateLimiter->recordFailure($request, $email);
|
||||
self::assertSame(1, $result->attempts);
|
||||
self::assertSame(0, $result->delaySeconds);
|
||||
|
||||
// Second failure - delay 1s (F0)
|
||||
$result = $this->rateLimiter->recordFailure($request, $email);
|
||||
self::assertSame(2, $result->attempts);
|
||||
self::assertSame(1, $result->delaySeconds);
|
||||
|
||||
// Third failure - delay 1s (F1)
|
||||
$result = $this->rateLimiter->recordFailure($request, $email);
|
||||
self::assertSame(3, $result->attempts);
|
||||
self::assertSame(1, $result->delaySeconds);
|
||||
|
||||
// Fourth failure - delay 2s (F2)
|
||||
$result = $this->rateLimiter->recordFailure($request, $email);
|
||||
self::assertSame(4, $result->attempts);
|
||||
self::assertSame(2, $result->delaySeconds);
|
||||
|
||||
// Fifth failure - delay 3s (F3), CAPTCHA required
|
||||
$result = $this->rateLimiter->recordFailure($request, $email);
|
||||
self::assertSame(5, $result->attempts);
|
||||
self::assertSame(3, $result->delaySeconds);
|
||||
self::assertTrue($result->requiresCaptcha);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function checkReturnsCorrectStateAfterFailures(): void
|
||||
{
|
||||
$request = $this->createRequest('192.168.1.1');
|
||||
$email = 'test@example.com';
|
||||
|
||||
// Record 5 failures
|
||||
for ($i = 0; $i < 5; ++$i) {
|
||||
$this->rateLimiter->recordFailure($request, $email);
|
||||
}
|
||||
|
||||
// Check should return the current state
|
||||
$result = $this->rateLimiter->check($request, $email);
|
||||
self::assertSame(5, $result->attempts);
|
||||
self::assertTrue($result->requiresCaptcha);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function blockIpPreventsSubsequentAttempts(): void
|
||||
{
|
||||
$ip = '192.168.1.1';
|
||||
$request = $this->createRequest($ip);
|
||||
|
||||
$this->rateLimiter->blockIp($ip);
|
||||
|
||||
$result = $this->rateLimiter->check($request, 'any@email.com');
|
||||
|
||||
self::assertTrue($result->ipBlocked);
|
||||
self::assertFalse($result->isAllowed);
|
||||
self::assertGreaterThan(0, $result->retryAfter);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function recordFailureBlocksIpAfter20Attempts(): void
|
||||
{
|
||||
$request = $this->createRequest('192.168.1.1');
|
||||
$email = 'attacker@example.com';
|
||||
|
||||
// Record 19 failures - should not be blocked
|
||||
for ($i = 0; $i < 19; ++$i) {
|
||||
$result = $this->rateLimiter->recordFailure($request, $email);
|
||||
self::assertFalse($result->ipBlocked);
|
||||
}
|
||||
|
||||
// 20th failure - should be blocked
|
||||
$result = $this->rateLimiter->recordFailure($request, $email);
|
||||
self::assertTrue($result->ipBlocked);
|
||||
self::assertSame(LoginRateLimiterInterface::IP_BLOCK_DURATION, $result->retryAfter);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function resetClearsAttemptsForEmail(): void
|
||||
{
|
||||
$request = $this->createRequest('192.168.1.1');
|
||||
$email = 'test@example.com';
|
||||
|
||||
// Record some failures
|
||||
$this->rateLimiter->recordFailure($request, $email);
|
||||
$this->rateLimiter->recordFailure($request, $email);
|
||||
|
||||
// Reset
|
||||
$this->rateLimiter->reset($email);
|
||||
|
||||
// Check should show 0 attempts
|
||||
$result = $this->rateLimiter->check($request, $email);
|
||||
self::assertSame(0, $result->attempts);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function isIpBlockedReturnsFalseForUnblockedIp(): void
|
||||
{
|
||||
self::assertFalse($this->rateLimiter->isIpBlocked('192.168.1.1'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function isIpBlockedReturnsTrueForBlockedIp(): void
|
||||
{
|
||||
$ip = '192.168.1.1';
|
||||
$this->rateLimiter->blockIp($ip);
|
||||
|
||||
self::assertTrue($this->rateLimiter->isIpBlocked($ip));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function differentEmailsHaveSeparateAttemptCounters(): void
|
||||
{
|
||||
$request = $this->createRequest('192.168.1.1');
|
||||
|
||||
// Record failures for email1
|
||||
$this->rateLimiter->recordFailure($request, 'email1@test.com');
|
||||
$this->rateLimiter->recordFailure($request, 'email1@test.com');
|
||||
|
||||
// Record failure for email2
|
||||
$this->rateLimiter->recordFailure($request, 'email2@test.com');
|
||||
|
||||
// Check each email
|
||||
$result1 = $this->rateLimiter->check($request, 'email1@test.com');
|
||||
$result2 = $this->rateLimiter->check($request, 'email2@test.com');
|
||||
|
||||
self::assertSame(2, $result1->attempts);
|
||||
self::assertSame(1, $result2->attempts);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function emailNormalizationIsCaseInsensitive(): void
|
||||
{
|
||||
$request = $this->createRequest('192.168.1.1');
|
||||
|
||||
$this->rateLimiter->recordFailure($request, 'Test@Example.COM');
|
||||
$this->rateLimiter->recordFailure($request, 'test@example.com');
|
||||
|
||||
$result = $this->rateLimiter->check($request, 'TEST@EXAMPLE.COM');
|
||||
|
||||
self::assertSame(2, $result->attempts);
|
||||
}
|
||||
|
||||
private function createRequest(string $clientIp): Request
|
||||
{
|
||||
$request = Request::create('/api/login', 'POST');
|
||||
$request->server->set('REMOTE_ADDR', $clientIp);
|
||||
|
||||
return $request;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user