JavaRush /Курсы /Spring Security /JWT: проверка и сбор...

JWT: проверка и сборка Authentication

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

1. JWT после извлечения — всё ещё “просто строка”

Когда token уже нашли в заголовке и довели до JWT-фильтра, легко поймать опасное ощущение: “Ну всё, токен нашли, значит пользователь аутентифицирован”. Это ощущение примерно как вера в бумажку с надписью “Я админ”. Бумажка может быть красивой, в ламинации, даже с печатью… но пока мы не проверили, кто её выписал и не испортили ли её по дороге, доверять ей нельзя.

JWT (в нашем случае JWS-подписанный токен) специально устроен так, что его payload легко читается. Это нормально: он не про секретность, а про целостность. Если вы просто “распарсили payload”, вы получили данные, которые мог написать кто угодно. Реальная магия JWT начинается ровно в тот момент, когда вы проверили подпись и убедились, что токен действительно выпущен вашим сервером и не был изменён.

Представим плохой сценарий. Злоумышленник берёт настоящий токен, меняет в payload список прав на ["user:manage", "draft:publish"] — и отправляет. Если ваш фильтр “наивно” верит claims без проверки подписи, то ваше приложение превращается в “пожалуйста, возьмите админку, она лежала на столе”.

Поэтому дисциплина такая: сначала мы делаем validation (доверие), и только потом — extraction (извлечение данных). Извлечь без доверия можно, но использовать нельзя.

Мини-напоминание (чтобы не путаться): простое декодирование Base64 — это не безопасность. Это как открыть чемодан и посмотреть, что внутри. Без проверки подписи вы не знаете, подложили ли туда кирпич вместо ноутбука.

2. Минимальная валидация JWT для Secure Content Platform API

Когда говорят “валидировать JWT”, новички иногда представляют себе криптографический ритуал с бубном. На практике в нашем курсе достаточно понять простой набор обязательных проверок: структура, подпись, время жизни и минимальные необходимые claims. Это уже даёт крепкий фундамент, не превращая лекцию в криптографический трек.

Сначала мы убеждаемся, что токен вообще похож на JWT: у него три части header.payload.signature. Затем мы проверяем подпись (для нас это означает: “подписан нашим секретом/ключом, значит выпущен нами и не изменён”). После этого проверяем exp (срок жизни). И только потом читаем полезные claims: кого мы аутентифицируем (username/sub, userId) и какие права ему дать (authorities).

Удобно держать эту логику в голове как “контрольный лист”:

Проверка Зачем она нужна Что будет, если пропустить
Структура a.b.c Чтобы не пытаться парсить мусор malformed token, ошибки парсинга
Подпись Чтобы не принять поддельный payload Эскалация прав “по бумажке”
exp (expiration) Чтобы токены не жили вечно Утёкший токен работает бесконечно
Обязательные claims (username, userId) Чтобы собрать осмысленного principal “Аутентифицированный” пользователь без личности
Authorities/roles Чтобы работали правила доступа Везде будет 403 или, наоборот, “везде можно”

И очень важное уточнение для нашей архитектуры курса: на этом шаге мы не проверяем пароль. Пароль был проверен на этапе POST /api/auth/login, когда выдавался токен. Сейчас мы лишь восстанавливаем “кто это” и “какие у него права” для текущего запроса.

3. TokenService: validate + extract без дублирования

Если вы попытаетесь сделать в фильтре 15 строк split, Base64, JsonNode и “ну вроде работает”, то через три дня вы сами себе начнёте задавать философский вопрос: “Кто написал этот код и почему он меня ненавидит?”. Поэтому мы держим дисциплину: у нас есть TokenService, и именно он отвечает за две вещи — валидировать токен и доставать из него то, что нужно.

Важно не скатиться в стиль “сервис на 30 методов extractXxx”, каждый из которых по-своему парсит токен. Во‑первых, это дублирование работы. Во‑вторых, это риск: один метод “проверил exp”, другой — “не проверил”, третий — “забыл про подпись”. И вы получаете самую страшную категорию багов: иногда безопасно, иногда нет.

Лучше сделать так: TokenService один раз парсит токен, проверяет доверие, и возвращает нормализованный результат. Например, отдельный record ValidatedToken.

package com.example.securecontent.security.jwt;

import com.example.securecontent.security.auth.CurrentUserPrincipal;
import java.util.List;

// Результат, которому уже можно доверять: токен проверен (подпись/exp/claims),
// и мы извлекаем только то, что нужно для Spring Security.
public record ValidatedToken(CurrentUserPrincipal principal, List<String> authorities) {
}

Теперь валидация и извлечение “склеены” в одну операцию. Примерный контракт:

package com.example.securecontent.security.jwt;

public interface TokenService {

    // Один вход — один выход: на этом шаге мы и валидируем, и извлекаем данные.
    // Важно: метод не должен возвращать "сырой" payload без проверки подписи.
    ValidatedToken parseAndValidate(String token);
}

А вот кусочек внутренней логики TokenService (упрощённо). Здесь важно не библиотека, а идея: сначала подпись и exp, потом claims.

На этом шаге удобно сразу различать категорию проблемы, а не только текст сообщения: фильтр сможет отреагировать на неё предсказуемо, без угадывания по строке ошибки. Здесь битую структуру, невалидную подпись и прочие “этому токену нельзя верить” удобно складывать в одну корзину TokenProblem.MALFORMED; отдельно нам нужен только EXPIRED.

import java.time.Clock;
import java.time.Instant;

private void validateExpiration(Instant exp, Clock clock) {
    // Берём текущее время из Clock, чтобы это было удобно тестировать.
    Instant now = clock.instant();

    // Если exp раньше now — токен просрочен, и мы не должны доверять его claims.
    if (exp.isBefore(now)) {
        throw new TokenValidationException(TokenProblem.EXPIRED, "Token expired");
    }
}

Если вы используете библиотеку, которая проверяет подпись, это будет выглядеть примерно так (снова: не как догма, а как ориентир):

import com.nimbusds.jose.crypto.MACVerifier;
import com.nimbusds.jwt.SignedJWT;

// Парсим JWS (подписанный JWT) из строки.
SignedJWT jwt = SignedJWT.parse(token);

// Криптографически проверяем подпись (а не "смотрим header").
boolean signatureOk = jwt.verify(new MACVerifier(secret));

if (!signatureOk) {
    // Если подпись не сошлась — токен подделан или был изменён по дороге.
    throw new TokenValidationException(TokenProblem.MALFORMED, "Bad signature");
}

Ещё раз: на уровне курса достаточно понимать, что “проверить подпись” — это не “посмотреть header”, а именно выполнить криптографическую проверку через библиотеку.

4. Principal из claims: CurrentUserPrincipal

В JWT есть соблазн засунуть всё: и профиль, и аватар, и любимый цвет кнопок. Но наш проект — не социальная сеть на стероидах, а учебный backend, который учится делать безопасность аккуратно. Поэтому мы держим principal компактным. Нам в запросе обычно нужны две вещи: идентификатор пользователя (для owner-based правил и доступа к БД) и логин/username (для логов и некоторых сценариев).

Идеальный формат для такого principal в Java — обычный record. Он маленький, неизменяемый и не пытается жить своей жизнью.

package com.example.securecontent.security.auth;

// Минимальное "security-представление" пользователя для текущего запроса.
// Важно: это НЕ JPA-сущность и не "полный профиль", а только то, что нужно в security.
public record CurrentUserPrincipal(Long userId, String username) {
}

Почему это важно? Потому что principal — это “security-представление” пользователя, а не “наш JPA-UserAccount во всей красе”. Если вы начнёте таскать в principal сущности, привязанные к БД, вы быстро получите проблемы: ленивые поля, случайные сериализации, зависимости слоя безопасности от persistence-слоя. А потом кто-то захочет залогировать principal — и вы внезапно увидите полпрофиля в логах. Не надо так.

Плюс — не забывайте: JWT payload по определению не секретен. Он читается. Поэтому никаких email, телефонов и “маминой девичьей фамилии” в principal из токена.

Мини‑пример того, как мы собираем principal после успешной валидации:

import com.example.securecontent.security.auth.CurrentUserPrincipal;

// Извлекаем только после успешной проверки подписи и exp.
Long userId = tokenClaims.userId();
String username = tokenClaims.username();

// Собираем компактный principal, который будет жить в Authentication/SecurityContext.
CurrentUserPrincipal principal = new CurrentUserPrincipal(userId, username);

Где tokenClaims — это ваш внутренний нормализованный результат разбора токена. Мы его можем хранить как record, как map — не принципиально. Принципиально, что к этому моменту токен уже проверен.

5. Строковые права → GrantedAuthority

В нашем проекте права выражены строками вроде draft:publish, user:manage, profile:write. В коде это выглядит красиво и читаемо. Но Spring Security внутри не хранит “просто строки”. Он хранит коллекцию объектов GrantedAuthority. Это интерфейс с одним методом getAuthority(), и Spring использует его как абстракцию над “правами”.

Самый простой адаптер из строки в GrantedAuthoritySimpleGrantedAuthority. Мы берём строку и оборачиваем.

import java.util.List;
import org.springframework.security.core.authority.SimpleGrantedAuthority;

// Конвертируем строки из токена в тип, который понимает Spring Security.
List<SimpleGrantedAuthority> grantedAuthorities = authoritiesFromToken.stream()
        .map(SimpleGrantedAuthority::new) // "draft:publish" -> new SimpleGrantedAuthority("draft:publish")
        .toList();

Здесь критически важно, чтобы строки authority в токене совпадали с тем, что ожидают ваши правила. Если в SecurityFilterChain вы пишете .hasAuthority("user:manage"), а в токене по ошибке лежит "USER_MANAGE" или "manage:user", то у вас будет вечное “почему у меня 403, я же админ”.

Отдельная тонкость — роли и ROLE_. Напомню: hasRole("ADMIN") фактически проверяет authority "ROLE_ADMIN". Если вы хотите продолжать использовать hasRole, то в токен нужно класть именно "ROLE_ADMIN", а не просто "ADMIN". В рамках нашего курса удобнее придерживаться одного стиля: или вы кладёте в токен уже нормализованные значения (ROLE_ADMIN, draft:publish), или вы в одном месте делаете конверсию. Главное — чтобы это было последовательно, а не “вчера было так, сегодня так”.

6. Authentication из JWT: сборка и смысл

Теперь у нас есть две вещи: principal (кто пользователь) и grantedAuthorities (какие у него права). Осталось собрать объект Authentication. Важно понять саму идею: Authentication — это стандартный контейнер Spring Security для результата аутентификации. Он не обязан означать “проверили пароль прямо сейчас”. Он означает “для текущего запроса у нас есть аутентифицированный пользователь”.

Самый удобный класс для нашего сценария — UsernamePasswordAuthenticationToken. Его название может сбить с толку (“почему username/password, если у нас JWT?”), но на практике это просто готовая реализация Authentication, которую удобно использовать. Пароль (credentials) в этом моменте нам не нужен, поэтому он будет null. И да, это нормально.

import com.example.securecontent.security.auth.CurrentUserPrincipal;
import java.util.List;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;

CurrentUserPrincipal principal = new CurrentUserPrincipal(userId, username);

// Права из токена приводим к формату, который ожидает Spring Security.
List<SimpleGrantedAuthority> granted = authorities.stream()
        .map(SimpleGrantedAuthority::new)
        .toList();

// credentials = null, потому что пароль/секрет мы здесь не проверяем (это JWT-сценарий).
// Важно: конструктор (principal, credentials, authorities) создаёт authenticated-объект.
Authentication authentication = new UsernamePasswordAuthenticationToken(principal, null, granted);

Обратите внимание на важную деталь: конструктор с тремя аргументами (principal, credentials, authorities) создаёт объект, который считается authenticated. Это нам и нужно: не “попытка аутентификации”, а “восстановленная аутентификация”.

И вот теперь мы получили стандартный объект, который “понимает” весь остальной Spring Security. Его смогут использовать:

— request-level правила (.anyRequest().authenticated(), .hasAuthority(...));
— method security (@PreAuthorize("hasAuthority('draft:publish')"));
— точки доступа к текущему пользователю через SecurityContext.

Схема потока: token → Authentication

Сейчас мы сделали важный промежуточный результат: мы умеем взять строку токена и получить из неё Authentication. Это как собрать паспорт и пропуск на проходной: “вот кто я” и “вот куда мне можно”. Но мы ещё не “передали охране эту информацию” — то есть не положили её в SecurityContext.

Чтобы в голове всё было максимально прозрачно, полезно держать схему. Вот как выглядит наш участок пайплайна:

flowchart TD
    A["HTTP request Authorization: Bearer token"] --> B["resolveBearerToken() получили строку token"]
    B --> C["tokenService.parseAndValidate(token) подпись + exp + claims"]
    C --> D["ValidatedToken principal + authorities"]
    D --> E["map -> SimpleGrantedAuthority"]
    E --> F["new UsernamePasswordAuthenticationToken(...) получили Authentication"]

Если вы сможете воспроизвести этот поток на словах, то вы уже, по сути, понимаете stateless-аутентификацию. Осталось встроить этот результат в request lifecycle, чтобы его увидели URL-rules, method security и @AuthenticationPrincipal.

7. Границы ответственности на этапе валидации

На этом этапе очень легко начать “улучшать” решение до состояния “ничего не работает”. Поэтому полезно явно проговорить границы. Проверка JWT и сборка Authentication — это инфраструктурный шаг, который должен быть быстрым, предсказуемым и не содержать бизнес-логики.

Во-первых, мы не должны делать здесь ownership-проверки. “Это его черновик или чужой?” — это вопрос авторизации на уровне бизнес-операции и доменных данных. Токен не обязан знать про конкретный draftId, и фильтр не должен превращаться в мини‑сервис контента.

Во-вторых, мы не должны ходить в базу данных при каждом запросе “чтобы убедиться, что пользователь ещё существует”. Иногда так делают, но это уже отдельная архитектурная ось (и часто это дороже, чем кажется). Здесь мы держим чистую stateless-модель: токен валиден — значит, запрос может считаться аутентифицированным. А дальше уже ваши правила и сервисы решают, можно ли делать конкретную операцию.

В-третьих, мы не должны “раздувать” principal до размеров доменной модели. У principal есть простая роль: быть идентификатором и минимальным представлением пользователя. Если вы положите туда сущность, коллекции ролей, профиль и аватар, вы ухудшите безопасность (лишние данные в памяти и логах) и ухудшите сопровождение.

И наконец, мы не должны смешивать “валидацию токена” и “ответ клиенту”. Если токен плохой, наша задача на этапе валидации — корректно сигнализировать об этом исключением или результатом. Формирование 401 JSON-ответа и единый контракт ошибок должны оставаться отдельной логикой: иначе в фильтре смешаются JSON, логи, бизнес-правила и немного магии.

8. Типичные ошибки при проверке JWT

Ошибка №1: использовать claims до проверки подписи.
Это самая опасная логическая ловушка: “я же вижу username в payload — значит, это он”. На практике payload можно изменить, а проверка подписи как раз и нужна, чтобы payload стал доверенным. Правильная последовательность всегда одна: сначала доверие (подпись/exp), потом извлечение (username/userId/authorities).

Ошибка №2: проверять только структуру токена и забывать про exp.
Новичок иногда аккуратно проверяет header.payload.signature, даже подпись, и думает, что всё хорошо. Но если не проверять срок жизни, токен становится бессрочным пропуском. В учебном проекте это тоже плохая привычка: вы закрепляете у себя ментальную модель “токен вечный”, а потом в реальном проекте вы удивитесь, почему безопасность так себе.

Ошибка №3: строить Authentication “по частям” в разных местах.
Если в фильтре вы сделали extractUsername, а authorities “где‑то потом”, а userId “в другом методе”, вы почти гарантированно получите рассинхрон. Лучший стиль — один метод parseAndValidate(), который возвращает нормализованный ValidatedToken, и уже из него собирается Authentication в одном месте.

Ошибка №4: класть в principal JPA-сущность или целый UserAccount.
Это очень соблазнительно: “ну раз мы всё равно работаем с пользователями, давайте положим UserAccount”. Потом начинаются сюрпризы: ленивые коллекции, лишние поля, случайные сериализации, утечки данных в логах. Principal должен быть компактным и независимым от persistence.

Ошибка №5: путаница hasRole и hasAuthority из-за ROLE_-префикса.
Если вы используете hasRole("ADMIN"), Spring ожидает "ROLE_ADMIN". Если токен несёт "ADMIN", то доступ будет запрещён, и вы получите загадочный 403, хотя “вроде админ”. Чтобы не заниматься шаманством, выберите один стиль именования и придерживайтесь его: либо роли в виде "ROLE_ADMIN", либо чистые authorities и .hasAuthority(...).

1
Задача
Spring Security, 24 уровень, 2 лекция
Недоступна
Сборка `Authentication` из validated token
Сборка `Authentication` из validated token
1
Задача
Spring Security, 24 уровень, 2 лекция
Недоступна
Преобразование claims в `UsernamePasswordAuthenticationToken`
Преобразование claims в `UsernamePasswordAuthenticationToken`
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ