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