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
219 lines
6.8 KiB
PHP
219 lines
6.8 KiB
PHP
<?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);
|
|
}
|
|
}
|