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,268 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\Redis;
use App\Administration\Domain\Exception\SessionNotFoundException;
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\Domain\Repository\SessionRepository;
use App\Shared\Domain\Tenant\TenantId;
use function count;
use DateTimeImmutable;
use DateTimeInterface;
use Psr\Cache\CacheItemPoolInterface;
/**
* Redis implementation of the sessions repository.
*
* Storage structure:
* - Session data: session:{family_id} → session JSON data
* - User index: user_sessions:{user_id} → set of family_ids
*
* @see Story 1.6 - Gestion des sessions
*/
final readonly class RedisSessionRepository implements SessionRepository
{
private const string SESSION_PREFIX = 'session:';
private const string USER_SESSIONS_PREFIX = 'user_sessions:';
/**
* Maximum TTL for user index (8 days).
*
* Must be >= the longest possible session TTL to ensure
* all sessions are properly indexed.
*/
private const int MAX_USER_INDEX_TTL = 691200;
public function __construct(
private CacheItemPoolInterface $sessionsCache,
) {
}
public function save(Session $session, int $ttlSeconds): void
{
// Save the session
$sessionItem = $this->sessionsCache->getItem(self::SESSION_PREFIX . $session->familyId);
$sessionItem->set($this->serialize($session));
$sessionItem->expiresAfter($ttlSeconds);
$this->sessionsCache->save($sessionItem);
// Add to user index
$userItem = $this->sessionsCache->getItem(self::USER_SESSIONS_PREFIX . $session->userId);
/** @var list<string> $familyIds */
$familyIds = $userItem->isHit() ? $userItem->get() : [];
$familyIds[] = (string) $session->familyId;
$userItem->set(array_values(array_unique($familyIds)));
$userItem->expiresAfter(self::MAX_USER_INDEX_TTL);
$this->sessionsCache->save($userItem);
}
public function getByFamilyId(TokenFamilyId $familyId): Session
{
$session = $this->findByFamilyId($familyId);
if ($session === null) {
throw new SessionNotFoundException($familyId);
}
return $session;
}
public function findByFamilyId(TokenFamilyId $familyId): ?Session
{
$item = $this->sessionsCache->getItem(self::SESSION_PREFIX . $familyId);
if (!$item->isHit()) {
return null;
}
/** @var array{family_id: string, user_id: string, tenant_id: string, device: string, browser: string, os: string, raw_user_agent: string, ip: string|null, country: string|null, city: string|null, created_at: string, last_activity_at: string} $data */
$data = $item->get();
return $this->deserialize($data);
}
public function findAllByUserId(UserId $userId): array
{
$userItem = $this->sessionsCache->getItem(self::USER_SESSIONS_PREFIX . $userId);
if (!$userItem->isHit()) {
return [];
}
/** @var list<string> $familyIds */
$familyIds = $userItem->get();
$sessions = [];
$validFamilyIds = [];
foreach ($familyIds as $familyIdStr) {
$session = $this->findByFamilyId(TokenFamilyId::fromString($familyIdStr));
if ($session !== null) {
$sessions[] = $session;
$validFamilyIds[] = $familyIdStr;
}
}
// Clean up stale entries from user index
if (count($validFamilyIds) !== count($familyIds)) {
$userItem->set($validFamilyIds);
$userItem->expiresAfter(self::MAX_USER_INDEX_TTL);
$this->sessionsCache->save($userItem);
}
return $sessions;
}
public function delete(TokenFamilyId $familyId): void
{
// Find session to get userId for index cleanup
$session = $this->findByFamilyId($familyId);
// Delete the session
$this->sessionsCache->deleteItem(self::SESSION_PREFIX . $familyId);
// Remove from user index if session existed
if ($session !== null) {
$this->removeFromUserIndex($session->userId, $familyId);
}
}
public function deleteAllExcept(UserId $userId, TokenFamilyId $exceptFamilyId): array
{
$userItem = $this->sessionsCache->getItem(self::USER_SESSIONS_PREFIX . $userId);
if (!$userItem->isHit()) {
return [];
}
/** @var list<string> $familyIds */
$familyIds = $userItem->get();
$deletedFamilyIds = [];
foreach ($familyIds as $familyIdStr) {
$familyId = TokenFamilyId::fromString($familyIdStr);
if ($familyId->equals($exceptFamilyId)) {
continue;
}
$this->sessionsCache->deleteItem(self::SESSION_PREFIX . $familyIdStr);
$deletedFamilyIds[] = $familyId;
}
// Update user index to only contain the excepted session
$userItem->set([(string) $exceptFamilyId]);
$userItem->expiresAfter(self::MAX_USER_INDEX_TTL);
$this->sessionsCache->save($userItem);
return $deletedFamilyIds;
}
public function updateActivity(TokenFamilyId $familyId, DateTimeImmutable $at, int $ttlSeconds): void
{
$session = $this->findByFamilyId($familyId);
if ($session === null) {
return;
}
$updatedSession = $session->updateActivity($at);
// Preserve TTL aligned with refresh token expiry
$sessionItem = $this->sessionsCache->getItem(self::SESSION_PREFIX . $familyId);
$sessionItem->set($this->serialize($updatedSession));
$sessionItem->expiresAfter($ttlSeconds);
$this->sessionsCache->save($sessionItem);
}
private function removeFromUserIndex(UserId $userId, TokenFamilyId $familyId): void
{
$userItem = $this->sessionsCache->getItem(self::USER_SESSIONS_PREFIX . $userId);
if (!$userItem->isHit()) {
return;
}
/** @var list<string> $familyIds */
$familyIds = $userItem->get();
$familyIds = array_values(array_filter(
$familyIds,
static fn (string $id) => $id !== (string) $familyId,
));
if (count($familyIds) === 0) {
$this->sessionsCache->deleteItem(self::USER_SESSIONS_PREFIX . $userId);
} else {
$userItem->set($familyIds);
$userItem->expiresAfter(self::MAX_USER_INDEX_TTL);
$this->sessionsCache->save($userItem);
}
}
/**
* @return array<string, mixed>
*/
private function serialize(Session $session): array
{
return [
'family_id' => (string) $session->familyId,
'user_id' => (string) $session->userId,
'tenant_id' => (string) $session->tenantId,
'device' => $session->deviceInfo->device,
'browser' => $session->deviceInfo->browser,
'os' => $session->deviceInfo->os,
'raw_user_agent' => $session->deviceInfo->rawUserAgent,
'ip' => $session->location->ip,
'country' => $session->location->country,
'city' => $session->location->city,
'created_at' => $session->createdAt->format(DateTimeInterface::ATOM),
'last_activity_at' => $session->lastActivityAt->format(DateTimeInterface::ATOM),
];
}
/**
* @param array{
* family_id: string,
* user_id: string,
* tenant_id: string,
* device: string,
* browser: string,
* os: string,
* raw_user_agent: string,
* ip: string|null,
* country: string|null,
* city: string|null,
* created_at: string,
* last_activity_at: string
* } $data
*/
private function deserialize(array $data): Session
{
return Session::reconstitute(
familyId: TokenFamilyId::fromString($data['family_id']),
userId: UserId::fromString($data['user_id']),
tenantId: TenantId::fromString($data['tenant_id']),
deviceInfo: DeviceInfo::reconstitute(
device: $data['device'],
browser: $data['browser'],
os: $data['os'],
rawUserAgent: $data['raw_user_agent'],
),
location: Location::reconstitute(
ip: $data['ip'],
country: $data['country'],
city: $data['city'],
),
createdAt: new DateTimeImmutable($data['created_at']),
lastActivityAt: new DateTimeImmutable($data['last_activity_at']),
);
}
}