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:
@@ -10,7 +10,7 @@ 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\Infrastructure\Tenant\TenantId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
<?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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ use App\Administration\Domain\Model\User\StatutCompte;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Domain\Policy\ConsentementParentalPolicy;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
Reference in New Issue
Block a user