1. Два світи: токен і поточний користувач
Коли ви вперше підключаєте вбудовану підтримку JWT, може здаватися, ніби Spring Security робить щось містичне: учора в нас була лише строка в заголовку, а сьогодні в контролері раптом доступні JwtAuthenticationToken, @AuthenticationPrincipal Jwt, hasAuthority(...) починає працювати… і все це без вашого фільтра. Щоб не сприймати це як магію, корисно розділити два світи: світ HTTP-запиту і світ Spring Security всередині застосунку.
В HTTP-світі в нас є заголовки, метод, шлях і тіло. Там токен — просто рядок після слова Bearer. У світі Spring Security токен перетворюється на конкретний об’єкт типу Authentication, який «живе» в SecurityContext рівно стільки, скільки триває обробка запиту. Вбудований JWT-шлях — це акуратний конвеєр, який займається саме перетворенням з одного світу в інший: «рядок із заголовка» → «перевірений токен» → «аутентифікований користувач для поточного запиту».
Щоб побачити цей конвеєр цілком, тримайте в голові таку схему:
flowchart TD R[HTTP-запит] --> 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["Контролер / сервіс / @PreAuthorize"]
Ліворуч — «сирий запит», праворуч — «поточний користувач у застосунку». Уся наша лекція — це подорож токена цим маршрутом.
2. Bearer-токен і межі MVC
Якщо ви звикли до «звичайного» застосунку, рука іноді тягнеться зробити в контролері request.getHeader("Authorization") і далі розбирати рядок регулярними виразами. У навчальних проєктах це часто виглядає як «та й працює ж». Але у світі Spring Security це саме той випадок, коли «працює» не означає «правильно»: ви виносите security-логіку на рівень MVC, ламаєте єдиний механізм помилок 401/403 і перетворюєте безпеку на набір випадкових рішень по контролерах.
Правильна ідея така: контролери та сервіси мають працювати не з заголовком, а з результатом аутентифікації. Заголовок — це вхідні дані для рівня безпеки. І саме тому вбудований шлях починає працювати ще до потрапляння в MVC, усередині фільтрів.
Щоб було зовсім прикладно, ось як виглядає запит «на землі»:
# -i: показати заголовки відповіді (зручно для перегляду 401/403)
curl -i \
# Передаємо Bearer-токен у стандартному заголовку Authorization
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \
# Захищена кінцева точка (якщо потрібен authenticated(), токен має бути в кожному запиті)
http://localhost:8080/api/me
Зверніть увагу на важливу деталь: якщо ви зробили систему stateless, то «увійти один раз, а потім ходити без токена» уже не вийде. Браузерна магія cookies/сесій тут не допоможе. Кожен запит, який вимагає authenticated(), має приносити Bearer-токен — інакше ніякого поточного користувача не буде.
3. BearerTokenAuthenticationFilter: вилучення токена
Вбудований JWT-шлях починається з фільтра з дуже промовистою назвою: 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() або кінцева точка публічна
chain.doFilter(request, response);
Тут важливо розуміти поведінку без токена. Якщо в запиті немає заголовка Authorization або там немає Bearer ..., фільтр не зобов’язаний падати. Він просто пропускає запит далі. І вже потім authorizeHttpRequests вирішить, що робити: якщо кінцева точка публічна — добре; якщо вимагає authenticated() — ви отримаєте 401.
Ще одна важлива деталь: фільтр не зобов’язаний «вгадувати», звідки брати токен. За це відповідає BearerTokenResolver (зазвичай за замовчуванням він читає заголовок Authorization). І це якраз те місце, де Spring Security каже: «давайте не змушувати кожного писати однаковий код вручну».
4. AuthenticationManager і JwtAuthenticationProvider
На цьому етапі корисно згадати стару мантру курсу: фільтр не перевіряє облікові дані сам. Він тільки «оформлює заявку» на аутентифікацію і віддає її в AuthenticationManager. Для username/password у нас був DaoAuthenticationProvider. Для JWT-моделі є свій провайдер: JwtAuthenticationProvider.
З погляду мисленнєвої моделі все дуже схоже:
- фільтр формує «вхідний Authentication» (ще не довірений),
- AuthenticationManager вибирає провайдера, який розуміє цей тип Authentication,
- провайдер повертає «вихідний Authentication» (уже аутентифікований).
У JWT-світі «вхідний» об’єкт зазвичай несе сирий рядок токена, а «вихідний» стає JwtAuthenticationToken.
Корисно зафіксувати це маленькою таблицею, щоб не плутатися:
| Етап | Що це за об’єкт | Чи довіряємо ми йому | Навіщо він потрібен |
|---|---|---|---|
| До аутентифікації | BearerTokenAuthenticationToken | Ні | Донести токен до AuthenticationManager |
| Після декодування | Jwt | Так (якщо підпис і строк дії валідні) | Зберігати claims у зручному вигляді |
| Підсумок | JwtAuthenticationToken | Так | Дати застосунку поточного користувача та authorities |
Важливо: саме JwtAuthenticationToken і буде лежати в SecurityContext. І саме його бачитимуть @PreAuthorize, hasAuthority(...), Authentication auth у контролері та будь-які інші security-механізми.
5. JwtDecoder: довіра до токена
До JwtDecoder токен — це просто рядок. Ви можете його прочитати, передати далі або навіть розпарсити частини Base64, але довіряти вмісту не можна. JwtDecoder робить дві ключові речі: перетворює JWT на об’єкт Jwt із claims і перевіряє, що цьому токену взагалі можна вірити — підпис збігається, строк дії не минув, формат не зламаний.
Тут важлива тільки межа відповідальності: decoder відповідає за довіру до токена, а не за права доступу. Якщо підпис, строк або формат не проходять перевірку, запит не дійде до контролера і не перетвориться на поточного користувача.
Окрема практична деталь: якщо кінцева точка, що випускає токен, підписує його одним ключем, а JwtDecoder налаштований на інший, ви будете отримувати вічне «токен недійсний» і 401. Це не «Spring Security вередує», це він чесно каже: «підпис не збігається — я не вірю».
6. JwtAuthenticationConverter: користувач і права
Після 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 не зберігається між запитами».
У вбудованому JWT-шляху SecurityContext створюється й заповнюється на час обробки конкретного запиту, зазвичай зберігається в thread-local через SecurityContextHolder, і після формування відповіді очищається. Це важливо не лише як теорія: це безпосередньо пояснює, чому токен треба передавати в кожному запиті і чому «випадково десь збережений SecurityContext» — це баг і потенційна вразливість.
Цю механіку зручно уявити так:
sequenceDiagram participant C as Клієнт participant FS as "Ланцюг фільтрів безпеки" participant SC as SecurityContextHolder participant MVC as "Контролер/сервіс" C->>FS: "Запит (+ Bearer token)" FS->>SC: setAuthentication(JwtAuthenticationToken) FS->>MVC: виклик контролера/сервісу MVC->>SC: читати поточного користувача / authorities MVC-->>FS: повернення відповіді FS->>SC: clearContext() FS-->>C: Відповідь
Сенс у тому, що SecurityContext — це «контекст поточної обробки», а не «пам’ять про користувача назавжди». Пам’ять у stateless-моделі живе в токені на стороні клієнта, а не в серверній сесії.
8. Доступ до current user у коді
Коли security-ланцюжок відпрацював, контролери та сервіси отримують уже готову картину світу: «ось поточний користувач, ось його права». І тут у нас з’являється приємний бонус вбудованого шляху: ви можете отримувати не лише абстрактний 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, насамперед дивляться не в контролер і не в заголовок, а в те, як claims перетворилися на authorities всередині ланцюжка аутентифікації.
9. Типові помилки під час роботи з вбудованим JWT
Помилка № 1: читати заголовок Authorization у контролері й вручну розбирати токен.
Це швидко здається «простим рішенням», але насправді ви викидаєте з процесу Spring Security: помилки перестають бути єдинообразними, @PreAuthorize не бачить користувача, а частина кінцевих точок живе за одними правилами, частина — за іншими. Вбудований шлях хороший саме тим, що переносить розбір і перевірку в рівень безпеки.
Помилка № 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-токен — з погляду сервера ви знову анонімний. Це не «баг» і не «Spring Security забув користувача», це і є сенс stateless.
Помилка № 5: логувати Bearer-токен цілком «для налагодження».
Бажання зрозуміле: «я ж просто подивлюся». Але Bearer-токен — це, по суті, ключ від дверей: хто вкрав рядок, той і зайшов. У логах, особливо в командних і CI-середовищах, такі рядки дуже люблять «випадково» витікати. Для налагодження зазвичай достатньо логувати sub, jti (якщо є) і факт помилки, але не весь токен.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ