При работе с сессионными данными в веб-приложениях, использующих защищённые токены или зашифрованные структуры данных на основе Iron, ключевая проблема возникает при росте числа одновременно авторизованных устройств одного пользователя. Без ограничений серверная часть может хранить неограниченное количество валидных сессий, что увеличивает риск компрометации аккаунта и усложняет управление безопасностью.
Подход с использованием Iron (в контексте @hapi/iron)
обычно предполагает, что данные сессии упаковываются в защищённую строку
и хранятся на клиенте (например, в cookie). Это делает невозможным
прямое серверное «видение» всех активных сессий без дополнительного слоя
учёта. Поэтому ограничение числа активных сессий всегда реализуется
через внешнее хранилище состояния.
Для контроля количества сессий вводится серверный реестр активных токенов. Даже если сами данные защищены Iron, идентификатор сессии должен быть сопоставим с записью в хранилище.
Типовая структура записи:
Хранилище может быть реализовано через Redis, SQL или in-memory слой в зависимости от нагрузки.
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-токены становятся недействительными, поскольку сервер больше не распознаёт их идентификаторы.
При большом количестве пользователей критически важно:
Пример фоновой очистки:
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-токен не гарантирует активность сессии без записи в хранилище.
Такая модель позволяет:
При одновременном создании нескольких сессий возможны гонки. Для предотвращения состояния, когда лимит нарушается, применяются блокировки или атомарные операции хранилища.
В 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 остаётся только транспортным слоем шифрования, не влияя на бизнес-логику управления доступом.