JavaRush /Курсы /Spring Security /Claims в payload JWT: минимум

Claims в payload JWT: минимум

Spring Security
22 уровень , 2 лекция
Открыта

1. JWT как контракт: легко превратить в мусор

Когда разработчик впервые видит JWT, очень хочется думать так: «Ну токен же подписан — значит, туда можно положить всё, что пригодится, и больше не ходить в базу». На практике это приводит к токенам-«чемоданам»: там и email, и display name, и кусок профиля, и какие-то внутренние флаги. А потом внезапно выясняется, что половину этого нельзя светить, другая половина устаревает, а третью вы сами уже не помните, зачем добавили (классическая ситуация: “кто это сюда положил?” — “я” — “почему?” — “не знаю”).

Как только токен перестал быть магической строкой и разложился на header.payload.signature, следующий инженерный вопрос становится жёстко практичным: что именно должно жить внутри payload, чтобы запрос можно было обработать, а токен при этом не превратился в чемодан с лишними данными.

JWT в нашем курсе — не витрина «как можно», а инструмент, который должен быть простым, стабильным и предсказуемым. И чтобы он таким был, мы обязаны относиться к payload как к контракту. Контракт — это когда вы можете открыть документацию и увидеть: какие claims есть, что они означают, какого типа значения, и какие из них действительно нужны проекту Secure Content Platform API.

2. Claim и standard/custom: смысл

Технически claim — это просто пара ключ → значение внутри JSON-объекта payload. Но с точки зрения разработки claim — это ещё и обещание смысла: если у вас есть claim "sub", то весь мир (и будущий вы через месяц) ожидает, что это «subject», то есть субъект токена. Если вы используете "sub" как «красивое имя на экране», вы как будто написали в паспорте «дата рождения» и туда же положили любимый мем. Вроде бумажка ваша, но людям вокруг потом сложно.

Здесь появляется полезное разделение:

Standard claims — это поля, у которых уже есть общепринятое назначение. Не обязательно использовать их все, но если используете — лучше придерживаться смысла. Это делает токен совместимее с инструментами, понятнее для коллег и проще для отладки.

Custom claims — это поля, которые придумывает ваш проект. Они нужны, когда стандартных полей не хватает, чтобы выразить ваш прикладной контекст. Но кастомные claims — это как специи: без них иногда пресно, но если высыпать половину банки, вкус будет «странный», и лечится это только слезами.

Чтобы было проще держать картину в голове, удобно запомнить одну фразу: standard claims отвечают за “кто/когда/откуда”, custom claims — за “что нужно моему приложению прямо сейчас”.

3. Standard claims: базовый минимум

Стандартных claims в спецификации больше, но на уровне Junior-разработчика важно хотя бы уверенно читать базовый набор. Мы не уходим в RFC-детали и не превращаем лекцию в энциклопедию, но фиксируем: эти имена встречаются чаще всего, и если вы их увидели — вы должны понимать общий смысл.

Ниже — таблица «узнаём по фамилии». Сразу договоримся: многие из этих claims — про время (iat, nbf, exp). Здесь важно пока увидеть их как маркеры временных границ токена: когда он выпущен, с какого момента допустим и когда перестаёт жить. Этого уже достаточно, чтобы не смешивать смысл claims с их отдельной временной проверкой.

Claim Тип значения в payload Смысл по-человечески Пример
sub String Субъект токена: «кто это?» в смысле идентификатора, а не “как красиво зовут” "sub": "anna" или "sub": "user-42"
iss String Кто выпустил токен (issuer). Полезно, чтобы токены «нашего приложения» не путались с чужими "iss": "secure-content-platform"
iat Number Когда выдан токен "iat": 1773828000
nbf Number Не раньше чем (not before): токен «существует», но ещё не должен приниматься "nbf": 1773828000
exp Number Когда истекает: после этого токен просрочен "exp": 1773828900

Можно добавить ещё несколько стандартных claims «на горизонте», чтобы вы не пугались, если встретите: aud (audience) — для кого токен предназначен, jti (JWT ID) — идентификатор токена. Они полезны, но для нашего минимального контракта на этом этапе не обязательны.

Самый важный среди этой пятёрки — sub. Именно из него обычно получается ответ на вопрос “кто сейчас пользователь?” в stateless-запросе. Но вот тонкость: subидентификатор, а не «человеческое представление». Если вы положите туда displayName, вы сами себе устроите веселую жизнь, потому что displayName может меняться, быть неуникальным и вообще быть «Анна (котики)» — а такие идентификаторы плохо ведут себя в backend-мире.

4. Custom claims для Secure Content Platform API

Теперь самое прикладное: наш токен должен помогать нашему проекту делать две вещи. Во-первых, восстановить кто пользователь (principal). Во-вторых, восстановить какой у него access-контекст (роли/права), чтобы request-level и method-level правила могли принимать решение. И при этом мы помним owner-based правила «свой/чужой»: для них часто нужен стабильный идентификатор пользователя, а не только “username”.

Отсюда рождается разумный минимум custom claims для нашего проекта:

userId (или более короткий uid) — числовой идентификатор аккаунта в БД, тот самый UserAccount.id.

roles — список ролей (USER, EDITOR, ADMIN) как компактный access-контекст.

Почему не email? Потому что email — персональные данные, и он нам не нужен для авторизации на каждом запросе. Почему не enabled/locked? Потому что это внутреннее состояние аккаунта, которое меняется, и хранить его в токене как «истину» рискованно: токен выдавался в прошлом, а состояние аккаунта живёт в настоящем.

Полезно оформить это в маленький проектный «token contract» — прямо в виде таблицы. Старайтесь мыслить не только “что положить”, но и “откуда берётся”, “насколько это стабильно” и “как часто меняется”.

Claim Standard / Custom Тип Откуда берём Насколько изменяемо Зачем проекту
sub standard String из username редко (обычно) понятный идентификатор субъекта токена
iss standard String константа приложения почти никогда отличать «наши токены» от чужих
userId custom Long из UserAccount.id никогда owner-based проверки, аудит, ссылки на данные
roles custom List<String> из ролей аккаунта иногда (админ меняет роли) role-based доступ, вычисление authorities

И теперь важный момент: custom claims — это не “полезные данные вообще”, это “данные, без которых проекту неудобно/дорого жить”. Если поле просто «может пригодиться», но не используется для решений доступа, отладки или аудита — скорее всего, оно должно жить не в токене, а в БД и доставаться обычным способом.

Чтобы было совсем наглядно, вот пример payload (как JSON), который выглядит «компактно и по делу»:

{
  "iss": "secure-content-platform",
  "sub": "anna",
  "userId": 42,
  "roles": ["USER", "EDITOR"],
  "iat": 1773828000,
  "exp": 1773828900
}

Здесь есть и стандартные поля, и пара кастомных, и ничего лишнего. Это хороший базовый стиль для учебного проекта: без маркетинга, без магии, без «снимка всей базы данных в токене».

5. Claims в Java: без “строк везде”

Пока мы не пишем генерацию токена и не строим фильтр. Но мы уже можем (и должны) научиться думать «как Java-разработчик»: контракт токена должен быть описан и типизирован хотя бы на уровне “вот имена claims, вот их смысл, вот ожидаемые типы”. Иначе stateless-ветка проекта быстро превращается в археологию: каждый класс выкапывает "sub" своей лопатой, иногда с ошибкой "subs".

Константы для имён claims

Первое простое улучшение — собрать имена claims в один класс. Да, это не «самая важная безопасность в мире», но это важная безопасность от человеческого фактора (то есть от нас с вами).

package com.example.securecontent.security.jwt;

// Единое место для имён claims, чтобы не плодить "магические строки" по коду.
public final class JwtClaimNames {

    // Standard claims
    public static final String SUBJECT = "sub";
    public static final String ISSUER = "iss";

    // Custom claims нашего проекта
    public static final String USER_ID = "userId";
    public static final String ROLES = "roles";

    private JwtClaimNames() {
        // Утилитный класс: экземпляры не нужны
    }
}

Смешно, но именно такие «скучные» решения потом экономят часы отладки, потому что IDE хотя бы может подсказать, что вы опечатались.

Record как “документация в коде”

Второе улучшение — завести record, который описывает наш минимальный набор claims. Он не делает токен «настоящим», он делает модель данных ясной.

package com.example.securecontent.security.jwt;

import java.util.List;

// Минимальный контракт access-токена на уровне модели данных.
public record AccessTokenClaims(
        String sub,        // кто пользователь (идентификатор/subject)
        String iss,        // кто выпустил токен (issuer)
        Long userId,       // стабильный id аккаунта в БД
        List<String> roles // роли, которые участвуют в проверках доступа
) {
}

Обратите внимание: в AccessTokenClaims нет iat/nbf/exp, хотя это стандартные claims. Здесь record описывает identity и access-context, а time-claims лучше держать отдельным слоем: они задают окно валидности токена, а не профиль пользователя. Иначе в одной модели смешиваются вопросы «кто это» и «когда этот токен вообще допустим».

Чтение payload как JSON

Даже без настоящего токена мы можем потренироваться: payload — это JSON, и Java может его прочитать. Для упражнений достаточно строки JSON и Jackson, который у нас в проекте уже есть.

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.util.Map;

// В учебных целях "payload" берём как обычную JSON-строку.
String payloadJson = """
        {"sub":"anna","iss":"secure-content-platform","userId":42,"roles":["USER"]}
        """;

ObjectMapper mapper = new ObjectMapper();

// Читаем JSON в "сырой" Map, чтобы увидеть реальные типы значений после десериализации.
Map<String, Object> claims = mapper.readValue(payloadJson, new TypeReference<>() {});

// Достаём отдельный claim по имени (в реальном коде лучше не хардкодить строкой).
System.out.println(claims.get("sub")); // anna

Этот кусочек кода — не про JWT-криптографию и не про Spring Security. Он про банальную, но важную штуку: payload — это данные, и вы должны уметь их читать и понимать. Тогда JWT перестаёт быть “чёрным ящиком” и становится обычным контрактом.

Мини-нюанс про типы

В Map<String, Object> вы почти всегда столкнётесь с тем, что числа и списки приходят как “какие-то объекты”. Например, userId может стать Integer, Long, а в некоторых случаях даже BigInteger — в зависимости от того, как сериализовали и как прочитали. Это нормально, но опасно, если вы пишете код “в лоб” с (Long) claims.get("userId").

На раннем этапе помогает простое правило: сначала воспринимайте claims как “сырой JSON”, а уже потом — как строго типизированные поля. Именно поэтому record с чёткими типами полезен как «цель», но путь от JSON к record нужно делать аккуратно.

6. Типичные ошибки при работе с claims

Ошибка №1: использовать sub как display name или email “потому что так удобно”.
sub — это субъект токена, то есть идентификатор. Если положить туда “Анна Dev” или email, вы можете случайно зацементировать персональные данные в каждом запросе, а заодно сделать идентификатор нестабильным (email и отображаемое имя иногда меняются). Гораздо спокойнее, когда sub — это что-то, что вы готовы считать главным идентификатором, например username или стабильный user-42.

Ошибка №2: превращать payload в “снимок профиля” (bio, avatarPath, displayName…).
Даже если эти данные кажутся безобидными, они меняются. Токен живёт какое-то время, а профиль может обновиться через минуту. В результате вы создаёте мир, где токен начинает врать просто потому, что он “старый”. Это не «баг JWT», это следствие решения положить mutable-данные в immutable-контракт.

Ошибка №3: класть в токен то, что нельзя светить (или то, что стыдно светить).
Пароли и passwordHash — очевидное табу, но новички иногда кладут “внутренние флаги” или служебные признаки, которые не хотели бы показывать клиенту. Помните простое правило из первой лекции дня: payload читается. Относитесь к нему как к открытке, а не как к запечатанному письму.

Ошибка №4: плодить хаотичные имена custom claims и потом утонуть в несовместимости.
Сегодня вы сделали user_id, завтра userId, послезавтра uid, а через неделю у вас три разных варианта в разных ветках кода. Это выглядит как мелочь, но именно такие мелочи заставляют stateless-ветку “вечно чиниться”. Нормальный путь — один раз зафиксировать имена claims и вынести их в JwtClaimNames, чтобы дальше проект жил без сюрпризов.

Ошибка №5: дублировать в токене всё сразу: и роли, и authorities, и ещё “на всякий случай” список доступных endpoint’ов.
Токен должен быть компактным. Чем больше вы тащите в payload, тем больше рисков утечки, устаревания и просто “больной головы” при поддержке. Для нашего учебного проекта разумный минимум — userId и roles. Всё остальное должно проходить через принцип “без этого нельзя принять решение доступа на запросе?” Если можно — значит, скорее всего, не надо.

1
Задача
Spring Security, 22 уровень, 2 лекция
Недоступна
Маппинг payload в record AccessTokenClaims
Маппинг payload в record AccessTokenClaims
1
Задача
Spring Security, 22 уровень, 2 лекция
Недоступна
Разделение claims на standard и custom
Разделение claims на standard и custom
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ