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
243 lines
9.2 KiB
PHP
243 lines
9.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Administration\Infrastructure\Api\Controller;
|
|
|
|
use App\Administration\Domain\Event\SessionInvalidee;
|
|
use App\Administration\Domain\Event\ToutesSessionsInvalidees;
|
|
use App\Administration\Domain\Exception\SessionNotFoundException;
|
|
use App\Administration\Domain\Model\RefreshToken\RefreshToken;
|
|
use App\Administration\Domain\Model\RefreshToken\TokenFamilyId;
|
|
use App\Administration\Domain\Model\User\UserId;
|
|
use App\Administration\Domain\Repository\RefreshTokenRepository;
|
|
use App\Administration\Domain\Repository\SessionRepository;
|
|
use App\Administration\Infrastructure\Security\SecurityUser;
|
|
use App\Shared\Domain\Clock;
|
|
use App\Shared\Domain\Tenant\TenantId;
|
|
|
|
use function array_map;
|
|
use function count;
|
|
|
|
use DateTimeInterface;
|
|
use InvalidArgumentException;
|
|
use Symfony\Bundle\SecurityBundle\Security;
|
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
|
use Symfony\Component\HttpFoundation\Request;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
use Symfony\Component\Messenger\MessageBusInterface;
|
|
use Symfony\Component\Routing\Attribute\Route;
|
|
|
|
/**
|
|
* Sessions management endpoints.
|
|
*
|
|
* Architecture note: Events (SessionInvalidee, ToutesSessionsInvalidees) are dispatched
|
|
* directly from this controller rather than from domain aggregates. This is a documented
|
|
* exception to the standard DDD pattern because:
|
|
* - Session is a read model without command/aggregate behavior
|
|
* - The "invalidate" operation spans multiple aggregates (RefreshToken + Session)
|
|
* - The controller orchestrates infrastructure operations, making it the natural
|
|
* event emission point for audit trail purposes
|
|
*
|
|
* @see Story 1.6 - Gestion des sessions
|
|
*/
|
|
final readonly class SessionsController
|
|
{
|
|
public function __construct(
|
|
private Security $security,
|
|
private SessionRepository $sessionRepository,
|
|
private RefreshTokenRepository $refreshTokenRepository,
|
|
private MessageBusInterface $eventBus,
|
|
private Clock $clock,
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* List all active sessions for the current user.
|
|
*/
|
|
#[Route('/api/me/sessions', name: 'api_sessions_list', methods: ['GET'])]
|
|
public function list(Request $request): JsonResponse
|
|
{
|
|
$user = $this->getSecurityUser();
|
|
$userId = UserId::fromString($user->userId());
|
|
$tenantId = TenantId::fromString($user->tenantId());
|
|
|
|
// Get the current family ID from the refresh token cookie
|
|
$currentFamilyId = $this->getCurrentFamilyId($request, $userId);
|
|
|
|
$sessions = $this->sessionRepository->findAllByUserId($userId);
|
|
|
|
$sessionsData = [];
|
|
foreach ($sessions as $session) {
|
|
// Skip sessions from other tenants (defense in depth)
|
|
if (!$session->tenantId->equals($tenantId)) {
|
|
continue;
|
|
}
|
|
|
|
$isCurrent = $currentFamilyId !== null && $session->isCurrent($currentFamilyId);
|
|
|
|
$sessionsData[] = [
|
|
'family_id' => (string) $session->familyId,
|
|
'device' => $session->deviceInfo->device,
|
|
'browser' => $session->deviceInfo->browser,
|
|
'os' => $session->deviceInfo->os,
|
|
'location' => $session->location->format(),
|
|
'created_at' => $session->createdAt->format(DateTimeInterface::ATOM),
|
|
'last_activity_at' => $session->lastActivityAt->format(DateTimeInterface::ATOM),
|
|
'is_current' => $isCurrent,
|
|
];
|
|
}
|
|
|
|
// Sort: current session first, then by last activity (most recent first)
|
|
usort($sessionsData, static function (array $a, array $b): int {
|
|
if ($a['is_current'] && !$b['is_current']) {
|
|
return -1;
|
|
}
|
|
if (!$a['is_current'] && $b['is_current']) {
|
|
return 1;
|
|
}
|
|
|
|
return $b['last_activity_at'] <=> $a['last_activity_at'];
|
|
});
|
|
|
|
return new JsonResponse(['sessions' => $sessionsData]);
|
|
}
|
|
|
|
/**
|
|
* Revoke a specific session.
|
|
*/
|
|
#[Route('/api/me/sessions/{familyId}', name: 'api_sessions_revoke', methods: ['DELETE'])]
|
|
public function revoke(string $familyId, Request $request): JsonResponse
|
|
{
|
|
$user = $this->getSecurityUser();
|
|
$userId = UserId::fromString($user->userId());
|
|
|
|
try {
|
|
$targetFamilyId = TokenFamilyId::fromString($familyId);
|
|
$session = $this->sessionRepository->getByFamilyId($targetFamilyId);
|
|
} catch (InvalidArgumentException|SessionNotFoundException) {
|
|
throw new NotFoundHttpException('Session not found');
|
|
}
|
|
|
|
// Verify the session belongs to the user (return 404 to not leak existence)
|
|
if (!$session->belongsToUser($userId)) {
|
|
throw new NotFoundHttpException('Session not found');
|
|
}
|
|
|
|
// Prevent revoking current session via this endpoint (use logout instead)
|
|
$currentFamilyId = $this->getCurrentFamilyId($request, $userId);
|
|
if ($currentFamilyId !== null && $session->isCurrent($currentFamilyId)) {
|
|
throw new AccessDeniedHttpException('Cannot revoke current session. Use logout instead.');
|
|
}
|
|
|
|
// Invalidate the token family (disconnects the device)
|
|
$this->refreshTokenRepository->invalidateFamily($targetFamilyId);
|
|
|
|
// Delete the session
|
|
$this->sessionRepository->delete($targetFamilyId);
|
|
|
|
// Dispatch event for audit trail
|
|
$this->eventBus->dispatch(new SessionInvalidee(
|
|
userId: $user->userId(),
|
|
familyId: (string) $targetFamilyId,
|
|
tenantId: TenantId::fromString($user->tenantId()),
|
|
ipAddress: $request->getClientIp() ?? 'unknown',
|
|
userAgent: $request->headers->get('User-Agent', 'unknown'),
|
|
occurredOn: $this->clock->now(),
|
|
));
|
|
|
|
return new JsonResponse(['message' => 'Session revoked'], Response::HTTP_OK);
|
|
}
|
|
|
|
/**
|
|
* Revoke all sessions except the current one.
|
|
*/
|
|
#[Route('/api/me/sessions', name: 'api_sessions_revoke_all', methods: ['DELETE'])]
|
|
public function revokeAll(Request $request): JsonResponse
|
|
{
|
|
$user = $this->getSecurityUser();
|
|
$userId = UserId::fromString($user->userId());
|
|
|
|
// Get the current family ID to exclude it
|
|
$currentFamilyId = $this->getCurrentFamilyId($request, $userId);
|
|
|
|
if ($currentFamilyId === null) {
|
|
throw new AccessDeniedHttpException('Unable to identify current session');
|
|
}
|
|
|
|
// Delete all sessions except current
|
|
$deletedFamilyIds = $this->sessionRepository->deleteAllExcept($userId, $currentFamilyId);
|
|
|
|
// Invalidate all corresponding token families
|
|
foreach ($deletedFamilyIds as $familyId) {
|
|
$this->refreshTokenRepository->invalidateFamily($familyId);
|
|
}
|
|
|
|
// Dispatch event for audit trail (only if sessions were actually revoked)
|
|
if (count($deletedFamilyIds) > 0) {
|
|
$this->eventBus->dispatch(new ToutesSessionsInvalidees(
|
|
userId: $user->userId(),
|
|
invalidatedFamilyIds: array_map(static fn (TokenFamilyId $id): string => (string) $id, $deletedFamilyIds),
|
|
exceptFamilyId: (string) $currentFamilyId,
|
|
tenantId: TenantId::fromString($user->tenantId()),
|
|
ipAddress: $request->getClientIp() ?? 'unknown',
|
|
userAgent: $request->headers->get('User-Agent', 'unknown'),
|
|
occurredOn: $this->clock->now(),
|
|
));
|
|
}
|
|
|
|
return new JsonResponse([
|
|
'message' => 'All other sessions revoked',
|
|
'revoked_count' => count($deletedFamilyIds),
|
|
], Response::HTTP_OK);
|
|
}
|
|
|
|
private function getSecurityUser(): SecurityUser
|
|
{
|
|
$user = $this->security->getUser();
|
|
|
|
if (!$user instanceof SecurityUser) {
|
|
throw new AccessDeniedHttpException('Authentication required');
|
|
}
|
|
|
|
return $user;
|
|
}
|
|
|
|
/**
|
|
* Get the current session's family ID from the refresh token cookie.
|
|
*
|
|
* Security: Validates that the refresh token belongs to the authenticated user
|
|
* to prevent cross-account issues in multi-tab scenarios where JWT and cookie
|
|
* could belong to different users.
|
|
*/
|
|
private function getCurrentFamilyId(Request $request, UserId $authenticatedUserId): ?TokenFamilyId
|
|
{
|
|
$refreshTokenValue = $request->cookies->get('refresh_token');
|
|
|
|
if ($refreshTokenValue === null) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
$tokenId = RefreshToken::extractIdFromTokenString($refreshTokenValue);
|
|
$refreshToken = $this->refreshTokenRepository->find($tokenId);
|
|
|
|
if ($refreshToken === null) {
|
|
return null;
|
|
}
|
|
|
|
// Verify the refresh token belongs to the authenticated user
|
|
// This prevents misidentification in multi-tab/account-switch scenarios
|
|
if (!$refreshToken->userId->equals($authenticatedUserId)) {
|
|
return null;
|
|
}
|
|
|
|
return $refreshToken->familyId;
|
|
} catch (InvalidArgumentException) {
|
|
return null;
|
|
}
|
|
}
|
|
}
|