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