Permet aux utilisateurs de visualiser et gérer leurs sessions actives sur différents appareils, avec la possibilité de révoquer des sessions à distance en cas de suspicion d'activité non autorisée. Fonctionnalités : - Liste des sessions actives avec métadonnées (appareil, navigateur, localisation) - Identification de la session courante - Révocation individuelle d'une session - Révocation de toutes les autres sessions - Déconnexion avec nettoyage des cookies sur les deux chemins (legacy et actuel) Sécurité : - Cache frontend scopé par utilisateur pour éviter les fuites entre comptes - Validation que le refresh token appartient à l'utilisateur JWT authentifié - TTL des sessions Redis aligné sur l'expiration du refresh token - Événements d'audit pour traçabilité (SessionInvalidee, ToutesSessionsInvalidees) @see Story 1.6 - Gestion des sessions
242 lines
8.1 KiB
PHP
242 lines
8.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Unit\Administration\Infrastructure\Persistence\Redis;
|
|
|
|
use App\Administration\Domain\Model\RefreshToken\TokenFamilyId;
|
|
use App\Administration\Domain\Model\Session\DeviceInfo;
|
|
use App\Administration\Domain\Model\Session\Location;
|
|
use App\Administration\Domain\Model\Session\Session;
|
|
use App\Administration\Domain\Model\User\UserId;
|
|
use App\Administration\Infrastructure\Persistence\Redis\RedisSessionRepository;
|
|
use App\Shared\Domain\Tenant\TenantId;
|
|
use DateTimeImmutable;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use PHPUnit\Framework\TestCase;
|
|
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
|
|
|
final class RedisSessionRepositoryTest extends TestCase
|
|
{
|
|
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
|
|
|
private ArrayAdapter $cache;
|
|
private RedisSessionRepository $repository;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->cache = new ArrayAdapter();
|
|
$this->repository = new RedisSessionRepository($this->cache);
|
|
}
|
|
|
|
#[Test]
|
|
public function savePersistsSession(): void
|
|
{
|
|
$session = $this->createSession();
|
|
|
|
$this->repository->save($session, 86400);
|
|
|
|
$retrieved = $this->repository->findByFamilyId($session->familyId);
|
|
self::assertNotNull($retrieved);
|
|
self::assertTrue($session->familyId->equals($retrieved->familyId));
|
|
}
|
|
|
|
#[Test]
|
|
public function findByFamilyIdReturnsNullWhenNotFound(): void
|
|
{
|
|
$nonExistentId = TokenFamilyId::generate();
|
|
|
|
$result = $this->repository->findByFamilyId($nonExistentId);
|
|
|
|
self::assertNull($result);
|
|
}
|
|
|
|
#[Test]
|
|
public function findAllByUserIdReturnsAllUserSessions(): void
|
|
{
|
|
$userId = UserId::generate();
|
|
$tenantId = TenantId::fromString(self::TENANT_ID);
|
|
|
|
$session1 = Session::create(
|
|
TokenFamilyId::generate(),
|
|
$userId,
|
|
$tenantId,
|
|
DeviceInfo::fromUserAgent('Chrome'),
|
|
Location::unknown(),
|
|
new DateTimeImmutable(),
|
|
);
|
|
$session2 = Session::create(
|
|
TokenFamilyId::generate(),
|
|
$userId,
|
|
$tenantId,
|
|
DeviceInfo::fromUserAgent('Firefox'),
|
|
Location::unknown(),
|
|
new DateTimeImmutable(),
|
|
);
|
|
$otherUserSession = Session::create(
|
|
TokenFamilyId::generate(),
|
|
UserId::generate(),
|
|
$tenantId,
|
|
DeviceInfo::fromUserAgent('Safari'),
|
|
Location::unknown(),
|
|
new DateTimeImmutable(),
|
|
);
|
|
|
|
$this->repository->save($session1, 86400);
|
|
$this->repository->save($session2, 86400);
|
|
$this->repository->save($otherUserSession, 86400);
|
|
|
|
$sessions = $this->repository->findAllByUserId($userId);
|
|
|
|
self::assertCount(2, $sessions);
|
|
|
|
$familyIds = array_map(
|
|
static fn (Session $s) => (string) $s->familyId,
|
|
$sessions,
|
|
);
|
|
self::assertContains((string) $session1->familyId, $familyIds);
|
|
self::assertContains((string) $session2->familyId, $familyIds);
|
|
}
|
|
|
|
#[Test]
|
|
public function deleteRemovesSession(): void
|
|
{
|
|
$session = $this->createSession();
|
|
$this->repository->save($session, 86400);
|
|
|
|
$this->repository->delete($session->familyId);
|
|
|
|
self::assertNull($this->repository->findByFamilyId($session->familyId));
|
|
}
|
|
|
|
#[Test]
|
|
public function deleteRemovesSessionFromUserIndex(): void
|
|
{
|
|
$userId = UserId::generate();
|
|
$session = Session::create(
|
|
TokenFamilyId::generate(),
|
|
$userId,
|
|
TenantId::fromString(self::TENANT_ID),
|
|
DeviceInfo::fromUserAgent('Chrome'),
|
|
Location::unknown(),
|
|
new DateTimeImmutable(),
|
|
);
|
|
|
|
$this->repository->save($session, 86400);
|
|
$this->repository->delete($session->familyId);
|
|
|
|
$sessions = $this->repository->findAllByUserId($userId);
|
|
self::assertCount(0, $sessions);
|
|
}
|
|
|
|
#[Test]
|
|
public function deleteAllExceptRemovesOtherSessions(): void
|
|
{
|
|
$userId = UserId::generate();
|
|
$tenantId = TenantId::fromString(self::TENANT_ID);
|
|
$currentFamilyId = TokenFamilyId::generate();
|
|
|
|
$currentSession = Session::create(
|
|
$currentFamilyId,
|
|
$userId,
|
|
$tenantId,
|
|
DeviceInfo::fromUserAgent('Chrome'),
|
|
Location::unknown(),
|
|
new DateTimeImmutable(),
|
|
);
|
|
$otherSession1 = Session::create(
|
|
TokenFamilyId::generate(),
|
|
$userId,
|
|
$tenantId,
|
|
DeviceInfo::fromUserAgent('Firefox'),
|
|
Location::unknown(),
|
|
new DateTimeImmutable(),
|
|
);
|
|
$otherSession2 = Session::create(
|
|
TokenFamilyId::generate(),
|
|
$userId,
|
|
$tenantId,
|
|
DeviceInfo::fromUserAgent('Safari'),
|
|
Location::unknown(),
|
|
new DateTimeImmutable(),
|
|
);
|
|
|
|
$this->repository->save($currentSession, 86400);
|
|
$this->repository->save($otherSession1, 86400);
|
|
$this->repository->save($otherSession2, 86400);
|
|
|
|
$deletedFamilyIds = $this->repository->deleteAllExcept($userId, $currentFamilyId);
|
|
|
|
self::assertCount(2, $deletedFamilyIds);
|
|
self::assertContains((string) $otherSession1->familyId, array_map(static fn ($id) => (string) $id, $deletedFamilyIds));
|
|
self::assertContains((string) $otherSession2->familyId, array_map(static fn ($id) => (string) $id, $deletedFamilyIds));
|
|
|
|
$sessions = $this->repository->findAllByUserId($userId);
|
|
self::assertCount(1, $sessions);
|
|
self::assertTrue($sessions[0]->familyId->equals($currentFamilyId));
|
|
}
|
|
|
|
#[Test]
|
|
public function updateActivityUpdatesLastActivityTimestamp(): void
|
|
{
|
|
$session = $this->createSession();
|
|
$this->repository->save($session, 86400);
|
|
|
|
$newActivityAt = new DateTimeImmutable('2026-02-01 15:30:00');
|
|
$remainingTtl = 43200; // 12 hours remaining
|
|
$this->repository->updateActivity($session->familyId, $newActivityAt, $remainingTtl);
|
|
|
|
$updated = $this->repository->findByFamilyId($session->familyId);
|
|
self::assertNotNull($updated);
|
|
self::assertEquals($newActivityAt, $updated->lastActivityAt);
|
|
}
|
|
|
|
#[Test]
|
|
public function savePreservesAllSessionData(): void
|
|
{
|
|
$familyId = TokenFamilyId::generate();
|
|
$userId = UserId::generate();
|
|
$tenantId = TenantId::fromString(self::TENANT_ID);
|
|
$deviceInfo = DeviceInfo::fromUserAgent(
|
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0',
|
|
);
|
|
$location = Location::fromIp('8.8.8.8', 'United States', 'Mountain View');
|
|
$createdAt = new DateTimeImmutable('2026-01-31 10:00:00');
|
|
|
|
$session = Session::create(
|
|
$familyId,
|
|
$userId,
|
|
$tenantId,
|
|
$deviceInfo,
|
|
$location,
|
|
$createdAt,
|
|
);
|
|
|
|
$this->repository->save($session, 86400);
|
|
$retrieved = $this->repository->findByFamilyId($familyId);
|
|
|
|
self::assertNotNull($retrieved);
|
|
self::assertTrue($familyId->equals($retrieved->familyId));
|
|
self::assertTrue($userId->equals($retrieved->userId));
|
|
self::assertTrue($tenantId->equals($retrieved->tenantId));
|
|
self::assertSame('Desktop', $retrieved->deviceInfo->device);
|
|
self::assertSame('Chrome 120', $retrieved->deviceInfo->browser);
|
|
self::assertSame('Windows 10', $retrieved->deviceInfo->os);
|
|
self::assertSame('United States', $retrieved->location->country);
|
|
self::assertSame('Mountain View', $retrieved->location->city);
|
|
self::assertEquals($createdAt, $retrieved->createdAt);
|
|
}
|
|
|
|
private function createSession(): Session
|
|
{
|
|
return Session::create(
|
|
TokenFamilyId::generate(),
|
|
UserId::generate(),
|
|
TenantId::fromString(self::TENANT_ID),
|
|
DeviceInfo::fromUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0'),
|
|
Location::fromIp('192.168.1.1', 'France', 'Paris'),
|
|
new DateTimeImmutable('2026-01-31 10:00:00'),
|
|
);
|
|
}
|
|
}
|