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
183 lines
6.0 KiB
PHP
183 lines
6.0 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Unit\Administration\Application\Command\ActivateAccount;
|
|
|
|
use App\Administration\Application\Command\ActivateAccount\ActivateAccountCommand;
|
|
use App\Administration\Application\Command\ActivateAccount\ActivateAccountHandler;
|
|
use App\Administration\Application\Command\ActivateAccount\ActivateAccountResult;
|
|
use App\Administration\Application\Port\PasswordHasher;
|
|
use App\Administration\Domain\Exception\ActivationTokenAlreadyUsedException;
|
|
use App\Administration\Domain\Exception\ActivationTokenExpiredException;
|
|
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\Domain\Tenant\TenantId;
|
|
use DateTimeImmutable;
|
|
use Override;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
final class ActivateAccountHandlerTest extends TestCase
|
|
{
|
|
private const string USER_ID = '550e8400-e29b-41d4-a716-446655440001';
|
|
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
|
private const string EMAIL = 'user@example.com';
|
|
private const string ROLE = 'ROLE_PARENT';
|
|
private const string SCHOOL_NAME = 'École Alpha';
|
|
private const string PASSWORD = 'SecurePass123';
|
|
private const string HASHED_PASSWORD = '$argon2id$hashed_password';
|
|
|
|
private InMemoryActivationTokenRepository $tokenRepository;
|
|
private PasswordHasher $passwordHasher;
|
|
private Clock $clock;
|
|
private ActivateAccountHandler $handler;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->tokenRepository = new InMemoryActivationTokenRepository();
|
|
$this->passwordHasher = new class implements PasswordHasher {
|
|
#[Override]
|
|
public function hash(string $plainPassword): string
|
|
{
|
|
return '$argon2id$hashed_password';
|
|
}
|
|
|
|
#[Override]
|
|
public function verify(string $hashedPassword, string $plainPassword): bool
|
|
{
|
|
return true;
|
|
}
|
|
};
|
|
$this->clock = new class implements Clock {
|
|
public DateTimeImmutable $now;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->now = new DateTimeImmutable('2026-01-16 10:00:00');
|
|
}
|
|
|
|
#[Override]
|
|
public function now(): DateTimeImmutable
|
|
{
|
|
return $this->now;
|
|
}
|
|
};
|
|
|
|
$this->handler = new ActivateAccountHandler(
|
|
$this->tokenRepository,
|
|
$this->passwordHasher,
|
|
$this->clock,
|
|
);
|
|
}
|
|
|
|
#[Test]
|
|
public function activateAccountSuccessfully(): void
|
|
{
|
|
$token = $this->createAndSaveToken();
|
|
|
|
$command = new ActivateAccountCommand(
|
|
tokenValue: $token->tokenValue,
|
|
password: self::PASSWORD,
|
|
);
|
|
|
|
$result = ($this->handler)($command);
|
|
|
|
self::assertInstanceOf(ActivateAccountResult::class, $result);
|
|
self::assertSame(self::USER_ID, $result->userId);
|
|
self::assertSame(self::EMAIL, $result->email);
|
|
self::assertSame(self::ROLE, $result->role);
|
|
self::assertSame(self::HASHED_PASSWORD, $result->hashedPassword);
|
|
}
|
|
|
|
#[Test]
|
|
public function activateAccountValidatesButDoesNotConsumeToken(): void
|
|
{
|
|
// Handler only validates the token - consumption is deferred to the processor
|
|
// after successful user activation, so failed activations don't burn the token
|
|
$token = $this->createAndSaveToken();
|
|
$tokenValue = $token->tokenValue;
|
|
|
|
$command = new ActivateAccountCommand(
|
|
tokenValue: $tokenValue,
|
|
password: self::PASSWORD,
|
|
);
|
|
|
|
($this->handler)($command);
|
|
|
|
// Token should still exist and NOT be marked as used
|
|
$updatedToken = $this->tokenRepository->findByTokenValue($tokenValue);
|
|
self::assertNotNull($updatedToken);
|
|
self::assertFalse($updatedToken->isUsed());
|
|
}
|
|
|
|
#[Test]
|
|
public function activateAccountThrowsWhenTokenNotFound(): void
|
|
{
|
|
$command = new ActivateAccountCommand(
|
|
tokenValue: 'non-existent-token',
|
|
password: self::PASSWORD,
|
|
);
|
|
|
|
$this->expectException(ActivationTokenNotFoundException::class);
|
|
|
|
($this->handler)($command);
|
|
}
|
|
|
|
#[Test]
|
|
public function activateAccountThrowsWhenTokenExpired(): void
|
|
{
|
|
$token = $this->createAndSaveToken(
|
|
createdAt: new DateTimeImmutable('2026-01-01 10:00:00'),
|
|
);
|
|
|
|
// Clock is set to 2026-01-16, token expires 2026-01-08
|
|
$command = new ActivateAccountCommand(
|
|
tokenValue: $token->tokenValue,
|
|
password: self::PASSWORD,
|
|
);
|
|
|
|
$this->expectException(ActivationTokenExpiredException::class);
|
|
|
|
($this->handler)($command);
|
|
}
|
|
|
|
#[Test]
|
|
public function activateAccountThrowsWhenTokenAlreadyUsed(): void
|
|
{
|
|
$token = $this->createAndSaveToken();
|
|
|
|
// Simulate a token that was already used (e.g., by the processor after successful activation)
|
|
$token->use($this->clock->now());
|
|
$this->tokenRepository->save($token);
|
|
|
|
$command = new ActivateAccountCommand(
|
|
tokenValue: $token->tokenValue,
|
|
password: self::PASSWORD,
|
|
);
|
|
|
|
// Should fail because token is already used
|
|
$this->expectException(ActivationTokenAlreadyUsedException::class);
|
|
|
|
($this->handler)($command);
|
|
}
|
|
|
|
private function createAndSaveToken(?DateTimeImmutable $createdAt = null): ActivationToken
|
|
{
|
|
$token = ActivationToken::generate(
|
|
userId: self::USER_ID,
|
|
email: self::EMAIL,
|
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
|
role: self::ROLE,
|
|
schoolName: self::SCHOOL_NAME,
|
|
createdAt: $createdAt ?? new DateTimeImmutable('2026-01-15 10:00:00'),
|
|
);
|
|
|
|
$this->tokenRepository->save($token);
|
|
|
|
return $token;
|
|
}
|
|
}
|