JavaRush /Курсы /Spring Security /Ошибки токена и request flow API

Ошибки токена и request flow API

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

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‑ветке — это маленькая цена за большую предсказуемость.

1
Задача
Spring Security, 24 уровень, 4 лекция
Недоступна
JSON `401` для `expired` и `malformed` token
JSON `401` для `expired` и `malformed` token
1
Задача
Spring Security, 24 уровень, 4 лекция
Недоступна
Полный request flow от login до `401` и `403`
Полный request flow от login до `401` и `403`
1
Опрос
JWT Аутентификация, 24 уровень, 4 лекция
Недоступен
JWT Аутентификация
Безсессионный доступ по токену
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ