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