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

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