feat: Gestion des sessions utilisateur

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
This commit is contained in:
2026-02-03 10:10:40 +01:00
parent affad287f9
commit b823479658
40 changed files with 4222 additions and 42 deletions

View File

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