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

@@ -14,7 +14,7 @@ use App\Administration\Domain\Exception\ActivationTokenNotFoundException;
use App\Administration\Domain\Model\ActivationToken\ActivationToken;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryActivationTokenRepository;
use App\Shared\Domain\Clock;
use App\Shared\Infrastructure\Tenant\TenantId;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use PHPUnit\Framework\Attributes\Test;

View File

@@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Service;
use App\Administration\Application\Service\RefreshTokenManager;
use App\Administration\Domain\Exception\TokenAlreadyRotatedException;
use App\Administration\Domain\Exception\TokenReplayDetectedException;
use App\Administration\Domain\Model\RefreshToken\DeviceFingerprint;
use App\Administration\Domain\Model\RefreshToken\RefreshToken;
use App\Administration\Domain\Model\RefreshToken\RefreshTokenId;
use App\Administration\Domain\Model\RefreshToken\TokenFamilyId;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\RefreshTokenRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class RefreshTokenManagerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private RefreshTokenRepository $repository;
private Clock $clock;
private RefreshTokenManager $manager;
protected function setUp(): void
{
$this->repository = $this->createMock(RefreshTokenRepository::class);
$this->clock = new class implements Clock {
public DateTimeImmutable $now;
public function __construct()
{
$this->now = new DateTimeImmutable('2026-01-31 10:00:00');
}
public function now(): DateTimeImmutable
{
return $this->now;
}
};
$this->manager = new RefreshTokenManager($this->repository, $this->clock);
}
#[Test]
public function createGeneratesAndSavesNewToken(): void
{
$userId = UserId::generate();
$tenantId = TenantId::fromString(self::TENANT_ID);
$fingerprint = DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1');
$this->repository->expects(self::once())
->method('save')
->with(self::isInstanceOf(RefreshToken::class));
$token = $this->manager->create($userId, $tenantId, $fingerprint);
self::assertSame($userId, $token->userId);
self::assertSame($tenantId, $token->tenantId);
}
#[Test]
public function refreshThrowsForTokenNotFound(): void
{
$this->repository->method('find')->willReturn(null);
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Token not found');
// Use a valid UUID format for the token ID
$validUuid = '550e8400-e29b-41d4-a716-446655440099';
$this->manager->refresh(
base64_encode($validUuid),
DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1'),
);
}
#[Test]
public function refreshRotatesTokenAndReturnsNew(): void
{
$existingToken = $this->createExistingToken(isRotated: false);
$tokenString = $existingToken->toTokenString();
$fingerprint = $existingToken->deviceFingerprint;
$this->repository->method('find')
->willReturn($existingToken);
$this->repository->expects(self::exactly(2))
->method('save');
$newToken = $this->manager->refresh($tokenString, $fingerprint);
self::assertNotEquals($existingToken->id, $newToken->id);
self::assertEquals($existingToken->familyId, $newToken->familyId);
}
#[Test]
public function refreshThrowsForExpiredToken(): void
{
$expiredToken = RefreshToken::create(
UserId::generate(),
TenantId::fromString(self::TENANT_ID),
DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1'),
new DateTimeImmutable('2026-01-01 10:00:00'), // Issued long ago
3600, // 1 hour TTL - expired
);
$this->repository->method('find')->willReturn($expiredToken);
$this->repository->expects(self::once())->method('delete');
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('expired');
$this->manager->refresh(
$expiredToken->toTokenString(),
$expiredToken->deviceFingerprint,
);
}
#[Test]
public function refreshThrowsAndInvalidatesFamilyForWrongDevice(): void
{
$existingToken = $this->createExistingToken();
$differentFingerprint = DeviceFingerprint::fromRequest('Chrome/110', '10.0.0.1');
$this->repository->method('find')->willReturn($existingToken);
$this->repository->expects(self::once())
->method('invalidateFamily')
->with($existingToken->familyId);
$this->expectException(TokenReplayDetectedException::class);
$this->manager->refresh($existingToken->toTokenString(), $differentFingerprint);
}
#[Test]
public function refreshThrowsAndInvalidatesFamilyForReplayAttack(): void
{
// Token rotaté il y a plus de 30 secondes (hors grace period)
$rotatedToken = $this->createExistingToken(
isRotated: true,
issuedAt: new DateTimeImmutable('2026-01-31 09:00:00'),
rotatedAt: new DateTimeImmutable('2026-01-31 09:30:00'), // Rotaté 30 min avant "now"
);
$this->repository->method('find')->willReturn($rotatedToken);
$this->repository->expects(self::once())
->method('invalidateFamily')
->with($rotatedToken->familyId);
$this->expectException(TokenReplayDetectedException::class);
$this->manager->refresh(
$rotatedToken->toTokenString(),
$rotatedToken->deviceFingerprint,
);
}
#[Test]
public function refreshThrowsTokenAlreadyRotatedForGracePeriod(): void
{
// Token rotaté il y a 10 secondes (dans la grace period de 30s)
$rotatedToken = $this->createExistingToken(
isRotated: true,
issuedAt: new DateTimeImmutable('2026-01-31 09:00:00'),
rotatedAt: new DateTimeImmutable('2026-01-31 09:59:50'), // Rotaté 10s avant "now" (10:00:00)
);
$this->repository->method('find')->willReturn($rotatedToken);
// Ne doit PAS invalider la famille
$this->repository->expects(self::never())->method('invalidateFamily');
$this->expectException(TokenAlreadyRotatedException::class);
$this->manager->refresh(
$rotatedToken->toTokenString(),
$rotatedToken->deviceFingerprint,
);
}
#[Test]
public function revokeInvalidatesTokenFamily(): void
{
$existingToken = $this->createExistingToken();
$this->repository->method('find')->willReturn($existingToken);
$this->repository->expects(self::once())
->method('invalidateFamily')
->with($existingToken->familyId);
$this->manager->revoke($existingToken->toTokenString());
}
private function createExistingToken(
bool $isRotated = false,
?DateTimeImmutable $issuedAt = null,
?DateTimeImmutable $rotatedAt = null,
): RefreshToken {
$issuedAt ??= new DateTimeImmutable('2026-01-31 09:00:00');
return RefreshToken::reconstitute(
id: RefreshTokenId::generate(),
familyId: TokenFamilyId::generate(),
userId: UserId::generate(),
tenantId: TenantId::fromString(self::TENANT_ID),
deviceFingerprint: DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1'),
issuedAt: $issuedAt,
expiresAt: $issuedAt->modify('+7 days'),
rotatedFrom: null,
isRotated: $isRotated,
rotatedAt: $rotatedAt,
);
}
}

View File

@@ -10,7 +10,7 @@ use App\Administration\Domain\Exception\ActivationTokenAlreadyUsedException;
use App\Administration\Domain\Exception\ActivationTokenExpiredException;
use App\Administration\Domain\Model\ActivationToken\ActivationToken;
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
use App\Shared\Infrastructure\Tenant\TenantId;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

View File

@@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\RefreshToken;
use App\Administration\Domain\Model\RefreshToken\DeviceFingerprint;
use App\Administration\Domain\Model\RefreshToken\RefreshToken;
use App\Administration\Domain\Model\User\UserId;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class RefreshTokenTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
#[Test]
public function createGeneratesTokenWithCorrectData(): void
{
$userId = UserId::generate();
$tenantId = TenantId::fromString(self::TENANT_ID);
$fingerprint = DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1');
$issuedAt = new DateTimeImmutable('2026-01-31 10:00:00');
$token = RefreshToken::create($userId, $tenantId, $fingerprint, $issuedAt);
self::assertSame($userId, $token->userId);
self::assertSame($tenantId, $token->tenantId);
self::assertTrue($token->deviceFingerprint->equals($fingerprint));
self::assertEquals($issuedAt, $token->issuedAt);
self::assertNull($token->rotatedFrom);
self::assertFalse($token->isRotated);
}
#[Test]
public function createSetsExpirationBasedOnTtl(): void
{
$issuedAt = new DateTimeImmutable('2026-01-31 10:00:00');
$ttl = 86400; // 1 day
$token = RefreshToken::create(
UserId::generate(),
TenantId::fromString(self::TENANT_ID),
DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1'),
$issuedAt,
$ttl,
);
$expectedExpiry = $issuedAt->modify('+86400 seconds');
self::assertEquals($expectedExpiry, $token->expiresAt);
}
#[Test]
public function rotateCreatesNewTokenWithSameFamily(): void
{
$token = $this->createToken();
$rotateAt = new DateTimeImmutable('2026-01-31 11:00:00');
[$newToken, $oldToken] = $token->rotate($rotateAt);
// Nouveau token
self::assertNotSame($token->id, $newToken->id);
self::assertSame($token->familyId, $newToken->familyId);
self::assertSame($token->userId, $newToken->userId);
self::assertSame($token->id, $newToken->rotatedFrom);
self::assertFalse($newToken->isRotated);
// Ancien token marqué comme rotaté
self::assertSame($token->id, $oldToken->id);
self::assertTrue($oldToken->isRotated);
}
#[Test]
public function isExpiredReturnsTrueWhenPastExpiration(): void
{
$issuedAt = new DateTimeImmutable('2026-01-31 10:00:00');
$token = RefreshToken::create(
UserId::generate(),
TenantId::fromString(self::TENANT_ID),
DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1'),
$issuedAt,
3600, // 1 hour
);
self::assertFalse($token->isExpired(new DateTimeImmutable('2026-01-31 10:30:00')));
self::assertTrue($token->isExpired(new DateTimeImmutable('2026-01-31 11:30:00')));
}
#[Test]
public function isInGracePeriodReturnsTrueWithin30SecondsOfRotation(): void
{
$token = $this->createToken();
$rotateAt = new DateTimeImmutable('2026-01-31 11:00:00');
[$_, $oldToken] = $token->rotate($rotateAt);
// Dans la grace period (30s après rotation)
self::assertTrue($oldToken->isInGracePeriod(new DateTimeImmutable('2026-01-31 11:00:15')));
self::assertTrue($oldToken->isInGracePeriod(new DateTimeImmutable('2026-01-31 11:00:30')));
// Après la grace period
self::assertFalse($oldToken->isInGracePeriod(new DateTimeImmutable('2026-01-31 11:00:31')));
self::assertFalse($oldToken->isInGracePeriod(new DateTimeImmutable('2026-01-31 11:01:00')));
}
#[Test]
public function rotatePreservesOriginalTtl(): void
{
$issuedAt = new DateTimeImmutable('2026-01-31 10:00:00');
$originalTtl = 86400; // 1 day (web session)
$token = RefreshToken::create(
UserId::generate(),
TenantId::fromString(self::TENANT_ID),
DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1'),
$issuedAt,
$originalTtl,
);
$rotateAt = new DateTimeImmutable('2026-01-31 14:00:00');
[$newToken, $oldToken] = $token->rotate($rotateAt);
// Le nouveau token doit avoir le même TTL que l'original
$expectedExpiry = $rotateAt->modify("+{$originalTtl} seconds");
self::assertEquals($expectedExpiry, $newToken->expiresAt);
// L'ancien token garde son expiration originale
self::assertEquals($issuedAt->modify("+{$originalTtl} seconds"), $oldToken->expiresAt);
// L'ancien token a rotatedAt défini
self::assertEquals($rotateAt, $oldToken->rotatedAt);
self::assertNull($newToken->rotatedAt);
}
#[Test]
public function matchesDeviceReturnsTrueForSameFingerprint(): void
{
$fingerprint = DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1');
$token = RefreshToken::create(
UserId::generate(),
TenantId::fromString(self::TENANT_ID),
$fingerprint,
new DateTimeImmutable(),
);
$sameFingerprint = DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1');
$differentFingerprint = DeviceFingerprint::fromRequest('Chrome/110', '10.0.0.1');
self::assertTrue($token->matchesDevice($sameFingerprint));
self::assertFalse($token->matchesDevice($differentFingerprint));
}
#[Test]
public function toTokenStringAndExtractIdRoundTrips(): void
{
$token = $this->createToken();
$tokenString = $token->toTokenString();
$extractedId = RefreshToken::extractIdFromTokenString($tokenString);
self::assertEquals($token->id, $extractedId);
}
private function createToken(): RefreshToken
{
return RefreshToken::create(
UserId::generate(),
TenantId::fromString(self::TENANT_ID),
DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1'),
new DateTimeImmutable('2026-01-31 10:00:00'),
);
}
}

View File

@@ -14,7 +14,7 @@ use App\Administration\Domain\Model\User\StatutCompte;
use App\Administration\Domain\Model\User\User;
use App\Administration\Domain\Policy\ConsentementParentalPolicy;
use App\Shared\Domain\Clock;
use App\Shared\Infrastructure\Tenant\TenantId;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

View File

@@ -16,7 +16,7 @@ use App\Administration\Infrastructure\Api\Processor\ActivateAccountProcessor;
use App\Administration\Infrastructure\Api\Resource\ActivateAccountInput;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryActivationTokenRepository;
use App\Shared\Domain\Clock;
use App\Shared\Infrastructure\Tenant\TenantId;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
@@ -163,7 +163,7 @@ final class ActivateAccountProcessorTest extends TestCase
return null;
}
public function findByEmail(\App\Administration\Domain\Model\User\Email $email): ?\App\Administration\Domain\Model\User\User
public function findByEmail(\App\Administration\Domain\Model\User\Email $email, TenantId $tenantId): ?\App\Administration\Domain\Model\User\User
{
return null;
}

View File

@@ -18,12 +18,15 @@ use Psr\Cache\CacheItemPoolInterface;
/**
* Tests for CacheUserRepository.
*
* Key invariant: Users must not expire from cache (unlike activation tokens which have 7-day TTL).
* This was a bug where users were stored in the activation_tokens.cache pool with TTL,
* causing activated accounts to become inaccessible after 7 days.
* Key invariants:
* - Users must not expire from cache (unlike activation tokens which have 7-day TTL)
* - Email lookups are scoped by tenant ID for multi-tenant isolation
*/
final class CacheUserRepositoryTest extends TestCase
{
private const string TENANT_ALPHA_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
private const string TENANT_BETA_ID = 'b2c3d4e5-f6a7-8901-bcde-f12345678901';
#[Test]
public function userIsSavedWithoutExpiration(): void
{
@@ -48,7 +51,7 @@ final class CacheUserRepositoryTest extends TestCase
$user = User::creer(
email: new Email('test@example.com'),
role: Role::PARENT,
tenantId: TenantId::fromString('550e8400-e29b-41d4-a716-446655440001'),
tenantId: TenantId::fromString(self::TENANT_ALPHA_ID),
schoolName: 'École Test',
dateNaissance: null,
createdAt: new DateTimeImmutable(),
@@ -72,13 +75,12 @@ final class CacheUserRepositoryTest extends TestCase
// Arrange
$userId = '550e8400-e29b-41d4-a716-446655440001';
$email = 'test@example.com';
$tenantId = '550e8400-e29b-41d4-a716-446655440002';
$userData = [
'id' => $userId,
'email' => $email,
'role' => 'ROLE_PARENT',
'tenant_id' => $tenantId,
'tenant_id' => self::TENANT_ALPHA_ID,
'school_name' => 'École Test',
'statut' => 'pending',
'hashed_password' => null,
@@ -108,18 +110,18 @@ final class CacheUserRepositoryTest extends TestCase
}
#[Test]
public function userCanBeRetrievedByEmail(): void
public function userCanBeRetrievedByEmailWithinSameTenant(): void
{
// Arrange
$userId = '550e8400-e29b-41d4-a716-446655440001';
$email = 'test@example.com';
$tenantId = '550e8400-e29b-41d4-a716-446655440002';
$tenantId = TenantId::fromString(self::TENANT_ALPHA_ID);
$userData = [
'id' => $userId,
'email' => $email,
'role' => 'ROLE_PARENT',
'tenant_id' => $tenantId,
'tenant_id' => self::TENANT_ALPHA_ID,
'school_name' => 'École Test',
'statut' => 'pending',
'hashed_password' => null,
@@ -139,8 +141,10 @@ final class CacheUserRepositoryTest extends TestCase
$cachePool = $this->createMock(CacheItemPoolInterface::class);
$cachePool->method('getItem')
->willReturnCallback(static function ($key) use ($emailIndexItem, $userItem) {
if (str_starts_with($key, 'user_email:')) {
->willReturnCallback(static function ($key) use ($emailIndexItem, $userItem, $tenantId) {
// Email index key should include tenant ID
$expectedEmailKey = 'user_email:' . $tenantId . ':test_at_example_dot_com';
if ($key === $expectedEmailKey) {
return $emailIndexItem;
}
@@ -150,11 +154,86 @@ final class CacheUserRepositoryTest extends TestCase
$repository = new CacheUserRepository($cachePool);
// Act
$user = $repository->findByEmail(new Email($email));
$user = $repository->findByEmail(new Email($email), $tenantId);
// Assert
self::assertNotNull($user);
self::assertSame($userId, (string) $user->id);
self::assertSame($email, (string) $user->email);
}
#[Test]
public function userCannotBeFoundByEmailInDifferentTenant(): void
{
// Arrange: User exists in tenant Alpha
$tenantAlpha = TenantId::fromString(self::TENANT_ALPHA_ID);
$tenantBeta = TenantId::fromString(self::TENANT_BETA_ID);
$email = new Email('test@example.com');
// Cache miss for tenant Beta's email index
$missItem = $this->createMock(CacheItemInterface::class);
$missItem->method('isHit')->willReturn(false);
$cachePool = $this->createMock(CacheItemPoolInterface::class);
$cachePool->method('getItem')
->willReturnCallback(static function ($key) use ($missItem, $tenantBeta) {
// When looking up with tenant Beta, return cache miss
$betaEmailKey = 'user_email:' . $tenantBeta . ':test_at_example_dot_com';
if ($key === $betaEmailKey) {
return $missItem;
}
// For any other key, also return miss
return $missItem;
});
$repository = new CacheUserRepository($cachePool);
// Act: Try to find user in tenant Beta (where they don't exist)
$user = $repository->findByEmail($email, $tenantBeta);
// Assert: User should not be found
self::assertNull($user, 'User from tenant Alpha should not be found when searching in tenant Beta');
}
#[Test]
public function emailIndexKeyIncludesTenantId(): void
{
// Arrange: Track what cache keys are used
$savedKeys = [];
$cacheItem = $this->createMock(CacheItemInterface::class);
$cacheItem->method('set')->willReturnSelf();
$cachePool = $this->createMock(CacheItemPoolInterface::class);
$cachePool->method('getItem')
->willReturnCallback(static function ($key) use (&$savedKeys, $cacheItem) {
$savedKeys[] = $key;
return $cacheItem;
});
$cachePool->method('save')->willReturn(true);
$repository = new CacheUserRepository($cachePool);
$user = User::creer(
email: new Email('test@example.com'),
role: Role::PARENT,
tenantId: TenantId::fromString(self::TENANT_ALPHA_ID),
schoolName: 'École Test',
dateNaissance: null,
createdAt: new DateTimeImmutable(),
);
// Act
$repository->save($user);
// Assert: Email index key should include tenant ID
$emailIndexKey = 'user_email:' . self::TENANT_ALPHA_ID . ':test_at_example_dot_com';
self::assertContains(
$emailIndexKey,
$savedKeys,
'Email index cache key should include tenant ID for multi-tenant isolation'
);
}
}

View File

@@ -0,0 +1,223 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Security;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\StatutCompte;
use App\Administration\Domain\Model\User\User;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\UserRepository;
use App\Administration\Infrastructure\Security\DatabaseUserProvider;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Administration\Infrastructure\Security\SecurityUserFactory;
use App\Shared\Infrastructure\Tenant\InMemoryTenantRegistry;
use App\Shared\Infrastructure\Tenant\TenantConfig;
use App\Shared\Infrastructure\Tenant\TenantId;
use App\Shared\Infrastructure\Tenant\TenantResolver;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use stdClass;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
final class DatabaseUserProviderTest extends TestCase
{
private const string TENANT_ALPHA_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
private const string TENANT_BETA_ID = 'b2c3d4e5-f6a7-8901-bcde-f12345678901';
#[Test]
public function loadUserByIdentifierReturnsSecurityUserForActiveAccount(): void
{
$tenantId = TenantId::fromString(self::TENANT_ALPHA_ID);
$domainUser = $this->createUser($tenantId, StatutCompte::ACTIF, hashedPassword: '$argon2id$hash');
$repository = $this->createMock(UserRepository::class);
$repository->method('findByEmail')
->with(
self::callback(static fn (Email $email) => (string) $email === 'user@example.com'),
self::callback(static fn (TenantId $id) => (string) $id === self::TENANT_ALPHA_ID)
)
->willReturn($domainUser);
$provider = $this->createProvider($repository, 'ecole-alpha.classeo.local');
$securityUser = $provider->loadUserByIdentifier('user@example.com');
self::assertInstanceOf(SecurityUser::class, $securityUser);
self::assertSame((string) $domainUser->email, $securityUser->getUserIdentifier());
self::assertSame((string) $domainUser->id, $securityUser->userId());
}
#[Test]
public function loadUserByIdentifierThrowsForNonExistentUser(): void
{
$repository = $this->createMock(UserRepository::class);
$repository->method('findByEmail')->willReturn(null);
$provider = $this->createProvider($repository, 'ecole-alpha.classeo.local');
$this->expectException(UserNotFoundException::class);
$provider->loadUserByIdentifier('nonexistent@example.com');
}
#[Test]
public function loadUserByIdentifierThrowsForInactiveAccount(): void
{
$tenantId = TenantId::fromString(self::TENANT_ALPHA_ID);
$domainUser = $this->createUser($tenantId, StatutCompte::EN_ATTENTE);
$repository = $this->createMock(UserRepository::class);
$repository->method('findByEmail')->willReturn($domainUser);
$provider = $this->createProvider($repository, 'ecole-alpha.classeo.local');
// Should throw because account is not active (AC2: no account existence revelation)
$this->expectException(UserNotFoundException::class);
$provider->loadUserByIdentifier('user@example.com');
}
#[Test]
public function loadUserByIdentifierThrowsForUnknownTenant(): void
{
$repository = $this->createMock(UserRepository::class);
// Repository should not even be called if tenant is unknown
$repository->expects(self::never())->method('findByEmail');
$provider = $this->createProvider($repository, 'unknown-tenant.classeo.local');
// Should throw generic error (don't reveal tenant doesn't exist)
$this->expectException(UserNotFoundException::class);
$provider->loadUserByIdentifier('user@example.com');
}
#[Test]
public function loadUserByIdentifierUsesCorrectTenantFromRequest(): void
{
$tenantAlphaId = TenantId::fromString(self::TENANT_ALPHA_ID);
$tenantBetaId = TenantId::fromString(self::TENANT_BETA_ID);
// User exists in Alpha but not in Beta
$domainUser = $this->createUser($tenantAlphaId, StatutCompte::ACTIF, hashedPassword: '$argon2id$hash');
$repository = $this->createMock(UserRepository::class);
$repository->method('findByEmail')
->willReturnCallback(static function (Email $email, TenantId $tenantId) use ($domainUser, $tenantAlphaId) {
// Only return user if looking in Alpha tenant
if ((string) $tenantId === (string) $tenantAlphaId) {
return $domainUser;
}
return null;
});
// Request comes from Beta tenant
$provider = $this->createProvider($repository, 'ecole-beta.classeo.local');
// Should throw because user doesn't exist in Beta tenant
$this->expectException(UserNotFoundException::class);
$provider->loadUserByIdentifier('user@example.com');
}
#[Test]
public function refreshUserReloadsUserFromRepository(): void
{
$tenantId = TenantId::fromString(self::TENANT_ALPHA_ID);
$domainUser = $this->createUser($tenantId, StatutCompte::ACTIF, hashedPassword: '$argon2id$hash');
$repository = $this->createMock(UserRepository::class);
$repository->expects(self::once())
->method('findByEmail')
->willReturn($domainUser);
$provider = $this->createProvider($repository, 'ecole-alpha.classeo.local');
$factory = new SecurityUserFactory();
$existingSecurityUser = $factory->fromDomainUser($domainUser);
$refreshedUser = $provider->refreshUser($existingSecurityUser);
self::assertInstanceOf(SecurityUser::class, $refreshedUser);
}
#[Test]
public function supportsClassReturnsTrueForSecurityUser(): void
{
$repository = $this->createMock(UserRepository::class);
$provider = $this->createProvider($repository, 'ecole-alpha.classeo.local');
self::assertTrue($provider->supportsClass(SecurityUser::class));
self::assertFalse($provider->supportsClass(stdClass::class));
}
#[Test]
public function localhostFallsBackToEcoleAlphaTenant(): void
{
$tenantAlphaId = TenantId::fromString(self::TENANT_ALPHA_ID);
$domainUser = $this->createUser($tenantAlphaId, StatutCompte::ACTIF, hashedPassword: '$argon2id$hash');
$repository = $this->createMock(UserRepository::class);
$repository->method('findByEmail')
->with(
self::callback(static fn (Email $email) => (string) $email === 'user@example.com'),
// Should use ecole-alpha tenant ID when accessed from localhost
self::callback(static fn (TenantId $id) => (string) $id === self::TENANT_ALPHA_ID)
)
->willReturn($domainUser);
// Request from localhost should use ecole-alpha tenant
$provider = $this->createProvider($repository, 'localhost');
$securityUser = $provider->loadUserByIdentifier('user@example.com');
self::assertInstanceOf(SecurityUser::class, $securityUser);
}
private function createProvider(UserRepository $repository, string $host): DatabaseUserProvider
{
$tenantRegistry = new InMemoryTenantRegistry([
new TenantConfig(
TenantId::fromString(self::TENANT_ALPHA_ID),
'ecole-alpha',
'postgresql://localhost/alpha'
),
new TenantConfig(
TenantId::fromString(self::TENANT_BETA_ID),
'ecole-beta',
'postgresql://localhost/beta'
),
]);
$tenantResolver = new TenantResolver($tenantRegistry, 'classeo.local');
$request = Request::create('https://' . $host . '/api/login');
$requestStack = new RequestStack();
$requestStack->push($request);
return new DatabaseUserProvider($repository, $tenantResolver, $requestStack, new SecurityUserFactory());
}
private function createUser(TenantId $tenantId, StatutCompte $statut, ?string $hashedPassword = null): User
{
return User::reconstitute(
id: UserId::generate(),
email: new Email('user@example.com'),
role: Role::PARENT,
tenantId: $tenantId,
schoolName: 'École Test',
statut: $statut,
dateNaissance: null,
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
hashedPassword: $hashedPassword,
activatedAt: $statut === StatutCompte::ACTIF ? new DateTimeImmutable() : null,
consentementParental: null,
);
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Security;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Infrastructure\Security\JwtPayloadEnricher;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Shared\Infrastructure\Tenant\TenantId;
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTCreatedEvent;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class JwtPayloadEnricherTest extends TestCase
{
private JwtPayloadEnricher $enricher;
protected function setUp(): void
{
$this->enricher = new JwtPayloadEnricher();
}
#[Test]
public function onJWTCreatedAddsCustomClaimsToPayload(): void
{
$userId = UserId::generate();
$tenantId = TenantId::fromString('550e8400-e29b-41d4-a716-446655440002');
$securityUser = new SecurityUser(
userId: $userId,
email: 'user@example.com',
hashedPassword: 'hashed',
tenantId: $tenantId,
roles: ['ROLE_PARENT'],
);
$initialPayload = ['username' => 'user@example.com'];
$event = new JWTCreatedEvent($initialPayload, $securityUser);
$this->enricher->onJWTCreated($event);
$payload = $event->getData();
self::assertSame((string) $userId, $payload['user_id']);
self::assertSame((string) $tenantId, $payload['tenant_id']);
self::assertSame(['ROLE_PARENT'], $payload['roles']);
}
#[Test]
public function onJWTCreatedPreservesExistingPayloadData(): void
{
$securityUser = new SecurityUser(
userId: UserId::generate(),
email: 'user@example.com',
hashedPassword: 'hashed',
tenantId: TenantId::fromString('550e8400-e29b-41d4-a716-446655440002'),
roles: ['ROLE_ADMIN'],
);
$initialPayload = [
'username' => 'user@example.com',
'iat' => 1706436600,
'exp' => 1706438400,
];
$event = new JWTCreatedEvent($initialPayload, $securityUser);
$this->enricher->onJWTCreated($event);
$payload = $event->getData();
self::assertSame('user@example.com', $payload['username']);
self::assertSame(1706436600, $payload['iat']);
self::assertSame(1706438400, $payload['exp']);
}
#[Test]
public function onJWTCreatedDoesNothingForNonSecurityUser(): void
{
$nonSecurityUser = $this->createMock(\Symfony\Component\Security\Core\User\UserInterface::class);
$initialPayload = ['username' => 'other@example.com'];
$event = new JWTCreatedEvent($initialPayload, $nonSecurityUser);
$this->enricher->onJWTCreated($event);
$payload = $event->getData();
// Payload should remain unchanged
self::assertSame(['username' => 'other@example.com'], $payload);
}
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Security;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\StatutCompte;
use App\Administration\Domain\Model\User\User;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Infrastructure\Security\SecurityUserFactory;
use App\Shared\Infrastructure\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class SecurityUserTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private SecurityUserFactory $factory;
protected function setUp(): void
{
$this->factory = new SecurityUserFactory();
}
#[Test]
public function factoryCreatesSecurityUserWithCorrectData(): void
{
$domainUser = $this->createActivatedUser(Role::PARENT);
$securityUser = $this->factory->fromDomainUser($domainUser);
self::assertSame((string) $domainUser->email, $securityUser->getUserIdentifier());
self::assertSame((string) $domainUser->id, $securityUser->userId());
self::assertSame((string) $domainUser->email, $securityUser->email());
self::assertSame($domainUser->hashedPassword, $securityUser->getPassword());
self::assertSame((string) $domainUser->tenantId, $securityUser->tenantId());
}
#[Test]
#[DataProvider('roleProvider')]
public function factoryMapsRolesToSymfonyRoles(Role $domainRole, string $expectedSymfonyRole): void
{
$domainUser = $this->createActivatedUser($domainRole);
$securityUser = $this->factory->fromDomainUser($domainUser);
self::assertContains($expectedSymfonyRole, $securityUser->getRoles());
}
/**
* @return iterable<string, array{Role, string}>
*/
public static function roleProvider(): iterable
{
yield 'Super Admin' => [Role::SUPER_ADMIN, 'ROLE_SUPER_ADMIN'];
yield 'Admin' => [Role::ADMIN, 'ROLE_ADMIN'];
yield 'Prof' => [Role::PROF, 'ROLE_PROF'];
yield 'Vie scolaire' => [Role::VIE_SCOLAIRE, 'ROLE_VIE_SCOLAIRE'];
yield 'Secrétariat' => [Role::SECRETARIAT, 'ROLE_SECRETARIAT'];
yield 'Parent' => [Role::PARENT, 'ROLE_PARENT'];
yield 'Elève' => [Role::ELEVE, 'ROLE_ELEVE'];
}
#[Test]
public function eraseCredentialsDoesNothing(): void
{
$domainUser = $this->createActivatedUser(Role::PARENT);
$securityUser = $this->factory->fromDomainUser($domainUser);
$passwordBefore = $securityUser->getPassword();
$securityUser->eraseCredentials();
// Les credentials sont immutables, donc rien ne change
self::assertSame($passwordBefore, $securityUser->getPassword());
}
private function createActivatedUser(Role $role): User
{
return User::reconstitute(
id: UserId::generate(),
email: new Email('user@example.com'),
role: $role,
tenantId: TenantId::fromString(self::TENANT_ID),
schoolName: 'École Test',
statut: StatutCompte::ACTIF,
dateNaissance: null,
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
hashedPassword: '$argon2id$v=19$m=65536,t=4,p=1$salt$hash',
activatedAt: new DateTimeImmutable('2026-01-15 12:00:00'),
consentementParental: null,
);
}
}

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