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,218 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Infrastructure\Captcha;
use App\Shared\Infrastructure\Captcha\TurnstileValidator;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
final class TurnstileValidatorTest extends TestCase
{
private const string SECRET_KEY = 'test-secret-key';
#[Test]
public function validTokenReturnsValid(): void
{
$httpClient = new MockHttpClient([
new MockResponse(json_encode([
'success' => true,
])),
]);
$validator = new TurnstileValidator($httpClient, new NullLogger(), self::SECRET_KEY);
$result = $validator->validate('valid-token', '192.168.1.1');
self::assertTrue($result->isValid);
self::assertNull($result->errorMessage);
}
#[Test]
public function invalidTokenReturnsInvalid(): void
{
$httpClient = new MockHttpClient([
new MockResponse(json_encode([
'success' => false,
'error-codes' => ['invalid-input-response'],
])),
]);
$validator = new TurnstileValidator($httpClient, new NullLogger(), self::SECRET_KEY);
$result = $validator->validate('invalid-token', '192.168.1.1');
self::assertFalse($result->isValid);
self::assertSame('Token invalide ou expiré', $result->errorMessage);
}
#[Test]
public function expiredTokenReturnsInvalid(): void
{
$httpClient = new MockHttpClient([
new MockResponse(json_encode([
'success' => false,
'error-codes' => ['timeout-or-duplicate'],
])),
]);
$validator = new TurnstileValidator($httpClient, new NullLogger(), self::SECRET_KEY);
$result = $validator->validate('expired-token', '192.168.1.1');
self::assertFalse($result->isValid);
self::assertSame('Token expiré ou déjà utilisé', $result->errorMessage);
}
#[Test]
public function emptyTokenReturnsInvalid(): void
{
$httpClient = new MockHttpClient(); // No request should be made
$validator = new TurnstileValidator($httpClient, new NullLogger(), self::SECRET_KEY);
$result = $validator->validate('', '192.168.1.1');
self::assertFalse($result->isValid);
self::assertSame('Token vide', $result->errorMessage);
}
#[Test]
public function apiErrorReturnsValidWhenFailOpenEnabled(): void
{
// Simulate API error with fail open
$httpClient = new MockHttpClient([
new MockResponse('', ['http_code' => 500]),
]);
$validator = new TurnstileValidator($httpClient, new NullLogger(), self::SECRET_KEY, failOpen: true);
$result = $validator->validate('some-token', '192.168.1.1');
// Fail open - allow through on API errors
self::assertTrue($result->isValid);
}
#[Test]
public function apiErrorReturnsInvalidWhenFailOpenDisabled(): void
{
// Simulate API error with fail closed (production default)
$httpClient = new MockHttpClient([
new MockResponse('', ['http_code' => 500]),
]);
$validator = new TurnstileValidator($httpClient, new NullLogger(), self::SECRET_KEY, failOpen: false);
$result = $validator->validate('some-token', '192.168.1.1');
// Fail closed - block on API errors
self::assertFalse($result->isValid);
self::assertSame('Service de vérification temporairement indisponible', $result->errorMessage);
}
#[Test]
public function networkErrorReturnsValidWhenFailOpenEnabled(): void
{
// Simulate network error with fail open
$httpClient = new MockHttpClient([
new MockResponse('', ['error' => 'Network error']),
]);
$validator = new TurnstileValidator($httpClient, new NullLogger(), self::SECRET_KEY, failOpen: true);
$result = $validator->validate('some-token', '192.168.1.1');
// Fail open - allow through on network errors
self::assertTrue($result->isValid);
}
#[Test]
public function networkErrorReturnsInvalidWhenFailOpenDisabled(): void
{
// Simulate network error with fail closed
$httpClient = new MockHttpClient([
new MockResponse('', ['error' => 'Network error']),
]);
$validator = new TurnstileValidator($httpClient, new NullLogger(), self::SECRET_KEY, failOpen: false);
$result = $validator->validate('some-token', '192.168.1.1');
// Fail closed - block on network errors
self::assertFalse($result->isValid);
}
#[Test]
public function invalidSecretKeyReturnsInvalid(): void
{
$httpClient = new MockHttpClient([
new MockResponse(json_encode([
'success' => false,
'error-codes' => ['invalid-input-secret'],
])),
]);
$validator = new TurnstileValidator($httpClient, new NullLogger(), self::SECRET_KEY);
$result = $validator->validate('token', '192.168.1.1');
self::assertFalse($result->isValid);
self::assertSame('Configuration serveur invalide', $result->errorMessage);
}
#[Test]
public function missingSecretKeyReturnsInvalid(): void
{
$httpClient = new MockHttpClient([
new MockResponse(json_encode([
'success' => false,
'error-codes' => ['missing-input-secret'],
])),
]);
$validator = new TurnstileValidator($httpClient, new NullLogger(), self::SECRET_KEY);
$result = $validator->validate('token', '192.168.1.1');
self::assertFalse($result->isValid);
self::assertSame('Configuration serveur invalide', $result->errorMessage);
}
#[Test]
public function unknownErrorCodeReturnsGenericMessage(): void
{
$httpClient = new MockHttpClient([
new MockResponse(json_encode([
'success' => false,
'error-codes' => ['unknown-error-code'],
])),
]);
$validator = new TurnstileValidator($httpClient, new NullLogger(), self::SECRET_KEY);
$result = $validator->validate('token', '192.168.1.1');
self::assertFalse($result->isValid);
self::assertSame('Vérification échouée', $result->errorMessage);
}
#[Test]
public function validationWithoutIpWorks(): void
{
$httpClient = new MockHttpClient([
new MockResponse(json_encode([
'success' => true,
])),
]);
$validator = new TurnstileValidator($httpClient, new NullLogger(), self::SECRET_KEY);
$result = $validator->validate('valid-token');
self::assertTrue($result->isValid);
}
}

View File

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

View File

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

View File

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