1. Два мира: токен и current user
Когда вы впервые подключаете built-in JWT поддержку, возникает ощущение, что Spring Security делает что-то мистическое: вчера у нас была просто строка в заголовке, а сегодня в контроллере внезапно доступен JwtAuthenticationToken, @AuthenticationPrincipal Jwt, hasAuthority(...) начинает работать… и всё это без вашего фильтра. Чтобы не воспринимать это как магию, полезно разделить два мира: мир HTTP-запроса и мир Spring Security внутри приложения.
В HTTP-мире у нас есть заголовки, метод, path, тело. Там токен — просто строка после слова Bearer. В мире Spring Security токен превращается в конкретный объект типа Authentication, который “живёт” в SecurityContext ровно на время обработки запроса. Встроенный JWT path — это аккуратный конвейер, который занимается именно переводом из одного мира в другой: “строка из заголовка” → “проверенный токен” → “аутентифицированный пользователь для текущего запроса”.
Чтобы увидеть этот конвейер целиком, держите в голове такую схему:
flowchart TD R[HTTP request] --> H["Authorization: Bearer ..."] H --> F[BearerTokenAuthenticationFilter] F --> AM[AuthenticationManager] AM --> P[JwtAuthenticationProvider] P --> D[JwtDecoder] D --> J[Jwt] J --> C[JwtAuthenticationConverter] C --> AT[JwtAuthenticationToken] AT --> SC[SecurityContext] SC --> MVC["Controller / Service / @PreAuthorize"]
Слева — “сырой запрос”, справа — “current user внутри приложения”. Вся наша лекция — это путешествие токена по этому маршруту.
2. Bearer-токен и границы MVC
Если вы привыкли к “обычному” приложению, рука иногда тянется сделать в контроллере request.getHeader("Authorization") и дальше разбирать строку регулярками. В учебных проектах это часто выглядит как “ну работает же”. Но в мире Spring Security это ровно тот случай, когда “работает” не значит “правильно”: вы выносите security-логику на уровень MVC, ломаете единый механизм ошибок 401/403 и превращаете безопасность в набор случайных решений по контроллерам.
Правильная идея такая: контроллеры и сервисы должны работать не с заголовком, а с результатом аутентификации. Заголовок — это входные данные для security layer. И именно поэтому built-in путь начинает работать ещё до попадания в MVC, внутри фильтров.
Чтобы было совсем прикладно, вот как выглядит запрос “на земле”:
# -i: показать заголовки ответа (удобно для просмотра 401/403)
curl -i \
# Передаём Bearer токен в стандартном заголовке Authorization
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \
# Защищённый endpoint (если требуется authenticated(), токен должен быть в каждом запросе)
http://localhost:8080/api/me
Обратите внимание на важную деталь: если вы сделали систему stateless, то “войти один раз, а потом ходить без токена” уже не получится. Браузерная магия cookies/сессий здесь не помогает. Каждый запрос, который требует authenticated(), должен приносить Bearer токен — иначе никакого current user не будет.
3. BearerTokenAuthenticationFilter: извлечение токена
Встроенный JWT path начинается с фильтра с очень говорящим названием: BearerTokenAuthenticationFilter. Он делает ровно две вещи, и именно этим он прекрасен: он извлекает токен из запроса и запускает аутентификацию, не пытаясь быть “всем в одном”.
На уровне логики это выглядит примерно так (упрощённый псевдокод, чтобы увидеть мысль, а не спорить о деталях API):
// 1) Достаём токен из запроса (обычно из Authorization: Bearer ...)
String token = bearerTokenResolver.resolve(request);
if (token != null) {
// 2) "Упаковываем" сырой токен в Authentication-заявку (пока ещё НЕ доверенную)
Authentication authRequest = new BearerTokenAuthenticationToken(token);
// 3) Отдаём заявку в AuthenticationManager: он найдёт подходящий Provider и проверит токен
Authentication authResult = authenticationManager.authenticate(authRequest);
// 4) Кладём результат (уже аутентифицированный Authentication) в SecurityContext текущего запроса
SecurityContextHolder.getContext().setAuthentication(authResult);
}
// 5) Всегда продолжаем цепочку фильтров: дальше решится, нужен ли authenticated() или endpoint публичный
chain.doFilter(request, response);
Здесь важно понимать “поведение без токена”. Если в запросе нет заголовка Authorization или там нет Bearer ..., фильтр не обязан падать. Он просто пропускает запрос дальше. И дальше уже authorizeHttpRequests решит, что делать: если endpoint публичный — окей; если требует authenticated() — вы получите 401.
Ещё одна важная деталь в том, что фильтр не обязан “угадывать”, откуда брать токен. За это отвечает BearerTokenResolver (обычно по умолчанию он читает заголовок Authorization). И это как раз то место, где Spring Security говорит: “давайте не будем заставлять каждого писать одинаковый код руками”.
4. AuthenticationManager и JwtAuthenticationProvider
На этом этапе полезно вспомнить старую мантру курса: фильтр не проверяет учётные данные сам. Он только “оформляет заявку” на аутентификацию и отдаёт её в AuthenticationManager. Для username/password у нас был DaoAuthenticationProvider. Для JWT-модели есть свой провайдер: JwtAuthenticationProvider.
С точки зрения mental model всё очень похоже:
- фильтр формирует “входной Authentication” (ещё не доверенный),
- AuthenticationManager выбирает провайдера, который понимает этот тип Authentication,
- провайдер возвращает “выходной Authentication” (уже аутентифицированный).
В JWT мире “входной” объект обычно несёт сырую строку токена, а “выходной” становится JwtAuthenticationToken.
Полезно зафиксировать это маленькой табличкой, чтобы не путаться:
| Стадия | Что это за объект | Доверяем ли мы ему | Зачем он нужен |
|---|---|---|---|
| До аутентификации | BearerTokenAuthenticationToken | Нет | Донести токен до AuthenticationManager |
| После декодирования | Jwt | Да (если подпись/срок валидны) | Хранить claims в удобном виде |
| Итог | JwtAuthenticationToken | Да | Дать приложению current user + authorities |
Важно: именно JwtAuthenticationToken и будет лежать в SecurityContext. И именно его будут видеть @PreAuthorize, hasAuthority(...), Authentication auth в контроллере и любые другие security-механики.
5. JwtDecoder: доверие к токену
До JwtDecoder токен — это просто строка. Вы можете её прочитать, передать дальше или даже распарсить base64-части, но доверять содержимому нельзя. JwtDecoder делает две ключевые вещи: превращает JWT в объект Jwt с claims и проверяет, что этому токену вообще можно верить — подпись сходится, срок жизни не истёк, формат не сломан.
Здесь важна только граница ответственности: decoder отвечает за доверие к токену, а не за права доступа. Если подпись, срок или формат не проходят проверку, запрос не дойдёт до контроллера и не превратится в current user.
Отдельный житейский момент: если login endpoint подписывает токен одним ключом, а JwtDecoder настроен на другой, вы будете получать вечное “невалидный токен” и 401. Это не “Spring Security вредничает”, это он честно говорит: “подпись не сходится — я не верю”.
6. JwtAuthenticationConverter: user и права
После JwtDecoder у нас есть объект Jwt — декодированный и проверенный. Но приложению обычно нужен не просто набор claims, а понятная вещь: Authentication, у которого есть name и authorities. Этим занимается JwtAuthenticationConverter.
Он берёт Jwt, превращает claims в набор GrantedAuthority и собирает итоговый JwtAuthenticationToken. Здесь важно не смешать две сущности: Jwt — это содержимое токена, JwtAuthenticationToken — это аутентификация, с которой потом работают @PreAuthorize, hasAuthority(...), Authentication auth в контроллере и другие security-механики.
Пока достаточно удержать одну границу: decoder отвечает за доверие к токену, converter — за то, как из claims получается пользователь с набором прав. Поэтому ситуация “токен валидный, но всё равно 403” чаще всего относится уже к mapping claims → authorities, а не к проверке подписи.
7. SecurityContext в stateless
Очень частая “фантомная боль” в JWT мире звучит так: “Я же вошёл! Почему следующий запрос уже не помнит меня?” В session-модели ответ был: “потому что у тебя есть JSESSIONID, и сервер помнит состояние”. В stateless-модели ответ другой: “потому что SecurityContext не сохраняется между запросами”.
В built-in JWT path SecurityContext создаётся/заполняется на время обработки конкретного запроса, обычно хранится в thread-local через SecurityContextHolder, и после формирования ответа очищается. Это важно не только как теория: это напрямую объясняет, почему токен нужно передавать в каждом запросе и почему “случайно где-то сохранившийся SecurityContext” — это баг и потенциальная уязвимость.
Эту механику удобно представить так:
sequenceDiagram participant C as Client participant FS as "Security Filter Chain" participant SC as SecurityContextHolder participant MVC as "Controller/Service" C->>FS: "Request (+ Bearer token)" FS->>SC: setAuthentication(JwtAuthenticationToken) FS->>MVC: call controller/service MVC->>SC: read current user / authorities MVC-->>FS: return response FS->>SC: clearContext() FS-->>C: Response
Смысл в том, что SecurityContext — это “контекст текущей обработки”, а не “память о пользователе навсегда”. Память в stateless-модели живёт в токене на стороне клиента, а не в серверной сессии.
8. Доступ к current user в коде
Когда security-цепочка отработала, контроллеры и сервисы получают уже готовую картину мира: “вот текущий пользователь, вот его права”. И тут у нас появляется приятный бонус built-in пути: вы можете получать не только абстрактный Authentication, но и конкретные JWT-объекты без ручного разбора заголовков.
Самый “прозрачный” способ — принять в метод JwtAuthenticationToken:
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class MeNameController {
@GetMapping("/api/me/name")
String name(JwtAuthenticationToken auth) {
// auth — это уже "итоговый" Authentication, который положили в SecurityContext
// getName() часто (но не всегда) соответствует subject (sub)
return auth.getName();
}
}
Если вам нужны именно claims токена, удобно получить Jwt как principal:
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
class MeSubController {
@GetMapping("/api/me/sub")
String sub(@AuthenticationPrincipal Jwt jwt) {
// jwt — это декодированный и валидированный токен со всеми claims
return jwt.getSubject(); // например "alice"
}
}
Authorities живут внутри JwtAuthenticationToken, и именно их потом проверяют hasAuthority(...), hasRole(...) и @PreAuthorize. Если запрос приходит с валидным токеном, но утыкается в 403, первым делом смотрят не в контроллер и не в header, а в то, как claims превратились в authorities внутри authentication-цепочки.
9. Типичные ошибки при работе с built-in JWT
Ошибка №1: читать заголовок Authorization в контроллере и парсить токен вручную.
Это быстро кажется “простым решением”, но на деле вы выбрасываете из процесса Spring Security: ошибки перестают быть единообразными, @PreAuthorize не видит пользователя, а часть endpoint’ов живёт по одним правилам, часть — по другим. Встроенный путь хорош именно тем, что переносит parsing/validation в security layer.
Ошибка №2: думать, что JwtDecoder “назначает права”, и удивляться 403.
JwtDecoder — это про доверие к токену (подпись, срок жизни) и получение claims. Authorities появляются позже, на этапе JwtAuthenticationConverter. Поэтому ситуация “токен валидный, но не хватает прав” почти всегда относится к converter’у и mapping’у claims, а не к decoder’у.
Ошибка №3: путать Jwt и JwtAuthenticationToken.
Jwt — это “данные токена”, а JwtAuthenticationToken — это итоговый Authentication, с которым работает Spring Security. Если вы в одном месте ожидаете Jwt, а в другом используете Authentication, но логически считаете их “одним и тем же”, начинаются странные касты и непредсказуемые ClassCastException.
Ошибка №4: ожидать, что после одного успешного запроса можно делать следующий без токена.
В stateless-модели SecurityContext живёт только во время обработки одного запроса и очищается после ответа. Если следующий запрос не принес Bearer токен — с точки зрения сервера вы снова anonymous. Это не “баг” и не “Spring Security забыл пользователя”, это и есть смысл stateless.
Ошибка №5: логировать Bearer токен целиком “для отладки”.
Желание понятное: “я же просто посмотрю”. Но Bearer токен — это по сути ключ от двери: кто украл строку, тот и вошёл. В логах, особенно в командных/CI окружениях, такие строки очень любят “случайно” утекать. Для отладки обычно достаточно логировать sub, jti (если есть) и факт ошибки, но не весь токен.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ