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:
@@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user