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

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

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

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

Claims: стандартные и свои поля

JWT-claims — это поля payload: стандартный набор RFC 7519, собранный в Go в структуру jwt.RegisteredClaims, плюс ваши данные о пользователе. Разберём каждое поле, соберём собственный тип claims и выясним, что golang-jwt проверяет сам, а что остаётся решать вам. Токен из прошлого урока станет вдумчивым: с типами, сроками и правилами того, что внутрь класть.

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

Claims — утверждения. Так это и переводится: токен говорит «субъект — 42», «выдан — сегодня», «годен — до обеда». Формально payload — обычный JSON, и класть туда можно что угодно; пользу приносят договорённости: имена полей закреплены стандартом RFC 7519, поэтому серверы и библиотеки разных языков понимают друг друга без перевода. В golang-jwt v5 два способа задать claims. Первый — jwt.MapClaims, map[string]any из прошлого урока: быстро и гибко, но компилятор не подскажет опечатку в имени поля. Второй — своя структура со встраиванием jwt.RegisteredClaims: стандартные поля с типами и методами валидации плюс ваши собственные. Для учебных скриптов и тестов хватает карты; для продакшена я завожу структуру — и в этом уроке покажу обе дороги, чтобы выбор был сознательным, а не случайным.

Стандартные claims RFC 7519 в RegisteredClaims#

Поле структурыJSON-имяСмысл
Issuerissкто выпустил токен: имя сервиса или домен
Subjectsubо ком токен: обычно идентификатор пользователя строкой
Audienceaudдля кого токен: получатель или список получателей
ExpiresAtexpвремя смерти, после которого токен невалиден
NotBeforenbfне принимать раньше этого момента
IssuedAtiatмомент выпуска
IDjtiуникальный номер токена — по нему ищут и отзывают

jwt.RegisteredClaims и NewNumericDate: типы, а не сырые числа#

Стандартные поля в структуре типизированы, и главное открытие ждёт там, где ждёшь int64. Даты — IssuedAt, ExpiresAt, NotBefore — живут в типе *jwt.NumericDate, и получают их через jwt.NewNumericDate от time.Time. Почему не голые числа: NumericDate хранит unix-секунды с возможной дробной частью, умеет сравниваться и главное — несёт методы валидации, по которым v5 понимает, что exp просрочен, а nbf ещё не наступил. Audience устроен хитрее всех: тип jwt.ClaimStrings принимает и строку, и массив строк, потому что стандарт допускает обе записи. Subject — строка даже для числовых идентификаторов: fmt.Sprintf с %d привычен и честен. Свои поля добавляются обычными полями структуры: UserID int64, Role string. Единственная шероховатость — имена в JSON: без json-тегов ваши поля уедут в payload как UserID и Role, а команда обычно хочет user_id и role; теги дописываются при объявлении структуры одной строкой и ничего не ломают.

Быстрый путь: MapClaims

GO
claims := jwt.MapClaims{
        "sub":  "42",
        "role": "admin",
        "exp":  jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
        "iat":  jwt.NewNumericDate(time.Now()),
        "jti":  "42-1767225600",
}

token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString([]byte(secret))

MapClaims — это map[string]any: имена полей пишутся руками, и компилятор опечатку не поймает. Зато никакую структуру объявлять не нужно, и в тестах это самый быстрый путь. jti собран из идентификатора и времени — при повторе он не уникален, но для демонстрации нагляден; в продакшене его делает UUID или похожий генератор.

Взрослый путь: своя структура с RegisteredClaims

GO
type UserClaims struct {
        UserID int64
        Role   string
        jwt.RegisteredClaims
}

func makeClaims(id int64, role string) UserClaims {
        now := time.Now()
        return UserClaims{
                UserID: id,
                Role:   role,
                RegisteredClaims: jwt.RegisteredClaims{
                        Subject:   fmt.Sprintf("%d", id),
                        Issuer:    "goma-auth",
                        IssuedAt:  jwt.NewNumericDate(now),
                        ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)),
                        ID:        fmt.Sprintf("%d-%d", id, now.UnixNano()),
                },
        }
}

token := jwt.NewWithClaims(jwt.SigningMethodHS256, makeClaims(42, "admin"))

Своя структура встраивает jwt.RegisteredClaims: вместе с ним прилетают типы полей и методы валидации, которые ParseWithClaims вызовет сам. Subject заполняется строкой из id, ID — jti из UnixNano, ExpiresAt — пятнадцатью минутами от now. Конструктор makeClaims собирает всё в одном месте, и пропустить exp становится сложно.

exp, iat, nbf: что библиотека проверяет сама#

Свои поля приносят и свои ошибки, и моя любимая — jti из идентификатора пользователя. Схема выглядит разумно: sub и jti совпадают, всё предсказуемо. Проблемы начинаются при отзыве: denylist хранит номера вычеркнутых токенов, и вычеркнутый jti убивает не конкретный токен, а все текущие и будущие токены пользователя — вместе с refresh-парой и следующими входами, которые он не заслужил. Вторая вариация — jti из time.Now().Unix(): два токена, выпущенные в одну секунду, получают один номер и обмениваются судьбой. Требование к jti ровно одно — уникальность, и дешевле закрыть её сразу: UUID, снежинка или UnixNano из примера выше. Проверяется это одной строкой в тесте: выпустите два токена подряд и сравните ID. Как строится отзыв и что он стоит — урок про denylist.

Проверка дат — то место, где v5 заметно добрее прежних версий. При ParseWithClaims библиотека автоматически сверяет exp с текущим временем и отвергает просроченный токен, проверяет nbf — не рано ли, и iat — не из будущего ли. Условие одно: поля должны быть заполнены. А вот отсутствие exp ошибкой по умолчанию не считается: токен без срока годности пройдёт проверку и будет валиден вечно — та самая ошибка из вводного урока, теперь на уровне API. Лечится опцией jwt.WithExpirationRequired(): парсер требует exp и возвращает ошибку, если его нет. Я ставлю эту опцию во все парсеры проекта по умолчанию: она превращает забывчивость выпуска в мгновенный отказ проверки. Ещё в копилку автопроверки — jwt.WithLeeway(time.Minute): часы серверов расходятся, и льготная минута снимает целую категорию случайных отказов на границе exp.

Вариация примера, которая показывает темпоральные поля в деле: токен, действующий с будущего момента. NotBefore заполняется тем же jwt.NewNumericDate, только время указывается не сейчас, а позже: ночной батч получает токен, который примется только после полуночи, или заранее выпускается партия токенов для завтрашнего мероприятия. Проверка идёт автоматически: парсер сверяет nbf наравне с exp и отвергает рано пришедший токен ошибкой jwt.ErrTokenNotValidYet — её, как и все ошибки v5, разбирают через errors.Is. Нюанс, о который спотыкаются: leeway действует и на nbf, поэтому льготная минута парсера сдвигает и начало действия — токен может приняться чуть раньше заявленного. Для большинства задач это неважно, для токенов с жёстким окном запуска — критично: либо убирайте leeway, либо закладывайте запас в nbf. iat остаётся честным временем выпуска: это момент подписи, а не начала действия.

Проверка UserClaims с обязательным exp

GO
parsed, err := jwt.ParseWithClaims(signed, &UserClaims{},
        func(t *jwt.Token) (any, error) {
                return []byte(secret), nil
        },
        jwt.WithValidMethods([]string{"HS256"}),
        jwt.WithExpirationRequired())

if errors.Is(err, jwt.ErrTokenExpired) {
        fmt.Println("токен просрочен — нужен refresh")
        return
}

if err != nil {
        fmt.Println("токен отвергнут:", err)
        return
}

claims := parsed.Claims.(*UserClaims)
fmt.Println(claims.UserID, claims.Role)

Два опциона-замка: WithValidMethods держит алгоритм, WithExpirationRequired требует exp. При ошибке первым проверяется jwt.ErrTokenExpired через errors.Is — просрочка отвечает клиенту подсказкой про refresh, остальное молча превращается в 401. На успехе claims уже декодированы в UserClaims: поле Role читается как обычное поле структуры.

Свои поля в jwt claims: сколько данных носить с собой#

Сколько пользовательских данных класть в jwt claims — вопрос компромисса, и решать его стоит по цене запроса. Каждое поле увеличивает токен, а токен едет в заголовке каждого запроса: сотни байт против похода в базу. В токене хорошо живут идентификатор, роль, версия прав — то, что меняется редко и читается всегда. Плохо живут корзина покупателя, настройки интерфейса, статистика — любые данные, которые редактируются чаще, чем раз в exp: обновите корзину в базе, а токен продолжит нести старую копию до истечения срока. Помните и о цене ошибки: payload читается всеми, поэтому ничего личного сверх идентификатора я бы не клал вовсе. Изменяемые данные достаются по sub из базы или кэша — токен отвечает за личность, база за состояние. Это разделение сохранит и размер, и свежесть данных, и спокойный сон.

Пара слов о выборе между картой и структурой напоследок. MapClaims — для тестов, примеров и разовых скриптов: меньше церемоний, но имена полей никто не проверяет. Структура с RegisteredClaims — для сервисов: компилятор ловит опечатки, валидация дат работает из коробки, а добавление своего поля — одна строка и понятный дифф. В курсе дальше будут и то, и другое: тесты удобно собирать картами, а middleware — структурой. Выбор структуры не помешает и переходу на RSA в уроке про асимметричные подписи: claims не зависят от алгоритма, меняются только тип ключа в SignedString и источник ключа в keyfunc. А если маршрут курса кажется вам затянутым — перечитайте программу на странице курса и посмотрите, что осталось до итогового проекта.

Проверка: время смерти

Какое поле RegisteredClaims задаёт время смерти токена?

Проверка: токен без exp

Токен выпущен без поля exp. Что сделает проверка ParseWithClaims с настройками по умолчанию?

Проверка: смена прав

Пользователь поменял роль в базе с user на admin, но продолжает ходить со старым токеном на 15 минут. Что вернёт ваш middleware?

Что напечатает декодер payload?

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

token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
        "sub":   "42",
        "role":  "admin",
        "email": "ivan@example.com",
})
signed, _ := token.SignedString([]byte("secret"))

parts := strings.Split(signed, ".")
decoded, _ := base64.RawURLEncoding.DecodeString(parts[1])
fmt.Println(string(decoded))

Даты в RegisteredClaims

Допишите заполнение дат: время смерти и момент выпуска токена.

claims := jwt.RegisteredClaims{ Subject: "42", ExpiresAt: jwt.(time.Now().Add(15 * time.Minute)), IssuedAt: jwt.(time.Now()), }

Соберите claims по порядку

Расставьте строки сборки MapClaims: от точки отсчёта времени до закрывающей скобки.

Строки

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

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

Карточки: claims

Термин

Claims

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

Утверждения в payload токена: стандартные поля RFC 7519 плюс свои. Верить им можно только после проверки подписи.

1 / 6

Токен обзавёлся типами и сроками, и остался последний кирпич фундамента — подпись. В уроке про HMAC и HS256 разберём, что именно считает SignedString, где жить секрету и как не дать атакующему подменить алгоритм. Если хотите освежить, как токен выглядит снаружи, — вернитесь к рукотворной сборке из второго урока и добавьте в неё поля из таблицы этого урока: те же строки, теперь с полным набором стандартных claims. Домашнее задание, как всегда, под локальный Go.

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

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

Вывод

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

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

  • Claims — поля payload: стандарт RFC 7519 собран в jwt.RegisteredClaims, свои поля добавляются в структуру или карту.
  • Даты — тип NumericDate через jwt.NewNumericDate; Audience принимает jwt.ClaimStrings — строку или массив.
  • Библиотека сама проверяет exp, nbf и iat; отсутствие exp по умолчанию пропускается — ставьте WithExpirationRequired().
  • Короткий exp — способ разносить изменения прав: новая роль подхватится после истечения срока.
  • В claims живут идентификатор, роль и версия прав; корзина, настройки и персональные данные — в базе.
  • MapClaims — для тестов и скриптов, своя структура с RegisteredClaims — для сервисов.

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

Расширьте программу из прошлого урока: заведите структуру UserClaims с UserID и Role, заполните RegisteredClaims полностью — Subject, Issuer, IssuedAt, ExpiresAt, ID. Выпустите токен и проверьте тем же ParseWithClaims с WithExpirationRequired. Затем уберите ExpiresAt из конструктора и убедитесь, что проверка стала отказывать: так работает обязательный exp.

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

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

MapClaims или своя структура — что выбрать?

Для тестов и примеров — MapClaims: быстрее собрать. Для сервисов — структура со встраиванием RegisteredClaims: компилятор проверяет имена полей, валидация дат работает из коробки, а payload получается предсказуемым.

Почему Subject — строка, если идентификаторы числовые?

Так диктует RFC 7519: sub — строка или URI. Число переводится fmt.Sprintf, обратно распознаётся strconv. Числовой идентификатор можно держать и в своём поле структуры рядом — но стандартное поле остаётся строкой.

Что происходит с токенами после смены прав пользователя?

Ничего: claims — снимок на момент выпуска, и старый токен будет врать до exp. Поэтому access-токены живут минуты, права читаются из свежего токена, а радикальные случаи — отзыв по jti — требуют отдельного механизма вроде denylist.

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

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

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

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

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

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

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