1. Роль обработки ошибок токена
Если честно, ошибок токена боятся не потому, что они сложные, а потому, что они стыдные: ты такой уверенно делаешь «у нас JWT, мы современные», а клиент получает 500 и странный stack trace. В этот момент «современность» быстро превращается в «а давайте выключим фильтр, чтобы работало». Чтобы такого не было, нужно заранее решить: какие типы поломок токена бывают, кто их ловит, какой статус возвращаем, и как это вписывается в единый JSON error contract нашего API.
Важная мысль: когда токен сломан или просрочен, это не business‑ошибка и не «ошибка контроллера». Это сбой на security‑границе. Значит, ошибка должна появиться до контроллера, в filter chain, и быть оформлена так, чтобы клиент мог однозначно понять: «я не аутентифицирован» — это 401, а не «мне запрещено» — это 403.
Четыре состояния запроса: no token, bad token, good token, not enough rights
Чтобы не путаться, полезно держать в голове не десять разных «вариантов JWT‑болей», а всего четыре состояния запроса. Их достаточно, чтобы объяснить почти любое поведение системы и не превращать отладку в шаманство с бубном и логами на DEBUG. Причём эти состояния хорошо ложатся на уже знакомую вам границу: authentication отвечает за «кто ты?», authorization отвечает за «что тебе можно?».
Ниже — таблица, которая будет вашим мини-навигатором по всей stateless‑ветке проекта:
| Состояние запроса | Что мы видим на входе | Что это значит | Должно закончиться чем | Где «рождается» ответ |
|---|---|---|---|---|
| missing token | Заголовка Authorization нет, или он не Bearer ... | Клиент не пытался аутентифицироваться в этом запросе | Для public endpoint — 200. Для protected endpoint — 401 | Часто «сам» Spring Security (через AuthenticationEntryPoint), а фильтр просто пропускает запрос |
| malformed / invalid token | Заголовок Bearer ... есть, но токен не парсится / подпись не совпадает | Клиент пытался, но credentials невалидны | 401 в JSON‑контракте | Наш JWT‑фильтр, потому что дальше идти бессмысленно |
| expired token | Токен корректный по формату/подписи, но exp уже в прошлом | Клиент пытался, но срок жизни закончился | 401 в JSON‑контракте | Наш JWT‑фильтр, если мы решили закрывать невалидный Bearer прямо на security‑границе |
| valid token, but forbidden | Токен валиден, Authentication восстановлен, но прав не хватает | Пользователь есть, но ему нельзя | 403 в JSON‑контракте | Это уже зона authorization: request‑rules / method security + AccessDeniedHandler |
Чтобы визуально закрепить, можно представить это как мини‑блок‑схему. Она простая, как if-else, но именно простота здесь спасает нервы:
flowchart TD
R[HTTP request] --> H{"Authorization: Bearer ... ?"}
H -->|нет| A[Остаёмся anonymous
идём дальше по chain]
H -->|да| V{"Token валиден?"}
V -->|нет / expired| U[Вернуть 401 JSON
и закончить запрос]
V -->|да| C[Собрать Authentication
положить в SecurityContext]
C --> Z["Идём дальше: request rules + @PreAuthorize"]
Z -->|прав хватает| OK[Контроллер/сервис → 200/2xx]
Z -->|прав не хватает| F[403 JSON]
3. Границы 401 и 403
Одна из самых частых новичковых ошибок в JWT‑проектах звучит так: «Если доступ запрещён — значит 403». Проблема в том, что 403 — это уже второй акт пьесы. В первом акте мы должны понять, есть ли вообще аутентификация. Если аутентификации нет, или она не прошла (сломанный/просроченный токен), мы ещё даже не дошли до разговора о правах — это 401. И только если пользователь успешно восстановлен, тогда можно обсуждать права — и это 403.
В нашем проекте Secure Content Platform API это особенно важно, потому что зоны доступа разные. GET /api/public/articles должен нормально работать для anonymous. GET /api/me должен требовать аутентификацию. POST /api/editor/.../publish должен требовать authority вроде draft:publish. И если вы начнёте отвечать 403 на всё подряд, клиенту будет невозможно понять: «мне нужно перелогиниться и получить новый токен» или «я логиниться могу, но я не редактор».
С практической точки зрения это означает следующее. JWT‑фильтр не должен решать «можно ли публиковать черновик». Он должен решить только «есть ли на этом запросе валидный пользователь». А вот правила доступа (request‑level и method‑level) уже решают «можно ли». Именно поэтому мы в прошлых лекциях собирали Authentication и SecurityContext: чтобы authorization‑часть работала на стандартных механизмах Spring Security, а не на самописных if внутри фильтра.
4. Категории проблем токена
Если мы хотим аккуратно обрабатывать expired и malformed, нам нужно иметь способ различать причины. Да, можно ловить «какое‑то исключение JWT‑библиотеки», но тогда код быстро превращается в «угадай класс исключения по исходникам». Здесь лучше сделать тонкий слой абстракции: наше исключение TokenValidationException + причина (enum). Это как завести нормальные названия переменных вместо a1, a2: оно не делает вас умнее, но делает код терпимым к жизни.
Начнём с enum причин — коротко и читаемо:
package com.example.securecontent.security.jwt;
public enum TokenProblem {
// Токен отсутствует (например, заголовка Authorization нет или он не Bearer)
MISSING,
// Токен есть, но он битый/подделанный/не парсится/подпись не сходится
MALFORMED,
// Токен валиден по формату и подписи, но срок жизни истёк
EXPIRED
}
Теперь исключение, которое будет бросать TokenService (или helper вокруг него). Обратите внимание: мы не обязаны прятать исходную причину навсегда, но наружу (в HTTP) мы её показывать не будем:
package com.example.securecontent.security.jwt;
public class TokenValidationException extends RuntimeException {
// Категория проблемы — её удобно маппить на code/message в HTTP-ответе
private final TokenProblem problem;
public TokenValidationException(TokenProblem problem, String message) {
super(message);
this.problem = problem;
}
// Явно отдаём тип проблемы: фильтр решит, что именно ответить клиенту
public TokenProblem problem() {
return problem;
}
}
На практике MISSING чаще закрывается ещё до TokenService, когда resolveBearerToken(...) вернул null. Но держать одну терминологию всё равно удобно: так и фильтр, и логи, и ответы клиенту говорят об одних и тех же состояниях.
И вот тут важно не переусложнить. В учебном проекте нам достаточно этих трёх категорий. «Подпись не та», «алгоритм не тот», «плейлоад не JSON» — для клиента и для нашей API‑семантики это всё одна и та же группа: токен нельзя использовать, значит будет 401.
5. Возврат 401 в JSON из фильтра
Когда токен сломан, у фильтра есть выбор. Можно пропустить запрос дальше с пустым контекстом и надеяться, что на .authenticated() Spring Security вернёт 401. Это рабочий подход, но он теряет важную вещь: если клиент прислал именно Bearer‑заголовок, он пытался аутентифицироваться, и мы хотим ответить управляемо, а не «как получится». Поэтому здесь полезно сделать поведение явным: если заголовок Bearer есть, но токен невалиден или просрочен, мы закрываем запрос прямо в фильтре, возвращая единый JSON 401.
Чтобы не лепить JSON строкой через конкатенацию (это весело ровно до первой двойной кавычки в сообщении), заведём маленький DTO для ответа. Пусть будет совместим с нашим общим error‑стилем: code, message, плюс полезные поля path и timestamp.
package com.example.securecontent.common.error;
import java.time.Instant;
public record ApiErrorResponse(
// Машиночитаемый код ошибки (по нему удобно писать обработку на клиенте)
String code,
// Человеко-читаемое сообщение (короткое и безопасное для наружного мира)
String message,
// Путь запроса, на котором произошла ошибка
String path,
// Время формирования ответа (полезно для корреляции с логами)
Instant timestamp
) {
}
Теперь в фильтре нам нужна маленькая функция, которая пишет ответ. В идеале это отдельный компонент, но даже в компактной версии лучше держать эту логику отдельно и прозрачно. Используем ObjectMapper — он уже есть в Spring Boot как bean.
import com.example.securecontent.common.error.ApiErrorResponse;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import java.io.IOException;
private void writeJson(HttpServletResponse response, ApiErrorResponse body) throws IOException {
// Всегда отвечаем 401, потому что это сбой аутентификации (а не бизнес-логики)
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
// В REST API отдаём JSON, а не HTML и не редирект на login page
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
// objectMapper — это бин Spring, сериализует DTO в JSON и пишет в output stream
objectMapper.writeValue(response.getOutputStream(), body);
}
Обратите внимание на детали: мы не делаем println(), не печатаем stack trace, не отдаём HTML, не редиректим на login page (это REST API, а не браузерный flow), и не пишем в ответ «signature mismatch at byte 17» — клиенту это не нужно, а злоумышленнику может быть приятно.
Теперь — сам центр лекции: «как собрать happy path и failure path в одном фильтре». Пусть у нас уже есть методы resolveBearerToken(...), authenticateAndSetContext(...) и setAuthentication(...), которые вы писали раньше. Мы добавим try/catch и возврат 401.
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.context.SecurityContextHolder;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws java.io.IOException, jakarta.servlet.ServletException {
// 1) Пытаемся достать Bearer-токен из заголовка Authorization
String token = resolveBearerToken(request);
// 2) Если токена нет — это normal flow (public endpoints / anonymous), просто идём дальше
if (token == null) {
chain.doFilter(request, response);
return;
}
try {
// 3) Если токен есть — валидируем и восстанавливаем Authentication в SecurityContext
authenticateAndSetContext(token);
// 4) Только после успешной аутентификации отдаём управление дальше по цепочке фильтров
chain.doFilter(request, response);
} catch (TokenValidationException ex) {
// 5) На любой проблеме токена чистим контекст: ThreadLocal не должен оставаться "грязным"
SecurityContextHolder.clearContext();
// 6) И возвращаем единый JSON-ответ 401, не давая запросу доползти до authorization
writeJson(response, toUnauthorizedError(request, ex));
}
}
Здесь есть две важные «мелочи», которые на практике решают половину загадочных багов. Первая — return после chain.doFilter(...) в ветке token == null. Если забыть, вы попадёте в try/catch и начнёте «аутентифицировать null», а это уже путь к философскому вопросу «как выглядит JWT Шрёдингера». Вторая — SecurityContextHolder.clearContext() в catch: даже если вы уверены, что контекст пустой, лучше очистить явно. ThreadLocal‑контекст — вещь полезная, но когда он «грязный», страдают все.
Осталось сделать toUnauthorizedError(...). Мы специально не раскрываем детали, но можем различать EXPIRED и «остальное», чтобы клиент понял «перелогинься» vs «ты что-то сломал/подменил».
import com.example.securecontent.common.error.ApiErrorResponse;
import java.time.Instant;
private ApiErrorResponse toUnauthorizedError(HttpServletRequest request, TokenValidationException ex) {
// Наружу отдаём безопасные коды: они полезны клиенту и не раскрывают внутренности валидации
String code = (ex.problem() == TokenProblem.EXPIRED) ? "TOKEN_EXPIRED" : "INVALID_TOKEN";
// Сообщение тоже держим коротким: не надо возвращать клиенту технические детали
String message = (ex.problem() == TokenProblem.EXPIRED) ? "Token expired" : "Invalid token";
// path и timestamp помогают и клиенту, и разработчику при отладке
return new ApiErrorResponse(code, message, request.getRequestURI(), Instant.now());
}
Пару слов про логирование, потому что это типичный «самострел». Логировать полностью Authorization header нельзя: это фактически выдача токена в логах. Если вам очень нужно, логируйте только факт события и путь, а максимум — безопасный «кусочек» токена.
private String tokenPreview(String token) {
// Не логируем токен целиком: даже в учебном проекте это вредная привычка
if (token.length() < 12) return "***";
// Показываем только первые символы, чтобы можно было сопоставить события в логах
return token.substring(0, 8) + "...";
}
Эту строку можно использовать в логах, если прям очень хочется, но даже с ней лучше не увлекаться. Токен — это не пароль, но всё ещё секрет, который открывает дверь.
6. Полный request flow: от login до 403 на editor/admin
Сейчас соберём картину целиком, чтобы она не оставалась набором «отдельных умных методов». В нашей ветке проекта логин уже существует: POST /api/auth/login аутентифицирует пользователя через AuthenticationManager и возвращает access token. После этого клиент должен класть этот токен в Authorization на каждом запросе к защищённым endpoint’ам. И уже здесь наш фильтр становится «охранником у турникета».
Ниже — sequence‑diagram без лишней романтики, но с понятной последовательностью событий:
sequenceDiagram
participant Client as API Client
participant Login as /api/auth/login
participant Chain as SecurityFilterChain
participant JwtF as JwtAuthFilter
participant AuthZ as "Authorization (rules + method security)"
participant Api as Controller/Service
Client->>Login: "POST /api/auth/login (username+password)"
Login-->>Client: 200 { accessToken: "..." }
Client->>Chain: GET /api/me + Authorization: Bearer ...
Chain->>JwtF: doFilterInternal()
JwtF->>JwtF: validate token (exp/signature)
JwtF->>JwtF: build Authentication + set SecurityContext
JwtF-->>Chain: continue
Chain->>AuthZ: check authenticated / hasAuthority
AuthZ-->>Api: ok
Api-->>Client: 200 { userId, username }
Client->>Chain: POST /api/editor/drafts/10/publish + Bearer(USER token)
Chain->>JwtF: restore Authentication
Chain->>AuthZ: "hasAuthority('draft:publish')?"
AuthZ-->>Client: 403 JSON (forbidden)
И теперь ключевое отличие, которое вы должны буквально ощущать кожей, как холодный воздух кондиционера в офисе: если токен невалиден/просрочен, запрос не должен «доползти» до проверки прав. Он должен завершиться раньше — 401. А если токен валиден, но прав нет — это уже 403.
Очень полезно проверить это руками на проекте. Например, такой запрос без токена в /api/me должен стать 401:
curl -i http://localhost:8080/api/me
# HTTP/1.1 401
# {"code":"UNAUTHORIZED", ...} (формат зависит от вашего контракта)
А такой запрос с валидным токеном обычного пользователя в editor‑зону должен стать 403:
curl -i -H "Authorization: Bearer <user_token>" \
-X POST http://localhost:8080/api/editor/drafts/10/publish
# HTTP/1.1 403
# {"code":"FORBIDDEN", ...}
Если у вас вдруг получается «всё время 401», значит вы не восстанавливаете Authentication или не кладёте его в SecurityContext. Если у вас «всё время 403», значит вы путаете отсутствие аутентификации с отсутствием прав, или ваш JSON‑обработчик ошибок настроен неверно.
7. Public/login endpoints и shouldNotFilter(...)
Ту же границу ответственности удобно выразить через shouldNotFilter(...). Это делает код doFilterInternal(...) чище: он занимается токеном, а не маршрутизацией.
Для нашего проекта правило обычно простое: не фильтруем public‑зону, auth-endpoints и всё, что вообще не /api/**. Для такого базового scope этого достаточно и не требует сложных matcher‑комбинаций.
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.filter.OncePerRequestFilter;
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
// Чем меньше фильтр знает про маршрутизацию, тем проще его поддерживать и отлаживать
String uri = request.getRequestURI();
// Не трогаем всё вне API, public-зону и auth-endpoints:
// там Bearer-токен либо не нужен, либо ещё не существует
return !uri.startsWith("/api/")
|| uri.startsWith("/api/public/")
|| uri.startsWith("/api/auth/");
}
Почему это важно именно для обработки ошибок? Потому что без такого ограничения вы легко получаете «сам себя съел» сценарий: вы вызываете auth-endpoint, токена ещё нет (его только собираются выдавать), фильтр начинает нервничать, и вы пытаетесь аутентифицировать запрос, который вообще не обязан быть bearer‑запросом. Это похоже на ситуацию «чтобы войти в систему, покажите пропуск; чтобы получить пропуск, войдите в систему». Красиво, но бесполезно.
8. Типичные ошибки при обработке missing/expired/malformed token
Ошибка №1: возвращать 500, когда токен сломан.
Это происходит, когда исключение из TokenService улетает наружу и попадает в общий exception handler как «неожиданная ошибка сервера». Клиент видит 500, думает «сервер упал», а вы начинаете чинить то, что не сломано. Невалидный/просроченный токен — это нормальная клиентская ситуация, она должна превращаться в контролируемый 401 с понятным JSON‑ответом.
Ошибка №2: путать 401 и 403 и делать всё 403.
Если вы отвечаете 403 на отсутствие токена, клиенту невозможно понять, что нужно «войти заново». В JWT‑модели это особенно критично: истёк токен — это 401, прав не хватает — это 403. Когда эти коды смешаны, даже правильный клиент начинает вести себя странно, а у вас появляется «магический баг», который лечится только перезапуском Postman.
Ошибка №3: логировать токен (или весь Authorization header).
Иногда это делают «временно, для отладки», а потом забывают. В итоге токены живут в логах, логи живут в лог‑агрегаторе, к агрегатору имеет доступ половина компании, и внезапно токен уже не секрет. В учебном проекте это тоже важно: привычка «логировать всё подряд» потом очень плохо переносится в реальные системы.
Ошибка №4: пытаться делать авторизацию внутри JWT‑фильтра.
Фильтр должен восстановить пользователя и его authorities, но не решать, «можно ли публиковать черновик» или «это свой draft или чужой». Эти правила уже живут в request‑level конфигурации и в method security. Если вы начнёте внедрять бизнес‑проверки в фильтр, вы получите код, который сложно тестировать, сложно поддерживать и очень легко случайно обойти.
Ошибка №5: не очищать SecurityContext при ошибке и оставлять «грязный контекст».
В теории «всё равно запрос завершится ошибкой», но на практике бывает, что вы частично создали Authentication, где-то выбросили исключение и забыли очистить контекст. Потом какой-нибудь downstream‑код увидит «похоже, пользователь есть» и начнёт вести себя непредсказуемо. Явный SecurityContextHolder.clearContext() в failure‑ветке — это маленькая цена за большую предсказуемость.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ