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

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