Ограничение числа активных сессий

При работе с сессионными данными в веб-приложениях, использующих защищённые токены или зашифрованные структуры данных на основе Iron, ключевая проблема возникает при росте числа одновременно авторизованных устройств одного пользователя. Без ограничений серверная часть может хранить неограниченное количество валидных сессий, что увеличивает риск компрометации аккаунта и усложняет управление безопасностью.

Подход с использованием Iron (в контексте @hapi/iron) обычно предполагает, что данные сессии упаковываются в защищённую строку и хранятся на клиенте (например, в cookie). Это делает невозможным прямое серверное «видение» всех активных сессий без дополнительного слоя учёта. Поэтому ограничение числа активных сессий всегда реализуется через внешнее хранилище состояния.

Архитектура учёта активных сессий

Для контроля количества сессий вводится серверный реестр активных токенов. Даже если сами данные защищены Iron, идентификатор сессии должен быть сопоставим с записью в хранилище.

Типовая структура записи:

  • userId — идентификатор пользователя
  • sessionId — уникальный идентификатор сессии
  • createdAt — время создания
  • lastSeen — время последней активности
  • fingerprint — опциональный идентификатор устройства или браузера

Хранилище может быть реализовано через Redis, SQL или in-memory слой в зависимости от нагрузки.

Генерация и упаковка сессии с Iron

Iron используется для защиты данных сессии перед отправкой клиенту.

import Iron from '@hapi/iron';

const password = process.env.IRON_PASSWORD;

async function sealSession(sessionData) {
  return await Iron.seal(sessionData, password, Iron.defaults);
}

async function unsealSession(ironToken) {
  return await Iron.unseal(ironToken, password, Iron.defaults);
}

Внутри sessionData обычно находится sessionId, который связывается с серверным реестром.

Создание новой сессии с ограничением

Логика ограничения количества активных сессий строится вокруг проверки текущего числа записей для пользователя.

const MAX_SESSIONS = 3;

async function createSession(userId, sessionStore, sessionData) {
  const sessions = await sessionStore.getByUser(userId);

  if (sessions.length >= MAX_SESSIONS) {
    const oldest = sessions.sort((a, b) => a.createdAt - b.createdAt)[0];
    await sessionStore.remove(oldest.sessionId);
  }

  const sessionId = generateUniqueId();

  const record = {
    userId,
    sessionId,
    createdAt: Date.now(),
    lastSeen: Date.now()
  };

  await sessionStore.save(record);

  const sealed = await Iron.seal({ sessionId, userId }, password, Iron.defaults);

  return sealed;
}

Такой подход обеспечивает предсказуемое поведение: при превышении лимита удаляется самая старая сессия.

Проверка сессии при каждом запросе

Каждый запрос должен сопровождаться проверкой как целостности Iron-данных, так и наличия сессии в серверном хранилище.

async function validateSession(ironToken, sessionStore) {
  const data = await Iron.unseal(ironToken, password, Iron.defaults);

  const session = await sessionStore.get(data.sessionId);

  if (!session) {
    throw new Error('Session expired or revoked');
  }

  await sessionStore.updateLastSeen(data.sessionId);

  return session;
}

Таким образом достигается возможность централизованного управления активными сессиями, несмотря на клиентское хранение зашифрованных данных.

Стратегии ограничения сессий

Удаление самой старой сессии

Наиболее распространённый вариант, при котором новая авторизация автоматически вытесняет старую сессию.

Особенность подхода заключается в предсказуемости поведения, но возможна потеря сессии на другом устройстве без уведомления.

Удаление всех сессий кроме текущей

Применяется в сценариях повышенной безопасности (например, при смене пароля или подозрительной активности).

async function revokeOtherSessions(userId, currentSessionId, sessionStore) {
  const sessions = await sessionStore.getByUser(userId);

  for (const s of sessions) {
    if (s.sessionId !== currentSessionId) {
      await sessionStore.remove(s.sessionId);
    }
  }
}

Ограничение по устройствам

При добавлении fingerprint устройства появляется возможность ограничивать не количество сессий, а количество уникальных устройств.

async function canCreateSession(userId, fingerprint, sessionStore) {
  const sessions = await sessionStore.getByUser(userId);

  return sessions.filter(s => s.fingerprint === fingerprint).length === 0;
}

Инвалидация сессий при компрометации

При обнаружении подозрительной активности необходимо мгновенно аннулировать все связанные записи.

async function revokeAllSessions(userId, sessionStore) {
  const sessions = await sessionStore.getByUser(userId);

  for (const s of sessions) {
    await sessionStore.remove(s.sessionId);
  }
}

После этого все ранее выданные Iron-токены становятся недействительными, поскольку сервер больше не распознаёт их идентификаторы.

Оптимизация хранения и производительности

При большом количестве пользователей критически важно:

  • использовать индекс по userId
  • очищать устаревшие сессии по TTL
  • хранить lastSeen для фоновой очистки неактивных сессий
  • минимизировать операции полного перебора

Пример фоновой очистки:

async function cleanupExpiredSessions(sessionStore, maxAgeMs) {
  const now = Date.now();
  const all = await sessionStore.getAll();

  for (const session of all) {
    if (now - session.lastSeen > maxAgeMs) {
      await sessionStore.remove(session.sessionId);
    }
  }
}

Связь Iron и серверного состояния

Iron в данной архитектуре выполняет исключительно функцию защиты данных при передаче клиенту. Реальное управление сессиями всегда остаётся на стороне сервера. Это ключевой момент: даже полностью валидный Iron-токен не гарантирует активность сессии без записи в хранилище.

Такая модель позволяет:

  • централизованно отзывать доступ
  • ограничивать количество входов
  • отслеживать активность пользователей
  • обеспечивать контроль устройств

Поведение при превышении лимита в конкурентной среде

При одновременном создании нескольких сессий возможны гонки. Для предотвращения состояния, когда лимит нарушается, применяются блокировки или атомарные операции хранилища.

В Redis это может выглядеть как транзакция:

await redis.multi()
  .lrange(`sessions:${userId}`, 0, -1)
  .exec();

Либо использование Lua-скриптов для атомарного удаления и добавления записей.

Контроль активности через обновление времени доступа

Регулярное обновление lastSeen позволяет отделять активные сессии от «зависших».

async function touchSession(sessionId, sessionStore) {
  await sessionStore.update(sessionId, {
    lastSeen: Date.now()
  });
}

Это позволяет вводить динамические лимиты: например, считать активными только те сессии, которые использовались за последние 30 минут.

Масштабирование системы контроля сессий

При росте нагрузки система часто выносится в отдельный сервис управления сессиями. Он отвечает за:

  • хранение метаданных
  • проверку лимитов
  • ревокацию
  • аудит активности

Iron остаётся только транспортным слоем шифрования, не влияя на бизнес-логику управления доступом.