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

JWT: BearerSecurityContext

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

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 (если есть) и факт ошибки, но не весь токен.

1
Задача
Spring Security, 25 уровень, 1 лекция
Недоступна
Имя текущего пользователя через JwtAuthenticationToken
Имя текущего пользователя через JwtAuthenticationToken
1
Задача
Spring Security, 25 уровень, 1 лекция
Недоступна
Чтение claims через @AuthenticationPrincipal Jwt
Чтение claims через @AuthenticationPrincipal Jwt
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ