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; } } }