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
222 lines
7.6 KiB
PHP
222 lines
7.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Unit\Administration\Application\Service;
|
|
|
|
use App\Administration\Application\Service\RefreshTokenManager;
|
|
use App\Administration\Domain\Exception\TokenAlreadyRotatedException;
|
|
use App\Administration\Domain\Exception\TokenReplayDetectedException;
|
|
use App\Administration\Domain\Model\RefreshToken\DeviceFingerprint;
|
|
use App\Administration\Domain\Model\RefreshToken\RefreshToken;
|
|
use App\Administration\Domain\Model\RefreshToken\RefreshTokenId;
|
|
use App\Administration\Domain\Model\RefreshToken\TokenFamilyId;
|
|
use App\Administration\Domain\Model\User\UserId;
|
|
use App\Administration\Domain\Repository\RefreshTokenRepository;
|
|
use App\Shared\Domain\Clock;
|
|
use App\Shared\Domain\Tenant\TenantId;
|
|
use DateTimeImmutable;
|
|
use InvalidArgumentException;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
final class RefreshTokenManagerTest extends TestCase
|
|
{
|
|
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
|
|
|
private RefreshTokenRepository $repository;
|
|
private Clock $clock;
|
|
private RefreshTokenManager $manager;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->repository = $this->createMock(RefreshTokenRepository::class);
|
|
$this->clock = new class implements Clock {
|
|
public DateTimeImmutable $now;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->now = new DateTimeImmutable('2026-01-31 10:00:00');
|
|
}
|
|
|
|
public function now(): DateTimeImmutable
|
|
{
|
|
return $this->now;
|
|
}
|
|
};
|
|
|
|
$this->manager = new RefreshTokenManager($this->repository, $this->clock);
|
|
}
|
|
|
|
#[Test]
|
|
public function createGeneratesAndSavesNewToken(): void
|
|
{
|
|
$userId = UserId::generate();
|
|
$tenantId = TenantId::fromString(self::TENANT_ID);
|
|
$fingerprint = DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1');
|
|
|
|
$this->repository->expects(self::once())
|
|
->method('save')
|
|
->with(self::isInstanceOf(RefreshToken::class));
|
|
|
|
$token = $this->manager->create($userId, $tenantId, $fingerprint);
|
|
|
|
self::assertSame($userId, $token->userId);
|
|
self::assertSame($tenantId, $token->tenantId);
|
|
}
|
|
|
|
#[Test]
|
|
public function refreshThrowsForTokenNotFound(): void
|
|
{
|
|
$this->repository->method('find')->willReturn(null);
|
|
|
|
$this->expectException(InvalidArgumentException::class);
|
|
$this->expectExceptionMessage('Token not found');
|
|
|
|
// Use a valid UUID format for the token ID
|
|
$validUuid = '550e8400-e29b-41d4-a716-446655440099';
|
|
$this->manager->refresh(
|
|
base64_encode($validUuid),
|
|
DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1'),
|
|
);
|
|
}
|
|
|
|
#[Test]
|
|
public function refreshRotatesTokenAndReturnsNew(): void
|
|
{
|
|
$existingToken = $this->createExistingToken(isRotated: false);
|
|
$tokenString = $existingToken->toTokenString();
|
|
$fingerprint = $existingToken->deviceFingerprint;
|
|
|
|
$this->repository->method('find')
|
|
->willReturn($existingToken);
|
|
|
|
$this->repository->expects(self::exactly(2))
|
|
->method('save');
|
|
|
|
$newToken = $this->manager->refresh($tokenString, $fingerprint);
|
|
|
|
self::assertNotEquals($existingToken->id, $newToken->id);
|
|
self::assertEquals($existingToken->familyId, $newToken->familyId);
|
|
}
|
|
|
|
#[Test]
|
|
public function refreshThrowsForExpiredToken(): void
|
|
{
|
|
$expiredToken = RefreshToken::create(
|
|
UserId::generate(),
|
|
TenantId::fromString(self::TENANT_ID),
|
|
DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1'),
|
|
new DateTimeImmutable('2026-01-01 10:00:00'), // Issued long ago
|
|
3600, // 1 hour TTL - expired
|
|
);
|
|
|
|
$this->repository->method('find')->willReturn($expiredToken);
|
|
$this->repository->expects(self::once())->method('delete');
|
|
|
|
$this->expectException(InvalidArgumentException::class);
|
|
$this->expectExceptionMessage('expired');
|
|
|
|
$this->manager->refresh(
|
|
$expiredToken->toTokenString(),
|
|
$expiredToken->deviceFingerprint,
|
|
);
|
|
}
|
|
|
|
#[Test]
|
|
public function refreshThrowsAndInvalidatesFamilyForWrongDevice(): void
|
|
{
|
|
$existingToken = $this->createExistingToken();
|
|
$differentFingerprint = DeviceFingerprint::fromRequest('Chrome/110', '10.0.0.1');
|
|
|
|
$this->repository->method('find')->willReturn($existingToken);
|
|
$this->repository->expects(self::once())
|
|
->method('invalidateFamily')
|
|
->with($existingToken->familyId);
|
|
|
|
$this->expectException(TokenReplayDetectedException::class);
|
|
|
|
$this->manager->refresh($existingToken->toTokenString(), $differentFingerprint);
|
|
}
|
|
|
|
#[Test]
|
|
public function refreshThrowsAndInvalidatesFamilyForReplayAttack(): void
|
|
{
|
|
// Token rotaté il y a plus de 30 secondes (hors grace period)
|
|
$rotatedToken = $this->createExistingToken(
|
|
isRotated: true,
|
|
issuedAt: new DateTimeImmutable('2026-01-31 09:00:00'),
|
|
rotatedAt: new DateTimeImmutable('2026-01-31 09:30:00'), // Rotaté 30 min avant "now"
|
|
);
|
|
|
|
$this->repository->method('find')->willReturn($rotatedToken);
|
|
$this->repository->expects(self::once())
|
|
->method('invalidateFamily')
|
|
->with($rotatedToken->familyId);
|
|
|
|
$this->expectException(TokenReplayDetectedException::class);
|
|
|
|
$this->manager->refresh(
|
|
$rotatedToken->toTokenString(),
|
|
$rotatedToken->deviceFingerprint,
|
|
);
|
|
}
|
|
|
|
#[Test]
|
|
public function refreshThrowsTokenAlreadyRotatedForGracePeriod(): void
|
|
{
|
|
// Token rotaté il y a 10 secondes (dans la grace period de 30s)
|
|
$rotatedToken = $this->createExistingToken(
|
|
isRotated: true,
|
|
issuedAt: new DateTimeImmutable('2026-01-31 09:00:00'),
|
|
rotatedAt: new DateTimeImmutable('2026-01-31 09:59:50'), // Rotaté 10s avant "now" (10:00:00)
|
|
);
|
|
|
|
$this->repository->method('find')->willReturn($rotatedToken);
|
|
|
|
// Ne doit PAS invalider la famille
|
|
$this->repository->expects(self::never())->method('invalidateFamily');
|
|
|
|
$this->expectException(TokenAlreadyRotatedException::class);
|
|
|
|
$this->manager->refresh(
|
|
$rotatedToken->toTokenString(),
|
|
$rotatedToken->deviceFingerprint,
|
|
);
|
|
}
|
|
|
|
#[Test]
|
|
public function revokeInvalidatesTokenFamily(): void
|
|
{
|
|
$existingToken = $this->createExistingToken();
|
|
|
|
$this->repository->method('find')->willReturn($existingToken);
|
|
$this->repository->expects(self::once())
|
|
->method('invalidateFamily')
|
|
->with($existingToken->familyId);
|
|
|
|
$this->manager->revoke($existingToken->toTokenString());
|
|
}
|
|
|
|
private function createExistingToken(
|
|
bool $isRotated = false,
|
|
?DateTimeImmutable $issuedAt = null,
|
|
?DateTimeImmutable $rotatedAt = null,
|
|
): RefreshToken {
|
|
$issuedAt ??= new DateTimeImmutable('2026-01-31 09:00:00');
|
|
|
|
return RefreshToken::reconstitute(
|
|
id: RefreshTokenId::generate(),
|
|
familyId: TokenFamilyId::generate(),
|
|
userId: UserId::generate(),
|
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
|
deviceFingerprint: DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1'),
|
|
issuedAt: $issuedAt,
|
|
expiresAt: $issuedAt->modify('+7 days'),
|
|
rotatedFrom: null,
|
|
isRotated: $isRotated,
|
|
rotatedAt: $rotatedAt,
|
|
);
|
|
}
|
|
}
|