JavaRush /Курси /Spring Security /JWT:

JWT: BearerSecurityContext

Spring Security
Рівень 25 , Лекція 1
Відкрита

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 (якщо є) і факт помилки, але не весь токен.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ