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
176 lines
6.2 KiB
PHP
176 lines
6.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Unit\Administration\Domain\Model\RefreshToken;
|
|
|
|
use App\Administration\Domain\Model\RefreshToken\DeviceFingerprint;
|
|
use App\Administration\Domain\Model\RefreshToken\RefreshToken;
|
|
use App\Administration\Domain\Model\User\UserId;
|
|
use App\Shared\Domain\Tenant\TenantId;
|
|
use DateTimeImmutable;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
final class RefreshTokenTest extends TestCase
|
|
{
|
|
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
|
|
|
#[Test]
|
|
public function createGeneratesTokenWithCorrectData(): void
|
|
{
|
|
$userId = UserId::generate();
|
|
$tenantId = TenantId::fromString(self::TENANT_ID);
|
|
$fingerprint = DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1');
|
|
$issuedAt = new DateTimeImmutable('2026-01-31 10:00:00');
|
|
|
|
$token = RefreshToken::create($userId, $tenantId, $fingerprint, $issuedAt);
|
|
|
|
self::assertSame($userId, $token->userId);
|
|
self::assertSame($tenantId, $token->tenantId);
|
|
self::assertTrue($token->deviceFingerprint->equals($fingerprint));
|
|
self::assertEquals($issuedAt, $token->issuedAt);
|
|
self::assertNull($token->rotatedFrom);
|
|
self::assertFalse($token->isRotated);
|
|
}
|
|
|
|
#[Test]
|
|
public function createSetsExpirationBasedOnTtl(): void
|
|
{
|
|
$issuedAt = new DateTimeImmutable('2026-01-31 10:00:00');
|
|
$ttl = 86400; // 1 day
|
|
|
|
$token = RefreshToken::create(
|
|
UserId::generate(),
|
|
TenantId::fromString(self::TENANT_ID),
|
|
DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1'),
|
|
$issuedAt,
|
|
$ttl,
|
|
);
|
|
|
|
$expectedExpiry = $issuedAt->modify('+86400 seconds');
|
|
self::assertEquals($expectedExpiry, $token->expiresAt);
|
|
}
|
|
|
|
#[Test]
|
|
public function rotateCreatesNewTokenWithSameFamily(): void
|
|
{
|
|
$token = $this->createToken();
|
|
$rotateAt = new DateTimeImmutable('2026-01-31 11:00:00');
|
|
|
|
[$newToken, $oldToken] = $token->rotate($rotateAt);
|
|
|
|
// Nouveau token
|
|
self::assertNotSame($token->id, $newToken->id);
|
|
self::assertSame($token->familyId, $newToken->familyId);
|
|
self::assertSame($token->userId, $newToken->userId);
|
|
self::assertSame($token->id, $newToken->rotatedFrom);
|
|
self::assertFalse($newToken->isRotated);
|
|
|
|
// Ancien token marqué comme rotaté
|
|
self::assertSame($token->id, $oldToken->id);
|
|
self::assertTrue($oldToken->isRotated);
|
|
}
|
|
|
|
#[Test]
|
|
public function isExpiredReturnsTrueWhenPastExpiration(): void
|
|
{
|
|
$issuedAt = new DateTimeImmutable('2026-01-31 10:00:00');
|
|
$token = RefreshToken::create(
|
|
UserId::generate(),
|
|
TenantId::fromString(self::TENANT_ID),
|
|
DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1'),
|
|
$issuedAt,
|
|
3600, // 1 hour
|
|
);
|
|
|
|
self::assertFalse($token->isExpired(new DateTimeImmutable('2026-01-31 10:30:00')));
|
|
self::assertTrue($token->isExpired(new DateTimeImmutable('2026-01-31 11:30:00')));
|
|
}
|
|
|
|
#[Test]
|
|
public function isInGracePeriodReturnsTrueWithin30SecondsOfRotation(): void
|
|
{
|
|
$token = $this->createToken();
|
|
$rotateAt = new DateTimeImmutable('2026-01-31 11:00:00');
|
|
|
|
[$_, $oldToken] = $token->rotate($rotateAt);
|
|
|
|
// Dans la grace period (30s après rotation)
|
|
self::assertTrue($oldToken->isInGracePeriod(new DateTimeImmutable('2026-01-31 11:00:15')));
|
|
self::assertTrue($oldToken->isInGracePeriod(new DateTimeImmutable('2026-01-31 11:00:30')));
|
|
|
|
// Après la grace period
|
|
self::assertFalse($oldToken->isInGracePeriod(new DateTimeImmutable('2026-01-31 11:00:31')));
|
|
self::assertFalse($oldToken->isInGracePeriod(new DateTimeImmutable('2026-01-31 11:01:00')));
|
|
}
|
|
|
|
#[Test]
|
|
public function rotatePreservesOriginalTtl(): void
|
|
{
|
|
$issuedAt = new DateTimeImmutable('2026-01-31 10:00:00');
|
|
$originalTtl = 86400; // 1 day (web session)
|
|
|
|
$token = RefreshToken::create(
|
|
UserId::generate(),
|
|
TenantId::fromString(self::TENANT_ID),
|
|
DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1'),
|
|
$issuedAt,
|
|
$originalTtl,
|
|
);
|
|
|
|
$rotateAt = new DateTimeImmutable('2026-01-31 14:00:00');
|
|
[$newToken, $oldToken] = $token->rotate($rotateAt);
|
|
|
|
// Le nouveau token doit avoir le même TTL que l'original
|
|
$expectedExpiry = $rotateAt->modify("+{$originalTtl} seconds");
|
|
self::assertEquals($expectedExpiry, $newToken->expiresAt);
|
|
|
|
// L'ancien token garde son expiration originale
|
|
self::assertEquals($issuedAt->modify("+{$originalTtl} seconds"), $oldToken->expiresAt);
|
|
|
|
// L'ancien token a rotatedAt défini
|
|
self::assertEquals($rotateAt, $oldToken->rotatedAt);
|
|
self::assertNull($newToken->rotatedAt);
|
|
}
|
|
|
|
#[Test]
|
|
public function matchesDeviceReturnsTrueForSameFingerprint(): void
|
|
{
|
|
$fingerprint = DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1');
|
|
$token = RefreshToken::create(
|
|
UserId::generate(),
|
|
TenantId::fromString(self::TENANT_ID),
|
|
$fingerprint,
|
|
new DateTimeImmutable(),
|
|
);
|
|
|
|
$sameFingerprint = DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1');
|
|
$differentFingerprint = DeviceFingerprint::fromRequest('Chrome/110', '10.0.0.1');
|
|
|
|
self::assertTrue($token->matchesDevice($sameFingerprint));
|
|
self::assertFalse($token->matchesDevice($differentFingerprint));
|
|
}
|
|
|
|
#[Test]
|
|
public function toTokenStringAndExtractIdRoundTrips(): void
|
|
{
|
|
$token = $this->createToken();
|
|
|
|
$tokenString = $token->toTokenString();
|
|
$extractedId = RefreshToken::extractIdFromTokenString($tokenString);
|
|
|
|
self::assertEquals($token->id, $extractedId);
|
|
}
|
|
|
|
private function createToken(): RefreshToken
|
|
{
|
|
return RefreshToken::create(
|
|
UserId::generate(),
|
|
TenantId::fromString(self::TENANT_ID),
|
|
DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1'),
|
|
new DateTimeImmutable('2026-01-31 10:00:00'),
|
|
);
|
|
}
|
|
}
|