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:
@@ -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'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user