Чёрный список токенов
Чёрный список используется для принудительной аннулирации токенов до истечения их срока действия. Применяется при выходе пользователя из системы, смене пароля, изменении прав доступа и других сценариях.
Принцип работы
Revoke(token) → извлечение jti + exp → запись в BlacklistStore
Validate(token) → проверка подписи → проверка чёрного списка → возврат результатаRevoke не просто записывает переданную строку в чёрный список — сначала выполняется цепочка проверок безопасности, гарантирующая, что отзываются только реально выпущенные токены:
- Проверка подписи — подпись токена повторно проверяется ключом из конфигурации процессора; любые подделанные или искажённые токены отклоняются
- Проверка издателя и аудитории — проверяется соответствие
issиaudконфигурации процессора для предотвращения случайного отзыва между доменами - Извлечение jti — уникальный идентификатор токена (
jti) извлекается как ключ чёрного списка; если у токена нетjti, возвращаетсяErrTokenMissingID - Расчёт 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 дней, после чего они собираются фоновым механизмом очистки.
Встроенное хранилище в памяти
По умолчанию используется хранилище в памяти, готовое к работе без настройки:
cfg := jwt.DefaultConfig()
cfg.SecretKey = "hmac-key-that-has-at-least-32-bytes!"
// Чёрный список уже автоматически включён, используется DefaultBlacklistConfig()Параметры конфигурации
cfg.Blacklist.CleanupInterval = 5 * time.Minute // Интервал очистки
cfg.Blacklist.MaxSize = 100000 // Максимальное количество записей
cfg.Blacklist.EnableAutoCleanup = true // Автоматическая очистка| Поле | По умолчанию | Описание |
|---|---|---|
CleanupInterval | 5m | Интервал очистки просроченных записей |
MaxSize | 100000 | Максимальное количество записей |
EnableAutoCleanup | true | Автоматическая очистка (принудительно true) |
Автоматическая очистка
EnableAutoCleanup встроенного хранилища всегда принудительно установлено в true для предотвращения неограниченного роста памяти.
Поведение вытеснения
При достижении количества записей MaxSize запись новой записи вызывает вытеснение, освобождающее место в следующем порядке:
| Шаг | Действие | Описание |
|---|---|---|
| 1 | Очистка просроченных записей | Сначала удаляются все записи с истёкшим сроком |
| 2 | Вытеснение записей с самым ранним истечением | Если всё ещё заполнено, вытесняется около 10% (минимум 1) записей с самым ранним exp в порядке возрастания |
| 3 | Отказ в записи | Если всё ещё заполнено, Add возвращает ошибку, и Revoke соответственно возвращает неудачу |
Таким образом, MaxSize — это не «заполнился и остановись»: под нагрузкой приоритетно вытесняются те записи, которые должны исчезнуть раньше всего (уже просроченные, истекающие быстрее). Но в крайних случаях отзыв всё ещё может завершиться неудачей — поэтому в production рекомендуется увеличивать MaxSize в соответствии с пиковой нагрузкой отзыва или использовать внешнее хранилище.
Отзыв токена
// Отзыв
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, база данных и т.д.):
type BlacklistStore interface {
Add(tokenID string, expiresAt time.Time) error
Contains(tokenID string) (bool, error)
Close() error
}Пример с Redis
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()
}Использование пользовательского хранилища:
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новые отзываемые токены вытесняют самые ранние записи (см. выше «Встроенное хранилище в памяти»)
Дальнейшие шаги
- Справочник API → BlacklistStore — определение интерфейса
- Справочник API → BlacklistConfig — поля конфигурации
- Справочник API → Revoke — метод отзыва
- Продвинутые примеры — пример чёрного списка с Redis