1. Роль JwtDecoder и явная настройка
Если вы впервые видите слово JwtDecoder, очень легко представить себе «ещё один магический бин, который надо где-то включить, чтобы оно перестало ругаться». Но в реальности здесь всё гораздо проще и даже приятно: JwtDecoder — это конкретная точка, где мы честно говорим приложению, по каким правилам оно должно доверять входящему токену. Явная настройка нужна, чтобы не “искать ключ по всему проекту”, не гадать, почему токен то принимается, то нет, и не превращать безопасность в лотерею.
Ключевая проблема built-in JWT пути в том, что он не может угадывать вашу криптографию и ваш token contract. Spring Security готов выполнить всю инфраструктурную работу, но ему нужно знать две вещи. Во‑первых, чем проверить подпись (секрет/публичный ключ). Во‑вторых, какие базовые проверки считать обязательными (например, срок действия exp). Поэтому JwtDecoder — это буквально «встроенный нотариус»: он берёт строку токена, проверяет подпись, проверяет минимальные условия валидности и возвращает нормальный объект Jwt, которому уже можно доверять дальше по цепочке.
Важно не путать JwtDecoder с тем, что мы делали при выдаче токена. В нашем проекте токен выдаёт TokenService, а JwtDecoder занимается только входящими запросами. Это удобно держать в голове как разделение труда: один компонент «печатает пропуска» (token issuing), другой — «проверяет пропуска на входе» (token validation).
Ниже — маленькая табличка, которая обычно моментально лечит головную боль “а где вообще JWT живёт в приложении”:
| Зона | Компонент | Что делает | Что НЕ делает |
|---|---|---|---|
| Выдача токена | TokenService | Собирает claims, подписывает JWT, возвращает строку клиенту | Не проверяет входящие токены |
| Проверка токена | JwtDecoder | Проверяет подпись и базовую валидность, возвращает Jwt | Не логинит пользователя, не назначает authorities |
| Authorization | rules / method security | Решает «можно/нельзя» на основе Authentication и authorities | Не проверяет подпись токена |
2. Ментальная модель JwtDecoder в цепочке
Перед тем как писать код, полезно на минуту остановиться и «положить в голову картинку». Spring Security в servlet/MVC приложении мыслит через один базовый сюжет: запрос приходит, фильтры и провайдеры пытаются собрать Authentication, дальше authorization сравнивает Authentication с правилами. JwtDecoder находится ровно в том месте, где нужно превратить “Bearer строку” в “проверенный JWT-объект”, а затем — в Authentication. То есть JwtDecoder — это не фильтр и не контроллер, а инфраструктурный компонент внутри authentication-части.
Представьте, что в запросе вам прилетела такая строка:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6...
На этом этапе у приложения есть только текст. А нам нужно получить ответы на три скучные, но жизненно важные вопросы. Во‑первых, токен вообще не сломан? Во‑вторых, он подписан тем ключом, которому мы доверяем? В‑третьих, он ещё не протух (не просрочен по exp)? Вот именно эти вопросы и закрывает декодер (и его валидаторы).
Удобно запомнить цепочку в виде схемы. Она грубо упрощённая, но на уровне курса — то, что нужно:
flowchart TD
A[HTTP Request] --> B[BearerTokenAuthenticationFilter]
B --> C[JwtAuthenticationProvider]
C --> D[JwtDecoder]
D --> E[Jwt]
E --> F[JwtAuthenticationConverter]
F --> G[JwtAuthenticationToken]
G --> H[SecurityContext]
И здесь есть важная развилка в мозгу: JwtDecoder возвращает Jwt (данные токена), а не Authentication. То есть он отвечает за проверку и распаковку токена, но не за превращение claims в authorities — это уже задача converter’а (следующая лекция сегодня).
3. Где хранить JwtDecoder и ключ
Сейчас мы будем писать код, и тут новичка обычно поджидает классическая ловушка: «ну я быстро вставлю secret вот сюда… и вот сюда… и вот сюда…». Через два дня получается, что токены подписываются одним ключом, валидируются другим, а поиск ошибки превращается в археологию. Поэтому правильное место для JwtDecoder — это ваш security/config слой, и ключ (или секрет) должен появиться в приложении в единственном виде: как один бин, который используют и TokenService, и JwtDecoder.
В нашем проекте Secure Content Platform API мы придерживаемся структуры пакетов из ТЗ курса, поэтому конфигурация built-in JWT ветки логично живёт здесь:
- com.example.securecontent.security.config — security-конфиги
- (опционально) com.example.securecontent.security.jwt — то, что относится к токенам
Хорошая практика для учебного проекта выглядит так: у вас есть JwtProperties (чтобы прочитать настройки из application.yml), есть SecretKey bean (один раз создаём ключ), а дальше два потребителя этого ключа: сервис выдачи токена и decoder проверки токена.
И да, это тот редкий случай, когда хочется сказать: “не стесняйтесь DI”. Dependency Injection здесь реально спасает от случайных рассинхронизаций. Если ключ создаётся в одном месте, то ломать систему будет сложнее (а это для security — плюс).
4. Секрет из конфигурации → SecretKey
Перед созданием JwtDecoder нам нужен ключ. В нашем учебном варианте мы используем симметричный секрет (HMAC), потому что он проще для понимания: один и тот же секрет и подписывает токен (в TokenService), и проверяет подпись (в JwtDecoder). В более “взрослых” системах часто есть асимметричные ключи (public/private), JWK и вот это всё, но сегодня мы туда не лезем — иначе лекция превращается в сериал на 12 сезонов.
Начнём с настроек. Допустим, в application.yml (или application-local.yml) у нас есть такой ключ:
app:
security:
jwt:
# Секрет берём из переменной окружения, чтобы не хранить реальный ключ в репозитории
# Дефолт нужен только для локального запуска/учебного проекта
secret: ${APP_SECURITY_JWT_SECRET:change-me-change-me-change-me-32-bytes}
Смысл простой: в репозитории мы не держим реальный секрет, а для локального запуска даём дефолтное значение. В идеале дефолтный секрет должен быть достаточно длинным, иначе на HMAC алгоритмах можно получить ошибку на уровне библиотек. Если вы сейчас подумали “а можно короткий, мне же просто учиться?” — можно, но тогда учиться вы будете на ошибках, а не на JWT.
Теперь создадим класс properties. В Spring Boot это удобно делать через @ConfigurationProperties. Я люблю record’и за лаконичность: меньше кода — меньше шансов ошибиться.
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "app.security.jwt")
// Этот record — «контейнер» для настроек JWT из application.yml
public record JwtProperties(String secret) {
// secret — строковое представление секрета (как пришло из конфигурации)
}
Чтобы такой record реально можно было внедрить в конфиг, он должен стать bean’ом. Обычно это решается двумя привычными способами: либо в приложении включён @ConfigurationPropertiesScan, либо класс явно регистрируется через @EnableConfigurationProperties(JwtProperties.class). Нам здесь важен сам факт регистрации: без него JwtProperties не доедет в JwtKeyConfig.
Теперь создадим конфигурацию, которая “вытаскивает” секрет из JwtProperties и делает SecretKey. Тут важно быть последовательным: если вы воспринимаете secret как строку, значит вы и в TokenService, и в decoder должны использовать её одинаково. Самая частая ошибка — один компонент берёт UTF-8 bytes, другой — Base64 decode, и ключи в итоге разные, хотя “на глаз” строка одна и та же.
Простой учебный вариант (секрет как обычная строка):
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
@Configuration
public class JwtKeyConfig {
@Bean
SecretKey jwtSecretKey(JwtProperties props) {
// Важно: фиксируем единый способ превращения строки в байты
// Если тут UTF-8, то и в TokenService должно быть то же самое.
byte[] bytes = props.secret().getBytes(StandardCharsets.UTF_8);
// "HmacSHA256" — имя алгоритма для SecretKeySpec (не путать с JWT "HS256")
return new SecretKeySpec(bytes, "HmacSHA256");
}
}
Если вы используете такой подход, вы должны точно так же использовать jwtSecretKey и при подписании токена. Тогда всё совпадёт, и JwtDecoder сможет проверять подпись.
Отдельный микро‑комментарий про “HmacSHA256”. Это не JWT-алгоритм в терминах HS256 (хотя логически связано), а название алгоритма для SecretKeySpec. На этом месте обычно хочется спросить: “почему так сложно?”. Ответ: потому что мир криптографии живёт в своих исторических стандартах, а мы здесь просто делаем мостик между “секретом” и “объектом ключа”.
5. NimbusJwtDecoder и SecurityFilterChain
Теперь у нас есть ключ, и пора собрать decoder. В Spring Security типовая реализация JwtDecoder — это NimbusJwtDecoder. Она не “магическая”, просто внутри использует Nimbus JOSE + JWT и умеет делать то, что нужно для resource server сценария.
Минимальный JwtDecoder bean выглядит так:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import javax.crypto.SecretKey;
@Configuration
public class JwtDecoderConfig {
@Bean
JwtDecoder jwtDecoder(SecretKey jwtSecretKey) {
// Создаём decoder, который будет проверять подпись входящих токенов этим ключом
return NimbusJwtDecoder.withSecretKey(jwtSecretKey).build();
}
}
На этом этапе decoder уже умеет проверять подпись, и этого достаточно, чтобы цепочка “схватила” токен как JWT. Дальше важный шаг — сделать так, чтобы SecurityFilterChain реально использовал именно этот decoder. Да, иногда Spring Boot способен сам подхватить бин по типу, но в учебном проекте я предпочитаю явность: студент должен глазами видеть, чем именно приложение валидирует токен.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain apiSecurity(HttpSecurity http, JwtDecoder jwtDecoder) throws Exception {
// Явно говорим resource server части: "используй вот этот JwtDecoder"
http.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.decoder(jwtDecoder))
);
return http.build();
}
}
Обратите внимание на одну тонкость: если у вас в проекте ещё жив ваш custom JWT filter из предыдущего дня, его нельзя “оставить на всякий случай”. В итоге вы получите ситуацию “два охранника на входе спорят, чей список гостей правильнее”, и виноват в этой ссоре, как обычно, будет разработчик. На одном наборе endpoint’ов должен быть один активный путь bearer-аутентификации.
Ещё один важный нюанс: stateless. Decoder сам по себе не делает приложение stateless. Stateless — это политика session management и общая модель. Поэтому в реальном конфиге вы обычно видите вместе:
import org.springframework.security.config.http.SessionCreationPolicy;
// ...
// Выключаем сессии: каждый запрос должен аутентифицироваться только токеном
http.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
Но сегодня мы не переобъясняем stateless — мы просто фиксируем, что decoder является частью stateless цепочки, а не отдельной “галочкой”.
6. Как проверить JwtDecoder
После настройки decoder’а очень хочется “убедиться руками”, что он живой. И тут есть важная психологическая ловушка: вы можете получить 401, и вам покажется, что decoder не работает. А на самом деле decoder как раз работает идеально — просто токен не валиден. Поэтому проверка должна быть осмысленной: мы берём токен, выданный нашим POST /api/auth/login, и с ним идём на защищённый endpoint.
Самый простой прикладной тест на уровне проекта — сделать “отладочный” endpoint в зоне /api/me, который возвращает, что именно попало в SecurityContext. Например, на уровне контроллера можно временно принять @AuthenticationPrincipal Jwt (это удобно именно для проверки decoder’а, потому что показывает decoded token, а не сырой header).
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class MeDebugController {
@GetMapping("/api/me/debug/jwt-sub")
public String subject(@AuthenticationPrincipal Jwt jwt) {
// Этот endpoint полезен для отладки: показывает claim "sub" из уже проверенного токена
// В боевом проекте такие ручки обычно убирают или ограничивают доступ.
return jwt.getSubject();
}
}
Если decoder работает, то при запросе с валидным Bearer токеном вы получите subject (sub) из токена. Если token отсутствует — request остановится раньше (обычно 401). Если token просрочен — тоже 401. Если token подписан не тем ключом — снова 401. И это хорошо: ваш контроллер не должен решать вопросы валидности токена.
Теперь о том, что decoder не делает (это важно проговорить, иначе следующая лекция будет болезненнее). Decoder не обязан понимать ваши роли, authorities и ваш бизнес. Он не превращает claims в draft:publish и не добавляет ROLE_ADMIN. Он просто возвращает Jwt, а дальше уже converter строит Authentication. Поэтому типичный симптом “token валидный, но всё равно 403” обычно означает не проблему decoder’а, а проблему mapping’а authorities.
И ещё одна маленькая sanity‑проверка для новичка: decoder не имеет никакого отношения к login endpoint. Если login endpoint работает — это не значит, что decoder настроен. И наоборот: decoder может быть идеальным, даже если ваш login endpoint сломан. Это разные части системы.
7. Типичные ошибки при настройке JwtDecoder
Ошибка №1: токены подписываются одним ключом, а проверяются другим (или тем же, но по-разному закодированным).
Это самая частая история. Вы храните secret как строку, в TokenService делаете getBytes(UTF_8), а в decoder случайно делаете Base64.getDecoder().decode(secret) — и ключи становятся разными. На вид “секрет тот же”, по факту подпись никогда не пройдёт проверку, и вы будете видеть 401 на каждый запрос с Bearer токеном.
Ошибка №2: секрет слишком короткий, и вы получаете странные ошибки “на ровном месте”.
У HMAC есть требования к размеру ключа, и если secret короткий, часть библиотек ругается довольно прямолинейно, а часть — чуть более загадочно. Новичок часто делает вывод “Spring Security сломан”, хотя на самом деле он просто попросил систему подписывать токены ключом уровня “qwerty”. В учебном проекте лучше сразу дать себе привычку: secret должен быть длинным и храниться во внешней конфигурации.
Ошибка №3: JwtDecoder спрятан где-то в случайном helper-классе, и через неделю никто не понимает, чем валидируется токен.
Security-конфигурация должна быть читаемой. Если decoder создаётся в утилите “потому что там уже лежал код”, то вы теряете главную ценность built-in пути: ясность архитектуры. Гораздо лучше держать JwtDecoder в security/config и явно подключать его в DSL через decoder(jwtDecoder).
Ошибка №4: вы включили built-in JWT path, но забыли отключить (или удалить из цепочки) custom JWT filter.
В результате часть запросов проходит через ваш фильтр, часть — через built-in цепочку, и вы получаете странные эффекты: то authorities “двойные”, то SecurityContext затирается, то ошибки выглядят непредсказуемо. На одном активном наборе endpoint’ов должен быть один владелец bearer-аутентификации, иначе проект превращается в комедию положений, где никто не виноват, но всё горит.
Ошибка №5: ожидание, что JwtDecoder решит authorization.
Decoder решает “можно ли доверять токену” и “можно ли из него извлечь claims безопасно”. Он не решает “можно ли этому пользователю публиковать черновик”. Если после подключения decoder’а токен стал приниматься, но вы получаете 403 на editor/admin зонах — это очень часто означает, что claims не превращаются в нужные authorities. И это не баг decoder’а, а следующий шаг: настраиваем claim-to-authority mapping.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ