Чёрный список токенов применяется в системах аутентификации, где необходимо принудительно отзывать ранее выданные токены до истечения их срока жизни. Это особенно актуально при использовании JWT или защищённых структурированных токенов, включая подходы с шифрованием и сериализацией данных, реализуемые через механизмы вроде Iron (Hapi Iron / @hapi/iron).
Основная проблема заключается в том, что многие токены изначально являются самодостаточными: они не требуют обращения к базе данных для проверки. Это даёт высокую производительность, но лишает возможности мгновенного отзыва. Решением становится введение централизованного слоя контроля — чёрного списка, хранящего идентификаторы отозванных токенов.
Redis используется как высокопроизводительное in-memory хранилище, идеально подходящее для операций частого чтения и записи с низкой задержкой. В контексте чёрного списка токенов Redis выполняет функцию временного реестра запрещённых идентификаторов.
Ключевые причины выбора Redis:
Каждый токен, попадающий в чёрный список, сохраняется с ограниченным временем жизни, равным оставшемуся времени валидности токена.
Библиотека Iron используется для безопасной упаковки данных с помощью шифрования и подписи. Типичный токен содержит:
Пример полезной нагрузки до упаковки:
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 с временем жизни токена. Это предотвращает накопление устаревших записей.
При обработке запроса необходимо:
jtiasync 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}
Это позволяет:
При высокой нагрузке чёрный список распределяется по узлам:
jtiДля снижения нагрузки применяются локальные кэши:
При одновременном отзыве и проверке токена возможны микро-задержки репликации Redis.
Решение:
При отсутствии TTL база Redis может переполниться.
Решение:
EXСбой Redis делает невозможной проверку blacklist.
Решение:
При распределённой архитектуре с несколькими сервисами:
Пример уведомления:
redis.publish("blacklist-channel", jti);
Слушатели:
redis.subscribe("blacklist-channel", (message) => {
localCache.set(message, true);
});
Без централизованного хранилища:
Использование только JWT без blacklist подходит только для статeless систем с низкими требованиями к безопасности сессий.
При использовании Iron важно учитывать, что токен уже является защищённой структурой, а значит:
Это превращает систему в гибридную модель: