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
This commit is contained in:
2026-02-01 10:25:25 +01:00
parent 6889c67a44
commit b9d9f48305
93 changed files with 6850 additions and 155 deletions

View File

@@ -0,0 +1,221 @@
<?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,
);
}
}