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
196 lines
6.3 KiB
PHP
196 lines
6.3 KiB
PHP
<?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;
|
|
}
|
|
}
|