Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 15 из 25 ~18 мин +20 XP

Отзыв токенов: denylist по jti

Отзыв jwt токена ломает главное обещание JWT — состояние без базы: пока не кончился exp, токен жив даже после выхода пользователя. Решение — denylist по jti в Redis: logout записывает идентификатор с TTL ровно до exp, middleware на каждом запросе сверяется со списком. Разбираем выпуск с jti, запись и проверку, TTL-арифметику и решение, которое придётся принять при падении Redis.

Содержание урока · 9 разделов

Аутентификация по JWT обещает состояние без хранилища: подпись верна, exp не вышел — токен годен. Обещание превращается в проблему в трёх ситуациях: пользователь нажал «выйти», а его access живёт ещё четырнадцать минут; аккаунт забанен, а его токены работают; пароль сменён после кражи, а украденная сессия ходит по API. Отзыв jwt токена возвращает контроль: denylist — список идентификаторов отозванных токенов в Redis, который middleware сверяет на каждом запросе. Полного stateless больше нет — за контроль заплачена одна проверка хранилища, и это честная цена: без неё единственным «отзывом» остаётся короткий exp, который сокращает окно, но не закрывает его. В уроке: выпуск с уникальным jti, запись в denylist с TTL до exp, проверка после Auth и развилка fail-open против fail-closed, на которой спорят все команды.

jti: идентификатор, по которому отзывают#

Отзывают не строку токена целиком — идентификатор из claims. Поле jti (JWT ID) живёт в jwt.RegisteredClaims как ID: сервер генерирует его при выпуске, шестнадцать случайных байт из crypto/rand в hex дают практически нулевую вероятность коллизии. Именно уникальность делает jti пригодным: denylist хранит «этот конкретный токен отозван», а не «этот пользователь» — отзыв чужих сессий по userID через denylist сделать нельзя, там просто нет такого ключа. Хеш самой строки токена тоже вариант, но он длиннее и хуже читается в логах: ключ deny:9f86d081 по jti короче и говорит сам за себя. Если токены выпускаются без jti — как все примеры до четвёртого урока про claims — отзыву не за что зацепиться, и первое изменение будет именно в выпуске.

Выпуск с уникальным jti

GO
func newJTI() (string, error) {
        b := make([]byte, 16)
        if _, err := rand.Read(b); err != nil {
                return "", err
        }
        return hex.EncodeToString(b), nil
}

func IssueWithJTI(userID int64, role string) (string, error) {
        jti, err := newJTI()
        if err != nil {
                return "", err
        }
        claims := AppClaims{
                UserID: userID,
                Role:   role,
                RegisteredClaims: jwt.RegisteredClaims{
                        ID:        jti,
                        ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
                },
        }
        return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)
}

crypto/rand даёт шестнадцать случайных байт, hex — строку из тридцати двух символов; она уходит в RegisteredClaims.ID и живёт в payload, а не в header. ID — единственное новое поле: остальное повторяет выпуск из урока про claims. Предупреждение на будущее: logout ниже читает claims.ID, поэтому пустой jti — это баг выпуска, который проявится не при выпуске, а в отзыве.

Logout: запись в denylist с TTL до exp

GO
func logout(r *http.Request) error {
        claims, ok := ClaimsFromContext(r.Context())
        if !ok {
                return errors.New("нет аутентификации")
        }
        ttl := time.Until(claims.ExpiresAt.Time)
        if ttl <= 0 {
                return nil
        }
        return redisClient.Set(r.Context(),
                "deny:"+claims.ID, "1", ttl).Err()
}

TTL вычисляется как time.Until от exp: ровно столько, сколько токен ещё прожил бы. Запись умирает сама в тот же момент, когда умер бы токен, поэтому чистка denylist не нужна вообще — за это отвечает Redis. Отрицательный TTL означает истёкший токен: писать его в список бессмысленно, и logout просто ничего не делает.

Проверка denylist в middleware#

Место проверки — между Auth и хендлером: сначала подпись и сроки разбирает существующий слой, потом новый слой NotRevoked смотрит jti в Redis, и только затем хендлер делает работу. Порядок важен: до разбора токена проверять нечего — jti живёт в claims. Команда Exists вместо Get короче и не тащит значение: ноль — ключа нет, токен годен; единица — отозван. Цена решения — один Redis-поход на каждый защищённый запрос: на локальной сети это доли миллисекунды, и за них вы получаете мгновенный logout вместо четырнадцати минут ожидания. Кэшировать отрицательный ответ «не отозван» внутри одного запроса можно, между запросами — нельзя: кэш между запросами превращает мгновенный отзыв в отложенный, и весь смысл решения пропадает.

NotRevoked: слой между Auth и хендлером

GO
func NotRevoked(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                claims, ok := ClaimsFromContext(r.Context())
                if !ok {
                        http.Error(w, "нет аутентификации", http.StatusUnauthorized)
                        return
                }
                n, err := redisClient.Exists(r.Context(), "deny:"+claims.ID).Result()
                if err != nil {
                        http.Error(w, "сервис проверки недоступен", http.StatusServiceUnavailable)
                        return
                }
                if n > 0 {
                        http.Error(w, "токен отозван", http.StatusUnauthorized)
                        return
                }
                next.ServeHTTP(w, r)
        })
}

Три исхода разделены честно: ключ не найден — next, ключ найден — 401 «токен отозван», Redis недоступен — 503. Exists возвращает количество найденных ключей; ошибка соединения не смешивается с ответом «отозван», это разные состояния. Слой читает те же claims из контекста, что и RequireRole из одиннадцатого урока: стек Auth, NotRevoked, RequireRole собирается без правок друг в друге.

TTL до exp: почему список не раздувается#

Элегантность схемы — в вычислении срока записи. Запись deny:jti живёт ровно до exp отозванного токена: после этого момента токен всё равно не прошёл бы проверку сроков, и упоминать его дальше незачем. Redis сам удаляет ключи по TTL, и denylist не требует ни фоновых чистильщиков, ни лимитов размера: память занята только реально отозванными и ещё живыми токенами. Числа: access на пятнадцать минут даёт максимум пятнадцать минут жизни записи; тысяча выходов в час — около тысячи ключей в пике, ничего. Растёт список только при отзыве refresh-токенов с длинным exp — формула та же, просто TTL больше. Если access короче минуты, размер denylist остаётся ничтожным: он пропорционален числу отзывов, а не числу всех выданных токенов.

Спорное мнение: одному инстансу Redis не нужен#

Если сервис ровно один, крутится в единственном экземпляре, а denylist нужен ради logout — я бы держал его в памяти процесса: map с jti и горутина-чистильщик, раз в минуту выбрасывающая просроченные записи. Ноль сетевых поездок, ноль зависимости от Redis, TTL до exp работает так же. Граница проходит по второму инстансу: два пода за балансировщиком означают два разных списка, и отозванный в одном токен спокойно живёт в другом — вот здесь общий Redis становится не оптимизацией, а условием корректности. Вторая оговорка: процесс с denylist в памяти теряет список при рестарте, и мгновенный logout пропадает на время простоя. Для многих команд это приемлемая цена, но принять её надо письменно, на ревью, — а не обнаружить во время разбора инцидента.

Что отзывать на какие события#

  • Logout: текущий jti access-токена — окно отзыва равно оставшемуся сроку, обычно минуты.
  • Refresh при ротации: старый помечается отозванным в базе — он хранится там с рождения, как в уроке про refresh-токены.
  • Смена пароля: все токены пользователя — одним jti не обойтись; проще всего версия пользователя в claims, которая растёт при смене и сверяется на проверке.
  • Бан аккаунта: тот же механизм версии пользователя плюс отзыв refresh в базе.
  • Утечка ключа подписи: это не denylist, а ротация kid из урока про JWKS — старые подписи умирают вместе с ключом.
  • Компрометация конкретного токена: его jti в denylist с TTL до exp — точечный ответ без побочных эффектов.

Кейс: бан в базе есть, а токены ходят#

Схема из реального разбора: модератор банит нарушителя, запись пользователя получает флаг, а API продолжает принимать его запросы — access подписан, exp не вышел, denylist пуст, потому что logout никто не вызывал. Бан, который действует до истечения exp, — не бан, а отсрочка: при пятнадцатиминутном access терпимо, при refresh-сессии на месяц — неделя плохих новостей. Лечение двухслойное: при бане отзывается refresh в базе, где он хранится, а access отсекается сверкой статуса — либо флаг загружается на проверке, либо в claims живёт token_version, которая растёт при бане и перестаёт совпадать на следующем же запросе. Denylist тут не помощник: он гасит конкретные jti, а их у пользователя столько, сколько устройств — по одному на каждую сессию.

Отдельно про refresh: настоящий долгий доступ к аккаунту живёт в нём, а не в пятнадцатиминутном access. Поэтому denylist ставят в пару к хранению refresh-токенов: access гасится списком на минуты, refresh — записью в базе, где он и так лежит для ротации. Схема из урока про refresh-токены уже хранит refresh — logout удаляет его и добавляет jti access в denylist. Отзыв одного access без отзыва refresh — полумера: пользователь вышел из браузера, а украденный refresh продолжает обновлять пары. Правило пары: logout гасит оба, и тогда «выйти» действительно значит выйти.

Проверка: срок записи

Какой TTL ставится записи deny:jti?

Проверка: fail-closed на практике

Redis недоступен, политика fail-closed. Что получают пользователи?

Токен без jti

Прочитайте код и выберите, что он выведет:

// токен выпущен без jti: RegisteredClaims.ID пуст
// однажды в Redis записался ключ "deny:" — logout токена без jti
// приходит запрос с другим токеном, тоже без jti

n, _ := redisClient.Exists(ctx, "deny:"+claims.ID).Result()
// что вернёт n?

Допишите отзыв токена

Восстановите расчёт TTL до exp и запись ключа в Redis.

ttl := time.(claims.ExpiresAt.) if ttl <= 0 { return nil } return redisClient.(r.Context(), "deny:"+claims.ID, "1", ttl).Err()

Соберите начало logout-хендлера

Расставьте строки в правильном порядке: от сигнатуры до тихого отказа без claims.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: отзыв токенов

Термин

jti

нажмите, чтобы перевернуть
Определение

JWT ID из RegisteredClaims: уникальный идентификатор токена, генерируется при выпуске из crypto/rand. Основа denylist: отзывается конкретный токен, а не пользователь.

1 / 5

Denylist закрывает главный вопрос, который задают JWT-скептики: «а если пользователь вышел?» Теперь ответ есть: jti плюс Redis с TTL до exp. Дальше сессия переезжает в клиентскую часть: в уроке про HttpOnly cookie токен living в куки, которую скрипт не читает, и logout совмещается с её очисткой. Происхождение jti и остальных registered-полей — в уроке про claims, а границы stateless-модели и споры «сессии против токенов» продолжатся в уроке про сервисные токены. Хранилище вокруг denylist — это вся территория курса go-redis: от строк и TTL до распределённых блокировок. Карта всех тем — на странице курса jwt-go.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • Отзыв jwt токена строится на denylist по jti: уникальный идентификатор из claims отзывает конкретный токен.
  • Logout пишет deny:jti через redis.Set с TTL ровно до exp: запись умирает вместе с токеном, чистка не нужна.
  • Проверка — слой NotRevoked между Auth и хендлером: Exists по ключу, один Redis-поход за запрос.
  • Redis упал: fail-closed даёт 503 и сохраняет гарантию отзыва; fail-open пропускает отозванные токены во время сбоя.
  • Токены без jti отзываются одним ключом deny: — уникальность идентификатора обязательна при выпуске.
  • Refresh-токены отзываются в базе, где живут; access — denylist-ом на оставшиеся минуты.

Домашнее задание

Добавьте jti в выпуск, logout с Set и TTL до exp и слой NotRevoked после Auth. Проверьте три сценария: logout до exp даёт 401, после exp — отказ сроков, остановленный Redis — 503. Затем добавьте token_version в claims и реализуйте отзыв всех токенов пользователя при смене пароля; сравните объём кода с denylist-вариантом.

Личные заметкиПусто
Хранятся только в вашем браузере (JWT · Отзыв токенов: denylist по jti)

Вопросы и ответы по уроку

Не теряется ли главный плюс JWT — отсутствие состояния?

Частично: каждый запрос ходит в Redis. Но состояние остаётся дешевле сессионного: хранятся только отозванные идентификаторы с коротким TTL, а не все активные сессии. Если и эта цена не нужна — укорачивайте exp и живите с окном риска; выбор зависит от того, сколько стоит украденная сессия.

Почему отзывать по jti, а не по userID?

Отзыв по userID — это отзыв всех токенов пользователя разом, что правильно при бане и смене пароля, но слишком широко для logout: гаснут сессии на всех устройствах. jti даёт точечность, а массовый отзыв делается версией пользователя поверх.

Что если один сервис забыл проверять denylist?

Отозванный токен будет работать именно там: denylist — часть контракта аутентификации, и каждый проверяющий обязан его сверять. Централизуйте слой в общей библиотеке middleware, как делали с Auth, — тогда дыра в одном сервисе станет ошибкой сборки, а не инцидентом.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Telegram25 уроков~12 чновичок25 уроков уже доступны

Telegram-боты на Go: от эха до полноценного сервиса

Учимся писать Telegram-ботов на Go: токен BotFather, команды, клавиатуры, состояния диалога, медиа, рассылки, webhook и деплой. 25 уроков с кодом.

  • Создавать бота через BotFather и хранить токен безопасно
  • Получать обновления через long polling и webhook
  • Отвечать сообщениями, командами и клавиатурами
  • Строить многошаговые диалоги с сохранением состояния