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
220 lines
6.9 KiB
PHP
220 lines
6.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Unit\Administration\Domain\Model\ActivationToken;
|
|
|
|
use App\Administration\Domain\Event\ActivationTokenGenerated;
|
|
use App\Administration\Domain\Event\ActivationTokenUsed;
|
|
use App\Administration\Domain\Exception\ActivationTokenAlreadyUsedException;
|
|
use App\Administration\Domain\Exception\ActivationTokenExpiredException;
|
|
use App\Administration\Domain\Model\ActivationToken\ActivationToken;
|
|
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
|
|
use App\Shared\Domain\Tenant\TenantId;
|
|
use DateTimeImmutable;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
final class ActivationTokenTest 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';
|
|
|
|
#[Test]
|
|
public function generateCreatesTokenWithCorrectProperties(): void
|
|
{
|
|
$userId = self::USER_ID;
|
|
$email = self::EMAIL;
|
|
$tenantId = TenantId::fromString(self::TENANT_ID);
|
|
$role = self::ROLE;
|
|
$schoolName = self::SCHOOL_NAME;
|
|
$now = new DateTimeImmutable('2026-01-15 10:00:00');
|
|
|
|
$token = ActivationToken::generate(
|
|
userId: $userId,
|
|
email: $email,
|
|
tenantId: $tenantId,
|
|
role: $role,
|
|
schoolName: $schoolName,
|
|
createdAt: $now,
|
|
);
|
|
|
|
self::assertInstanceOf(ActivationTokenId::class, $token->id);
|
|
self::assertSame($userId, $token->userId);
|
|
self::assertSame($email, $token->email);
|
|
self::assertTrue($tenantId->equals($token->tenantId));
|
|
self::assertSame($role, $token->role);
|
|
self::assertSame($schoolName, $token->schoolName);
|
|
self::assertEquals($now, $token->createdAt);
|
|
self::assertFalse($token->isUsed());
|
|
}
|
|
|
|
#[Test]
|
|
public function generateRecordsActivationTokenGeneratedEvent(): void
|
|
{
|
|
$token = $this->createToken();
|
|
|
|
$events = $token->pullDomainEvents();
|
|
|
|
self::assertCount(1, $events);
|
|
self::assertInstanceOf(ActivationTokenGenerated::class, $events[0]);
|
|
}
|
|
|
|
#[Test]
|
|
public function tokenValueIsUuidV4Format(): void
|
|
{
|
|
$token = $this->createToken();
|
|
|
|
self::assertMatchesRegularExpression(
|
|
'/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i',
|
|
$token->tokenValue,
|
|
);
|
|
}
|
|
|
|
#[Test]
|
|
public function expiresAtIs7DaysAfterCreation(): void
|
|
{
|
|
$createdAt = new DateTimeImmutable('2026-01-15 10:00:00');
|
|
$expectedExpiration = new DateTimeImmutable('2026-01-22 10:00:00');
|
|
|
|
$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,
|
|
);
|
|
|
|
self::assertEquals($expectedExpiration, $token->expiresAt);
|
|
}
|
|
|
|
#[Test]
|
|
public function isExpiredReturnsFalseWhenNotExpired(): void
|
|
{
|
|
$createdAt = new DateTimeImmutable('2026-01-15 10:00:00');
|
|
$checkAt = new DateTimeImmutable('2026-01-20 10:00:00');
|
|
|
|
$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,
|
|
);
|
|
|
|
self::assertFalse($token->isExpired($checkAt));
|
|
}
|
|
|
|
#[Test]
|
|
public function isExpiredReturnsTrueWhenExpired(): void
|
|
{
|
|
$createdAt = new DateTimeImmutable('2026-01-15 10:00:00');
|
|
$checkAt = new DateTimeImmutable('2026-01-25 10:00:00');
|
|
|
|
$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,
|
|
);
|
|
|
|
self::assertTrue($token->isExpired($checkAt));
|
|
}
|
|
|
|
#[Test]
|
|
public function isExpiredReturnsTrueAtExactExpirationMoment(): void
|
|
{
|
|
$createdAt = new DateTimeImmutable('2026-01-15 10:00:00');
|
|
$checkAt = new DateTimeImmutable('2026-01-22 10:00:00');
|
|
|
|
$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,
|
|
);
|
|
|
|
self::assertTrue($token->isExpired($checkAt));
|
|
}
|
|
|
|
#[Test]
|
|
public function useMarksTokenAsUsed(): void
|
|
{
|
|
$token = $this->createToken();
|
|
$usedAt = new DateTimeImmutable('2026-01-16 10:00:00');
|
|
|
|
$token->use($usedAt);
|
|
|
|
self::assertTrue($token->isUsed());
|
|
self::assertEquals($usedAt, $token->usedAt);
|
|
}
|
|
|
|
#[Test]
|
|
public function useRecordsActivationTokenUsedEvent(): void
|
|
{
|
|
$token = $this->createToken();
|
|
$token->pullDomainEvents();
|
|
|
|
$usedAt = new DateTimeImmutable('2026-01-16 10:00:00');
|
|
$token->use($usedAt);
|
|
|
|
$events = $token->pullDomainEvents();
|
|
self::assertCount(1, $events);
|
|
self::assertInstanceOf(ActivationTokenUsed::class, $events[0]);
|
|
}
|
|
|
|
#[Test]
|
|
public function useThrowsExceptionWhenTokenAlreadyUsed(): void
|
|
{
|
|
$token = $this->createToken();
|
|
$firstUse = new DateTimeImmutable('2026-01-16 10:00:00');
|
|
$token->use($firstUse);
|
|
|
|
$this->expectException(ActivationTokenAlreadyUsedException::class);
|
|
|
|
$secondUse = new DateTimeImmutable('2026-01-17 10:00:00');
|
|
$token->use($secondUse);
|
|
}
|
|
|
|
#[Test]
|
|
public function useThrowsExceptionWhenTokenExpired(): void
|
|
{
|
|
$createdAt = new DateTimeImmutable('2026-01-15 10:00:00');
|
|
$usedAt = new DateTimeImmutable('2026-01-25 10:00:00');
|
|
|
|
$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,
|
|
);
|
|
|
|
$this->expectException(ActivationTokenExpiredException::class);
|
|
|
|
$token->use($usedAt);
|
|
}
|
|
|
|
private function createToken(): ActivationToken
|
|
{
|
|
return ActivationToken::generate(
|
|
userId: self::USER_ID,
|
|
email: self::EMAIL,
|
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
|
role: self::ROLE,
|
|
schoolName: self::SCHOOL_NAME,
|
|
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
|
|
);
|
|
}
|
|
}
|