1. Диагностика: статусы и симптомы
Когда Spring Security «ломается», он почти никогда не ломается красиво. Обычно это выглядит так: “вчера работало”, “сегодня 403”, “почему?” — и дальше идёт хаотичное дёргание рычагов. Чтобы перестать играть в «угадайку», начнём с простого правила: прежде чем трогать код, нужно уметь назвать проблему так, чтобы её понял другой человек (или вы сами через неделю).
Я люблю мыслить в формате маленькой «карточки инцидента» — не ради бюрократии, а ради мозга. В ней должны быть вещи, без которых troubleshooting превращается в магию: какой endpoint, какой HTTP-метод, какой клиент, какой статус, и самое главное — какую модель аутентификации вы ожидали (session? Basic? Bearer/JWT?). Когда вы это фиксируете, половина «странностей» внезапно перестаёт быть странностями.
Вот пример такой карточки в табличном виде:
| Поле | Пример |
|---|---|
| Endpoint | GET /api/me |
| Клиент | Postman |
| Ожидаемая модель | Bearer token (JWT ветка) |
| Что отправили | Authorization отсутствует |
| Факт | 401 |
Видите? Здесь диагноз почти готов ещё до логов: мы ожидали JWT, но вообще не отправили токен. И это не «Spring Security снова бесит», это корректное поведение.
Полезный лайфхак для новичка: если вы не можете за 10 секунд ответить «какой auth source должен заполнить SecurityContext на этом запросе», значит, вы пока диагностируете не проблему, а свою усталость.
401, 403, 404: различаем статусы
Многие начинают debugging с фразы “мне вернуло ошибку доступа”. Это примерно как сказать врачу: “мне плохо”. Да, мы сочувствуем, но лечить по этому описанию можно только объятиями. Поэтому в security world мы начинаем с разведения кодов статуса: 401 и 403 — это разные истории. А иногда в security troubleshooting неожиданно появляется и 404, и он тоже может быть «про безопасность», а не про отсутствие контроллера.
Чтобы быстро ориентироваться, держите в голове такую таблицу:
| Код | На человеческом | Где обычно искать причину |
|---|---|---|
| 401 | “Кто ты? Я тебя не узнаю / ты не подтвердил личность” | Аутентификация: session/Basic/JWT/логин |
| 403 | “Я тебя узнаю, но тебе нельзя” | Авторизация: роли, authorities, owner-check, CSRF |
| 404 | “Я не знаю такой endpoint” | Иногда реально 404, а иногда chain/matcher/securityMatcher |
Про 404 важная тонкость: если вы играете с securityMatcher(...) и несколькими цепочками, вы можете случайно добиться ситуации, когда endpoint, который обычно “даёт” сам фильтр (например /login в formLogin-модели), вообще не существует, потому что запрос не матчитcя ни в один SecurityFilterChain. Spring Security прямо предупреждает, что endpoints, предоставляемые фильтрами, зависят от того, матчится ли запрос выбранным securityMatcher.
И теперь ключевой разворот для диагностики: пока вы не ответили себе, это 401, 403 или 404, вы ещё не начали troubleshooting — вы просто грустите над экраном.
После конфигов, proxy-настроек и hardening поверхностей проблемы как раз и приходят в таком виде: 401, 403, пустой контекст или ощущение, что «фильтр вообще не сработал». Поэтому дальше разберём маршрут диагностики — от ожидаемого источника аутентификации до того места в chain, где решение реально было принято.
2. SecurityContext: источник и пустые состояния
Слово SecurityContext звучит страшно, но по сути это «карман в текущем потоке выполнения», куда Spring Security кладёт информацию о текущем пользователе на время обработки запроса. Если в этом кармане лежит правильный Authentication, ваш код (контроллеры, сервисы, method security) может понимать “кто делает запрос” и “какие у него права”.
Когда вы слышите “empty SecurityContext”, в голове хочется представить null. Но в реальности часто встречаются три состояния:
1) контекст есть, но authentication == null (редко в прикладном коде, чаще в ранних стадиях),
2) контекст заполнен AnonymousAuthenticationToken (и это нормально для публичных запросов),
3) контекст заполнен вашим реальным Authentication (username/password, basic, jwt и т.п.).
Если вы хотите быстро посмотреть, что там лежит, минимальная диагностическая точка выглядит так:
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
// Диагностика: смотрим, чем заполнен SecurityContext прямо сейчас
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
// В логах/консоли это часто будет AnonymousAuthenticationToken для публичных запросов
System.out.println(auth); // например: AnonymousAuthenticationToken [...]
Пара слов осторожности. Это нормальный “термометр”, но плохая “таблетка”. То есть смотреть можно, а вот “если пусто — давай я сам туда что-нибудь положу” — это уже начало самописного mini-security, от которого мы весь курс убегали.
И здесь есть нюанс, который особенно ломает мозг новичку. Если endpoint помечен как permitAll, Spring Security может даже не доставать аутентификацию из session, потому что в этом нет смысла для разрешения доступа. В документации это описано прямо: для permitAll и denyAll Authentication “никогда не извлекается из session”. Следствие практическое и неприятное: вы заходите на публичный endpoint, ожидаете увидеть “я залогинен”, а видите anonymous — и делаете неправильный вывод “сессия сломалась”. На самом деле вы просто смотрите не туда. Диагностировать current user логичнее на endpoint’е, который требует authenticated() или role/authority.
Чтобы привязать к нашему проекту: проверять “кто я” лучше на /api/me, а не на /api/public/articles. Public зона по смыслу не обязана вытаскивать и демонстрировать вашу аутентификацию.
Для наглядности — маленькая схема, откуда вообще мог появиться SecurityContext в нашем курсе:
flowchart TD
%% Источники аутентификации, которые могут заполнить SecurityContext
R[HTTP request] --> FC[SecurityFilterChain]
FC -->|session| S[Достали SecurityContext из HttpSession]
FC -->|Basic| B[Прочитали Authorization: Basic ...]
FC -->|JWT custom| J[Прочитали Authorization: Bearer ...]
FC -->|resource server| RS["JwtDecoder + built-in bearer pipeline"]
FC --> A[Authentication в SecurityContext]
A --> MVC["Controller/Service/@PreAuthorize"]
И вот теперь главный вопрос troubleshooting: какая стрелка должна была сработать в вашем сценарии?
3. SecurityFilterChain: матчер и порядок
Иногда вы уверены: “Я добавил фильтр, он должен вызываться”, но по факту он ведёт себя как кот: “я не обязан приходить, когда вы меня зовёте”. В Spring Security это чаще всего означает не «сломался фильтр», а то, что запрос прошёл не через ту цепочку, или вообще не попал в security chain, которую вы ожидали.
Первая причина — путаница между securityMatcher(...) и requestMatchers(...). securityMatcher выбирает, какой SecurityFilterChain применяется вообще, а requestMatchers внутри выбранной цепочки задают правила авторизации. Документация подчёркивает, что каждый requestMatchers(...) работает только внутри того HttpSecurity, чей securityMatcher тоже совпал.
Сюда же относится и ещё один сюрприз: если SecurityFilterChain матчится только на /api/**, а вы ожидаете, что он “предоставит” endpoint /login (formLogin ветка), то /login может не появиться и будет 404. Это кажется «очень странным» ровно до тех пор, пока вы не вспомните: endpoint тоже обслуживается фильтрами, а фильтры не применились.
Вторая причина — порядок правил авторизации. Самый классический антипример выглядит так:
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
// ВАЖНО: это широкое правило "съест" все более узкие правила ниже
.requestMatchers("/api/**").permitAll() // "съело" всё под /api
// До этого правила код может просто не "дойти" в логике матчеров
.requestMatchers("/api/admin/**").hasRole("ADMIN"));
return http.build();
}
В этом фрагменте правило “разрешить всё под /api/**” стоит раньше, чем “закрыть /api/admin/**”. Итог — admin зона внезапно становится публичной. Это не баг Spring Security, это порядок правил. (Spring Security, как и компилятор, читает сверху вниз, а не “как вы имели в виду”.)
В корректном варианте вы сначала пишете частные/чувствительные правила, а потом широкие:
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
// Сначала защищаем самое чувствительное
.requestMatchers("/api/admin/**").hasRole("ADMIN")
// Потом задаём общее правило для остального API
.requestMatchers("/api/**").authenticated());
return http.build();
}
Третья причина — несколько цепочек и @Order. На fundamentals-уровне мы старались не плодить цепочки без нужды, но даже одна дополнительная цепочка может создать ситуацию “почему запрос идёт не туда?”. В официальном примере показывается, что несколько SecurityFilterChain выбираются по @Order и по securityMatcher, и запрос матчится сначала в более приоритетную цепочку. Практический симптом: вы правите одну конфигурацию, а запрос применяет другую — и вы думаете, что “настройки не применились”.
Четвёртая причина — custom JWT filter добавили, но не туда по порядку. Для custom фильтра чаще всего нужен addFilterBefore(...), чтобы ваш JWT успел заполнить SecurityContext до авторизации:
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
// jwtAuthenticationFilter — ваш OncePerRequestFilter bean
// ВАЖНО: ставим фильтр до UsernamePasswordAuthenticationFilter, чтобы контекст успел заполниться
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
Если фильтр стоит слишком поздно, AuthorizationFilter уже успеет принять решение “anonymous” и вы получите 401/403 ещё до вашего кода.
4. 401: сбой аутентификации
401 — это история про то, что пользователь не стал “узнанным” системой. Важно: “не стал узнанным” не всегда означает “не существует”. Иногда он существует, но вы не передали credentials, или передали не туда, или передали в формате “как мне кажется правильно”.
Начните с простого вопроса: какой auth source вы ожидаете?
Если вы ожидаете session-based модель, то ваш главный носитель состояния — cookie JSESSIONID (или эквивалент), и 401 чаще всего значит, что cookie нет, или она не та, или сессия истекла. И тут легко попасть в ловушку Postman/curl: вы логинитесь один раз, но не сохраняете cookie в следующем запросе — и приложение абсолютно честно говорит “не знаю тебя”. Тут нет “магии Spring”, тут чистая HTTP-реальность.
Если вы ожидаете HTTP Basic, то 401 почти всегда означает, что Authorization header отсутствует или не соответствует формату. Basic — это “credentials в каждом запросе”, и если их нет — current user не появится.
Если вы ожидаете JWT, то первым делом вы проверяете заголовок Authorization. В custom JWT filter часто встречается логика вида “если заголовка нет — просто пропускаем дальше, не аутентифицируя”. Это не ошибка, это нормальная стратегия: фильтр не должен падать на каждом публичном запросе. Но для protected endpoint это приводит к закономерному 401.
Мини-фрагмент, который полезно иметь в голове (и который стоит читать при debugging), выглядит так:
// Берём заголовок, где ожидается JWT (стандартная договорённость)
String header = request.getHeader("Authorization");
// Если заголовка нет или он не начинается с Bearer-предикса — считаем, что токена нет
if (header == null || !header.startsWith("Bearer ")) {
// ВАЖНО: просто пропускаем запрос дальше без аутентификации (останемся anonymous)
filterChain.doFilter(request, response);
return;
}
// Дальше обычно идёт парсинг/проверка токена и заполнение SecurityContext
С точки зрения troubleshooting это означает: отсутствие префикса Bearer почти эквивалентно отсутствию токена. Не “почти работает”, а “не работает совсем”.
И ещё один момент, который многие пропускают: иногда “я отправил токен” не равно “я отправил токен туда, где его читают”. Например, вы положили токен в query parameter ?token=..., а фильтр ищет его только в header. Итог: 401, SecurityContext пустой, и вы начинаете подозревать заговор.
Кстати, про “пустой SecurityContext”. Если вы видите 401, а в контроллере или в логах SecurityContextHolder.getContext().getAuthentication() показывает AnonymousAuthenticationToken, это почти всегда значит, что аутентификация просто не случилась. Не потому, что “у пользователя нет роли”, а потому что мы ещё даже не дошли до ролей.
5. 403: сбой авторизации
403 — намного коварнее, потому что он часто выглядит как “не та роль”, хотя реальная причина может быть совсем другой. В 403 мы уже находимся в мире, где пользователь распознан (или, по крайней мере, request прошёл через механизмы, которые приняли решение), и теперь система говорит “нельзя”.
Для начала полезно разделить три частых “подвида” 403 в нашем проекте.
Первый — request-level запрет. Это когда ваш SecurityFilterChain сказал “в /api/admin/** только ADMIN”, а вы пришли как USER. Здесь виноваты правила authorizeHttpRequests(...).
Второй — method-level запрет. Вы могли разрешить доступ на уровне URL, но внутри сервисного слоя вас остановил @PreAuthorize. Снаружи это выглядит как “ну я же прошёл в контроллер”, но на самом деле решение принял уже другой слой.
Третий — owner-based запрет. Это особенно частая история для Secure Content Platform API: роль USER у вас есть, вы аутентифицированы, но вы пытаетесь прочитать чужой черновик или обновить чужой профиль. С точки зрения безопасности это не “не та роль”, а “не твой объект”. В логах и тестах это должен быть отдельный сценарий.
И теперь главный подвох: 403 может быть вообще не про роли/owner, а про CSRF. В session-based ветке state-changing запрос без CSRF токена часто заканчивается 403. А в multipart-upload сценарии всё становится ещё веселее, потому что файл — это body, и CSRF-токен тоже где-то должен быть.
Spring Security отдельно обсуждает проблему multipart + CSRF как “chicken and egg”: чтобы достать CSRF token из тела, нужно прочитать тело, а значит — файл уже попал на сервер. Поэтому для браузерных приложений с JavaScript рекомендуется передавать CSRF token в заголовке — так вы не заставляете сервер читать body до проверки CSRF. В практическом troubleshooting это выглядит так: вы пробуете POST /api/me/avatar, получаете 403, и начинаете проверять роль, хотя правильный вопрос — “а CSRF токен вообще был?”.
Ещё одна тонкость, которая ломает ожидания: если вы видите 403 в admin/editor зоне, убедитесь, что вы сравниваете роль и authority в правильной форме. hasRole("ADMIN") ожидает наличие ROLE_ADMIN, а hasAuthority("ADMIN") — точное совпадение строки. Если вы где-то смешали naming conventions, 403 будет выглядеть “та же роль, но почему нельзя?”. Это тот случай, когда проблема не в логике доступа, а в строках (самый обидный вид ошибок, потому что он выглядит как философия, а на деле — опечатка).
6. Логи Spring Security: читаем без слёз
Логи Spring Security часто пугают новичка так же, как git rebase --interactive пугает человека, который просто хотел “переименовать коммит”. Но у логов есть хорошая новость: вам не нужно понимать всё. Вам нужно вытащить из них ответы на два вопроса: “какая цепочка сработала?” и “какое решение было принято?”.
Самый простой способ сделать это на локальной машине — временно включить DEBUG-уровень:
logging:
level:
# Включаем DEBUG только на время диагностики: логов будет очень много
org.springframework.security: DEBUG
После этого вы начинаете видеть, как запрос проходит через filter chain, какие фильтры применяются, и где именно принимается решение про 401/403.
Важно пользоваться этим аккуратно. DEBUG — это как рентген: полезно, когда есть подозрение, и вредно, когда вы делаете его каждое утро “на всякий случай”. Держать DEBUG постоянно — значит утонуть в шуме и перестать видеть сигнал.
Теперь как читать эти логи “по-взрослому”. Сначала вы ищете, что запрос вообще попал в security цепочку. Потом вы ищете, появился ли Authentication (или остался anonymous). Потом вы ищете, где принято решение об отказе. Если отказ — AccessDenied, вы в зоне 403 и проверяете роли/authorities/CSRF/owner-check. Если отказ — про отсутствие аутентификации, вы в зоне 401 и проверяете, почему auth source не заполнил контекст.
И маленькая психологическая победа: не пытайтесь “прочитать все фильтры”. Вы не обязаны помнить весь каталог Spring Security. Вам нужно научиться отвечать на вопрос “а мой custom JWT filter вообще вызывался?” или “а CSRF проверка была?”. Этого достаточно, чтобы 80% проблем перестали быть мистикой.
7. Типичные ошибки при troubleshooting security
Ошибка №1: считать любой отказ 403-ом, а потом искать роли.
Когда разработчик называет любой отказ “forbidden”, он сам себе ломает диагностику. 401 — это отсутствие аутентификации, и там роли вообще не при чём. В результате человек часами проверяет hasRole(...), хотя на самом деле не передал Authorization header.
Ошибка №2: диагностировать SecurityContext на permitAll endpoint’е и делать вывод “сессии нет”.
Это классическая ловушка. Для permitAll Spring Security может не извлекать Authentication из session. Документация прямо отмечает это поведение. Если вы смотрите контекст на публичной ручке, вы можете увидеть anonymous даже будучи залогиненным — и это не баг, а оптимизация/семантика.
Ошибка №3: “фильтр не сработал” → сразу переписываем фильтр.
Чаще всего фильтр “не сработал” не потому, что код фильтра плохой, а потому что запрос не попал в нужный SecurityFilterChain (ошибка securityMatcher) или фильтр стоит не там по order. Сначала докажите, что цепочка и order корректны, потом уже трогайте код.
Ошибка №4: неправильный порядок matcher-ов, который случайно открывает или закрывает всё.
Широкое правило типа /api/** легко “съедает” более узкое /api/admin/**, если стоит выше. В итоге вы либо внезапно открываете админку, либо внезапно всё закрываете. В обоих случаях симптом будет выглядеть как “Spring Security ведёт себя странно”, хотя причина — чистая логика порядка правил.
Ошибка №5: путать 403 из-за CSRF с 403 из-за ролей.
В session-based ветке отсутствие CSRF токена на POST/PATCH/DELETE часто выглядит как “нет прав”, хотя по факту это “запрос не прошёл CSRF-проверку”. В multipart-upload сценарии это ещё больнее: файл улетает, а вы ищете, где потерялась роль. Spring Security отдельно описывает multipart+CSRF как особый кейс и рекомендует header-based передачу токена, когда есть JavaScript-клиент.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ