Skip to content

Чёрный список токенов

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

Принцип работы

text
Revoke(token) → извлечение jti + exp → запись в BlacklistStore
Validate(token) → проверка подписи → проверка чёрного списка → возврат результата

Revoke не просто записывает переданную строку в чёрный список — сначала выполняется цепочка проверок безопасности, гарантирующая, что отзываются только реально выпущенные токены:

  1. Проверка подписи — подпись токена повторно проверяется ключом из конфигурации процессора; любые подделанные или искажённые токены отклоняются
  2. Проверка издателя и аудитории — проверяется соответствие iss и aud конфигурации процессора для предотвращения случайного отзыва между доменами
  3. Извлечение jti — уникальный идентификатор токена (jti) извлекается как ключ чёрного списка; если у токена нет jti, возвращается ErrTokenMissingID
  4. Расчёт TTL и запись в хранилище — по exp токена вычисляется время жизни записи (см. следующий раздел), и jti записывается в BlacklistStore

Проверка подписи — ключевая мера безопасности

Почему Revoke сначала проверяет подпись, а затем отзывает? Если бы непосредственно доверять переданному вызывающим jti, злоумышленник мог бы с подделанным jti отозвать токен любого легитимного пользователя, организовав атаку типа «отказ в обслуживании». Принудительная проверка подписи гарантирует, что «отозвать токен может только владелец реального токена» — это также означает, что при вызове Revoke необходимо передавать полную строку токена, а не «голый» jti.

Ни Revoke, ни IsRevoked не проверяют exp/nbf: даже если срок токена истёк, его всё ещё можно отозвать или запросить статус отзыва. Это сделано для того, чтобы аудит, ретроспективный отзыв и подобные сценарии охватывали исторические токены.

TTL записи чёрного списка

Записи чёрного списка не существуют постоянно. При записи Revoke вычисляет время жизни (TTL) записи на основе exp токена; по истечении срока токена запись становится недействительной и очищается. Три возможных случая:

  • У токена есть утверждение exp — TTL равен оставшемуся времени до exp, запись теряет силу синхронно с токеном. Это наиболее частый случай.
  • У токена нет утверждения exp — TTL по умолчанию равен 7 дням, предотвращая постоянное удержание записей токенами без срока действия.
  • Верхний предел TTL — 30 дней — даже если exp токена наступит через 100 лет, запись чёрного списка проживёт максимум 30 дней.

Ограничение в 30 дней — защита от DoS

30-дневный потолок — ключевая линия обороны. Без него атакующий мог бы конструировать токены с очень длинным exp (или злоупотреблять легитимными долгоживущими токенами) для массового отзыва, переполняя хранилище чёрного списка и исчерпывая память. С 30-дневным пределом время жизни любой отдельной записи ограничено, и масштаб хранилища остаётся управляемым.

Кроме того, истёкшие токены всё ещё можно отозвать: поскольку Revoke не проверяет exp/nbf, вы можете доотозвать токен после истечения его срока (например, при ретроспективном обнаружении риска в аудите). TTL таких записей берётся по умолчанию — 7 дней, после чего они собираются фоновым механизмом очистки.

Встроенное хранилище в памяти

По умолчанию используется хранилище в памяти, готовое к работе без настройки:

go
cfg := jwt.DefaultConfig()
cfg.SecretKey = "hmac-key-that-has-at-least-32-bytes!"
// Чёрный список уже автоматически включён, используется DefaultBlacklistConfig()

Параметры конфигурации

go
cfg.Blacklist.CleanupInterval = 5 * time.Minute  // Интервал очистки
cfg.Blacklist.MaxSize = 100000                     // Максимальное количество записей
cfg.Blacklist.EnableAutoCleanup = true             // Автоматическая очистка
ПолеПо умолчаниюОписание
CleanupInterval5mИнтервал очистки просроченных записей
MaxSize100000Максимальное количество записей
EnableAutoCleanuptrueАвтоматическая очистка (принудительно true)

Автоматическая очистка

EnableAutoCleanup встроенного хранилища всегда принудительно установлено в true для предотвращения неограниченного роста памяти.

Поведение вытеснения

При достижении количества записей MaxSize запись новой записи вызывает вытеснение, освобождающее место в следующем порядке:

ШагДействиеОписание
1Очистка просроченных записейСначала удаляются все записи с истёкшим сроком
2Вытеснение записей с самым ранним истечениемЕсли всё ещё заполнено, вытесняется около 10% (минимум 1) записей с самым ранним exp в порядке возрастания
3Отказ в записиЕсли всё ещё заполнено, Add возвращает ошибку, и Revoke соответственно возвращает неудачу

Таким образом, MaxSize — это не «заполнился и остановись»: под нагрузкой приоритетно вытесняются те записи, которые должны исчезнуть раньше всего (уже просроченные, истекающие быстрее). Но в крайних случаях отзыв всё ещё может завершиться неудачей — поэтому в production рекомендуется увеличивать MaxSize в соответствии с пиковой нагрузкой отзыва или использовать внешнее хранилище.

Отзыв токена

go
// Отзыв
err := processor.Revoke(accessToken)
if err != nil {
    panic(err)
}

// Проверка
revoked, err := processor.IsRevoked(accessToken)
fmt.Println("Revoked:", revoked) // true

// Проверка отозванного токена завершится ошибкой
_, _, err = processor.Validate(accessToken)
// err → jwt.ErrTokenRevoked

Пользовательский бэкенд хранилища

Реализуйте интерфейс BlacklistStore для подключения к внешнему хранилищу (Redis, база данных и т.д.):

go
type BlacklistStore interface {
    Add(tokenID string, expiresAt time.Time) error
    Contains(tokenID string) (bool, error)
    Close() error
}

Пример с Redis

go
type RedisStore struct {
    client *redis.Client
}

func (s *RedisStore) Add(tokenID string, expiresAt time.Time) error {
    ttl := time.Until(expiresAt)
    if ttl <= 0 {
        return nil // Просроченный токен не нужно хранить
    }
    return s.client.Set(ctx, "blacklist:"+tokenID, "1", ttl).Err()
}

func (s *RedisStore) Contains(tokenID string) (bool, error) {
    n, err := s.client.Exists(ctx, "blacklist:"+tokenID).Result()
    return n > 0, err
}

func (s *RedisStore) Close() error {
    return s.client.Close()
}

Использование пользовательского хранилища:

go
cfg.Blacklist.Store = &RedisStore{client: rdb}

Оптимизация TTL

Используйте time.Until(expiresAt) в качестве Redis TTL. Токен автоматически удалится из чёрного списка после истечения срока действия без дополнительной очистки.

Обязанности Close()

Processor.Close() при закрытии каскадно вызывает BlacklistStore.Close() — вам не нужно вручную закрывать хранилище чёрного списка, достаточно закрыть процессор. Реализация Close() пользовательского хранилища должна освобождать все нижележащие ресурсы:

  • Закрытие соединений Redis / базы данных
  • Остановку фоновых goroutine и ticker
  • Освобождение файловых дескрипторов и т.п.

s.client.Close() в примере Redis выше очищает пул соединений. Close() должен быть идемпотентным — повторные вызовы не должны возвращать ошибку (встроенная реализация хранилища уже следует этому соглашению, второй вызов просто возвращает nil).

Пользовательское хранилище не подчиняется CleanupInterval / MaxSize

CleanupInterval, MaxSize и EnableAutoCleanup из BlacklistConfig действуют только для встроенного хранилища в памяти. Как только вы устанавливаете поле Store для использования пользовательского бэкенда, эти три поля полностью игнорируются — очистку по истечении, лимит ёмкости и т.п. должно обеспечивать ваше хранилище (например, TTL Redis, запланированные задачи базы данных).

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

Несколько экземпляров должны использовать общий чёрный список

Встроенное хранилище в памяти не разделяется между процессами. Если сервис развёрнут в нескольких экземплярах (Pod / контейнер / сервер), токен, отозванный на одном экземпляре, всё равно будет проходить на других — после выхода пользователя переключение на другой экземпляр снова покажет его авторизованным. В многоэкземплярных сценариях необходимо использовать Redis, базу данных или иное разделяемое хранилище в качестве BlacklistStore, чтобы все экземпляры читали и писали единый чёрный список.

Мониторинг размера чёрного списка

Чёрный список постоянно накапливает записи отзыва, пока они не истекут по TTL. Рекомендуется мониторить размер хранилища (количество записей в памяти, число ключей в Redis) и оповещать об аномальном росте — внезапный всплеск часто означает массовый отзыв (например, инцидент безопасности) или слишком длинный TTL. Установка MaxSize немного выше пиковой нагрузки отзыва позволяет избежать вытеснения и неудач отзыва.

Короткие TTL-токены могут не нуждаться в чёрном списке

Если у токена доступа само по себе короткое время жизни (например, 15 минут), при «выходе» пользователя токен естественным образом истечёт через несколько минут — обычно нецелесообразно поддерживать для него чёрный список: затраты (хранилище + дополнительный запрос при каждой проверке) могут превышать выгоды. Чёрный список лучше подходит для отзыва долгоживущих токенов (долгоживущих access-токенов, refresh-токенов). Для сценариев с коротким TTL можно рассмотреть включение чёрного списка только для refresh-токенов.

Другие замечания

  • Реализация пользовательского хранилища должна обрабатывать сетевые тайм-ауты и повторные попытки, чтобы избежать блокировки цепочки проверки при нестабильности внешнего хранилища
  • После достижения предела MaxSize новые отзываемые токены вытесняют самые ранние записи (см. выше «Встроенное хранилище в памяти»)

Дальнейшие шаги