Чёрный список токенов: реализация через Redis

Чёрный список токенов применяется в системах аутентификации, где необходимо принудительно отзывать ранее выданные токены до истечения их срока жизни. Это особенно актуально при использовании JWT или защищённых структурированных токенов, включая подходы с шифрованием и сериализацией данных, реализуемые через механизмы вроде Iron (Hapi Iron / @hapi/iron).

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


Роль Redis в хранении отозванных токенов

Redis используется как высокопроизводительное in-memory хранилище, идеально подходящее для операций частого чтения и записи с низкой задержкой. В контексте чёрного списка токенов Redis выполняет функцию временного реестра запрещённых идентификаторов.

Ключевые причины выбора Redis:

  • скорость операций O(1)
  • поддержка TTL (автоматическое удаление устаревших записей)
  • атомарность команд
  • возможность масштабирования через кластеризацию

Каждый токен, попадающий в чёрный список, сохраняется с ограниченным временем жизни, равным оставшемуся времени валидности токена.


Структура токена при использовании Iron

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

  • идентификатор пользователя
  • уникальный идентификатор сессии (jti)
  • временные метки (iat, exp)
  • метаданные авторизации

Пример полезной нагрузки до упаковки:

const session = {
  sub: "user_123",
  jti: "8f3c2a9e-4b77-4a6f-9c10-12d8f1c1a9b3",
  iat: Date.now(),
  exp: Date.now() + 3600 * 1000
};

После упаковки через Iron структура становится непрозрачной строкой:

import Iron from '@hapi/iron';

const sealed = await Iron.seal(session, process.env.IRON_PASSWORD, Iron.defaults);

Принцип работы чёрного списка

Чёрный список строится вокруг идентификатора jti (JWT ID или аналогичный уникальный идентификатор сессии). При необходимости отзыва токена система добавляет этот идентификатор в Redis.

Добавление токена в чёрный список

import Redis from "ioredis";

const redis = new Redis();

async function blacklistToken(jti, exp) {
  const ttl = Math.floor(exp - Date.now() / 1000);

  if (ttl > 0) {
    await redis.set(`blacklist:${jti}`, "1", "EX", ttl);
  }
}

Здесь ключевой момент — синхронизация TTL с временем жизни токена. Это предотвращает накопление устаревших записей.


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

При обработке запроса необходимо:

  1. Распаковать токен через Iron
  2. Извлечь jti
  3. Проверить наличие в Redis
async function isTokenBlacklisted(jti) {
  const result = await redis.get(`blacklist:${jti}`);
  return result !== null;
}

Интеграция с проверкой доступа:

import Iron from '@hapi/iron';

async function verifyToken(sealedToken) {
  const session = await Iron.unseal(
    sealedToken,
    process.env.IRON_PASSWORD,
    Iron.defaults
  );

  const blacklisted = await isTokenBlacklisted(session.jti);

  if (blacklisted) {
    throw new Error("Token revoked");
  }

  return session;
}

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

При logout токен не удаляется (так как он уже выдан), но добавляется в Redis:

async function logout(sealedToken) {
  const session = await Iron.unseal(
    sealedToken,
    process.env.IRON_PASSWORD,
    Iron.defaults
  );

  await blacklistToken(session.jti, session.exp);
}

Это гарантирует мгновенную деактивацию токена во всех сервисах, использующих общий Redis-слой.


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

Использование префиксов ключей

Структура ключей должна быть унифицированной:

blacklist:{jti}

Это позволяет:

  • легко выполнять batch-операции
  • разделять пространства ключей
  • масштабировать систему

Использование Redis Cluster

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

  • шардинг по jti
  • горизонтальное масштабирование
  • отказоустойчивость при потере узла

Минимизация обращений к Redis

Для снижения нагрузки применяются локальные кэши:

  • LRU-кэш на уровне API gateway
  • краткоживущие in-memory проверки
  • батчинг запросов

Потенциальные проблемы архитектуры

1. Гонка состояния (race condition)

При одновременном отзыве и проверке токена возможны микро-задержки репликации Redis.

Решение:

  • использование master-узла для записи
  • read-after-write consistency

2. Рост количества ключей

При отсутствии TTL база Redis может переполниться.

Решение:

  • обязательное использование EX
  • периодический аудит ключей

3. Зависимость от Redis

Сбой Redis делает невозможной проверку blacklist.

Решение:

  • fallback-режим (deny-by-default или allow-by-default в зависимости от политики безопасности)
  • репликация Redis

Масштабирование системы чёрного списка

При распределённой архитектуре с несколькими сервисами:

  • каждый сервис проверяет Redis независимо
  • используется единый namespace ключей
  • возможна синхронизация через pub/sub для мгновенного распространения отзывов

Пример уведомления:

redis.publish("blacklist-channel", jti);

Слушатели:

redis.subscribe("blacklist-channel", (message) => {
  localCache.set(message, true);
});

Альтернативные подходы и их ограничения

Без централизованного хранилища:

  • невозможно мгновенно отозвать токен
  • требуется сокращённый TTL
  • увеличивается нагрузка на refresh-токены

Использование только JWT без blacklist подходит только для статeless систем с низкими требованиями к безопасности сессий.


Интеграция Iron с политикой отзыва

При использовании Iron важно учитывать, что токен уже является защищённой структурой, а значит:

  • содержимое нельзя модифицировать после выдачи
  • отзыв возможен только через внешний контрольный слой
  • Redis становится обязательным элементом архитектуры

Это превращает систему в гибридную модель:

  • stateless токен (Iron)
  • stateful контроль (Redis blacklist)