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