Files
Classeo/backend/tests/Unit/Administration/Domain/Model/RefreshToken/RefreshTokenTest.php
Mathias STRASSER b9d9f48305 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
2026-02-01 14:43:12 +01:00

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'),
);
}
}