JavaRush /Курси /Spring Security /Усунення неполадок 401

Усунення неполадок 401/ 403 і контекст

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

1. Діагностика: статуси та симптоми

Коли Spring Security «ламається», він майже ніколи не ламається красиво. Зазвичай це виглядає так: “вчора працювало”, “сьогодні 403”, “чому?” — і далі починається хаотичне смикання важелів. Щоб перестати гратися у «вгадайку», почнімо з простого правила: перш ніж чіпати код, потрібно вміти назвати проблему так, щоб її зрозуміла інша людина — або ви самі через тиждень.

Я люблю мислити у форматі маленької «картки інциденту» — не заради бюрократії, а заради ясності. У ній мають бути речі, без яких налагодження перетворюється на магію: який ендпойнт, який HTTP-метод, який клієнт, який статус і, найголовніше, яку модель автентифікації ви очікували (session? Basic? Bearer/JWT?). Коли ви це фіксуєте, половина «дивностей» раптово перестає бути дивностями.

Ось приклад такої картки в табличному вигляді:

Поле Приклад
Ендпойнт GET /api/me
Клієнт Postman
Очікувана модель Bearer-токен (JWT-гілка)
Що відправили Authorization відсутній
Факт 401

Бачите? Тут діагноз майже готовий ще до журналів: ми очікували JWT, але взагалі не передали токен. І це не «Spring Security знову дратує», а коректна поведінка.

Корисна порада для новачка: якщо ви не можете за 10 секунд відповісти, яке джерело автентифікації має заповнити SecurityContext на цьому запиті, значить, ви поки що діагностуєте не проблему, а свою втому.

401, 403, 404: розрізняємо статуси

Багато хто починає налагодження з фрази “мені повернуло помилку доступу”. Це приблизно як сказати лікарю: “мені погано”. Так, ми співчуваємо, але лікувати за таким описом можна лише обіймами. Тому у світі безпеки ми починаємо з розмежування кодів статусу: 401 і 403 — це різні історії. А іноді в налагодженні безпеки раптово з’являється і 404, і він теж може бути “про безпеку”, а не про відсутність контролера.

Щоб швидко орієнтуватися, тримайте в голові таку таблицю:

Код Простою мовою Де зазвичай шукати причину
401 “Хто ти? Я тебе не впізнаю / ти не підтвердив особу” Автентифікація: session/Basic/JWT/вхід
403 “Я тебе впізнаю, але тобі не можна” Авторизація: ролі, authorities, перевірка власника, CSRF
404 “Я не знаю такого ендпойнта” Іноді справді 404, а іноді chain/matcher/securityMatcher

Про 404 є важлива тонкість: якщо ви працюєте з securityMatcher(...) і кількома ланцюжками, ви можете випадково отримати ситуацію, коли ендпойнт, який зазвичай надає сам фільтр (наприклад /login у formLogin-моделі), взагалі не існує, тому що запит не потрапляє в жоден SecurityFilterChain. Spring Security прямо попереджає, що ендпойнти, які надаються фільтрами, залежать від того, чи збігається запит із вибраним securityMatcher.

І тепер ключовий поворот для діагностики: поки ви не відповіли собі, це 401, 403 чи 404, ви ще не почали налагодження — ви просто сумуєте перед екраном.

Після конфігів, налаштувань proxy та посилення захисту проблеми саме так і виглядають: 401, 403, порожній контекст або відчуття, що «фільтр узагалі не спрацював». Тому далі розберімо маршрут діагностики — від очікуваного джерела автентифікації до того місця в ланцюжку, де рішення реально було ухвалено.

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, від якого ми весь курс тікали.

І тут є нюанс, який особливо ламає мозок новачку. Якщо ендпойнт позначений як permitAll, Spring Security може навіть не діставати автентифікацію із session, тому що в цьому немає сенсу для надання доступу. У документації це описано прямо: для permitAll і denyAll Authentication “ніколи не витягується із session”. Практичний і неприємний наслідок: ви заходите на публічний ендпойнт, очікуєте побачити “я залогінений”, а бачите anonymous — і робите неправильний висновок “сесія зламалася”. Насправді ви просто дивитеся не туди. Діагностувати поточного користувача логічніше на ендпойнті, який вимагає authenticated() або role/authority.

До нашого проєкту це означає так: перевіряти “хто я” краще на /api/me, а не на /api/public/articles. Публічна зона за змістом не зобов’язана витягувати й демонструвати вашу автентифікацію.

Для наочності — маленька схема, звідки взагалі міг з’явитися SecurityContext у нашому курсі:

flowchart TD
    %% Джерела автентифікації, які можуть заповнити SecurityContext
    R[HTTP-запит] --> FC[SecurityFilterChain]
    FC -->|session| S[Отримали SecurityContext з HttpSession]
    FC -->|Basic| B[Прочитали Authorization: Basic ...]
    FC -->|JWT custom| J[Прочитали Authorization: Bearer ...]
    FC -->|сервер ресурсів| RS["JwtDecoder + вбудований bearer-потік"]
    A --> MVC["Контролер/Сервіс/@PreAuthorize"]

І ось тепер головне питання налагодження: яка стрілка мала спрацювати у вашому сценарії?

3. SecurityFilterChain: матчер і порядок

Іноді ви впевнені: “Я додав фільтр, він має викликатися”, але насправді він поводиться як кіт: “я не зобов’язаний приходити, коли ви мене кличете”. У Spring Security це найчастіше означає не «зламався фільтр», а те, що запит пройшов не через той ланцюжок або взагалі не потрапив у той security chain, якого ви очікували.

Перша причина — плутанина між securityMatcher(...) і requestMatchers(...). securityMatcher обирає, який SecurityFilterChain застосовується взагалі, а requestMatchers усередині вибраного ланцюжка задають правила авторизації. Документація підкреслює, що кожен requestMatchers(...) працює лише всередині того HttpSecurity, чий securityMatcher теж збігся.

Сюди ж належить і ще один сюрприз: якщо SecurityFilterChain матчиться лише на /api/**, а ви очікуєте, що він “надасть” ендпойнт /login (formLogin-гілка), то /login може не з’явитися, і буде 404. Це здається «дуже дивним» рівно доти, доки ви не згадаєте: ендпойнт теж обслуговується фільтрами, а фільтри не застосувалися.

Друга причина — порядок правил авторизації. Найкласичніший антиприклад виглядає так:

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
            // До цього правила код може просто не "дійти" в логіці matcher-ів
            .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. На базовому рівні ми намагалися не плодити ланцюжки без потреби, але навіть один додатковий ланцюжок може створити ситуацію “чому запит іде не туди?”. В офіційному прикладі показується, що кілька SecurityFilterChain вибираються за @Order і за securityMatcher, і запит матчиться спочатку в ланцюжок із вищим пріоритетом. Практичний симптом: ви правите одну конфігурацію, а запит застосовує іншу — і вам здається, що налаштування не спрацювали.

Четверта причина — custom JWT filter додали, але не туди за порядком. Для власного JWT-фільтра найчастіше потрібен 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 — це історія про те, що користувач не став “упізнаним” системою. Важливо: “не став упізнаним” не завжди означає “не існує”. Іноді він існує, але ви не передали облікові дані, або передали не туди, або передали у форматі “як мені здається правильно”.

Почніть із простого питання: яке джерело автентифікації ви очікуєте?

Якщо ви очікуєте модель на основі сесії, то ваш головний носій стану — cookie JSESSIONID (або еквівалент), і 401 найчастіше означає, що cookie немає, або вона не та, або сесія спливла. І тут легко потрапити в пастку Postman чи curl: ви логінитеся один раз, але не зберігаєте cookie в наступному запиті — і застосунок абсолютно чесно каже “не знаю вас”. Тут немає “магії Spring”, тут чиста HTTP-реальність.

Якщо ви очікуєте HTTP Basic, то 401 майже завжди означає, що заголовок Authorization відсутній або не відповідає формату. Basic — це “облікові дані в кожному запиті”, і якщо їх немає — поточний користувач не з’явиться.

Якщо ви очікуєте JWT, то насамперед перевіряєте заголовок Authorization. У власному JWT-фільтрі часто трапляється логіка на кшталт: “якщо заголовка немає — просто пропускаємо далі, не автентифікуючи”. Це не помилка, а нормальна стратегія: фільтр не повинен падати на кожному публічному запиті. Але для захищеного ендпойнта це закономірно призводить до 401.

Мініфрагмент, який корисно тримати в голові (і який варто читати під час налагодження), виглядає так:

// Беремо заголовок, де очікується JWT (стандартна домовленість)
String header = request.getHeader("Authorization");

// Якщо заголовка немає або він не починається з Bearer-префікса — вважаємо, що токена немає
if (header == null || !header.startsWith("Bearer ")) {
    // ВАЖЛИВО: просто пропускаємо запит далі без автентифікації (залишимося anonymous)
    filterChain.doFilter(request, response);
    return;
}

// Далі зазвичай іде парсинг/перевірка токена та заповнення SecurityContext

З погляду налагодження це означає: відсутність префікса Bearer майже дорівнює відсутності токена. Не “майже працює”, а “не працює взагалі”.

І ще один момент, який багато хто пропускає: іноді “я відправив токен” не дорівнює “я відправив токен туди, де його читають”. Наприклад, ви поклали токен у параметр запиту ?token=..., а фільтр шукає його лише в header. Підсумок: 401, SecurityContext порожній, і ви починаєте підозрювати змову.

До речі, про “порожній SecurityContext”. Якщо ви бачите 401, а в контролері або в журналах SecurityContextHolder.getContext().getAuthentication() показує AnonymousAuthenticationToken, це майже завжди означає, що автентифікація просто не відбулася. Не тому, що “в користувача немає ролі”, а тому, що ми ще навіть не дійшли до ролей.

5. 403: збій авторизації

403 — набагато підступніший, бо він часто виглядає як “не та роль”, хоча реальна причина може бути зовсім іншою. У 403 ми вже знаходимося у світі, де користувача впізнано (або, принаймні, запит пройшов через механізми, які ухвалили рішення), і тепер система каже “не можна”.

Для початку корисно розділити три часті “підвиди” 403 у нашому проєкті.

Перший — заборона на рівні запиту. Це коли ваш SecurityFilterChain сказав “у /api/admin/** лише ADMIN”, а ви прийшли як USER. Тут справа в правилах authorizeHttpRequests(...).

Другий — заборона на рівні методу. Ви могли дозволити доступ на рівні URL, але всередині сервісного шару вас зупинив @PreAuthorize. Ззовні це виглядає як “ну я ж дійшов до контролера”, але насправді рішення вже ухвалив інший шар.

Третій — заборона на основі власника. Це особливо часта історія для Secure Content Platform API: роль USER у вас є, ви автентифіковані, але намагаєтеся прочитати чужий чернетковий запис або оновити чужий профіль. З погляду безпеки це не “не та роль”, а “не ваш об’єкт”. У журналах і тестах це має бути окремий сценарій.

І тепер головна пастка: 403 може взагалі бути не про ролі чи owner-check, а про CSRF. У гілці, що працює через сесію, state-changing запит без CSRF-токена часто закінчується 403. А в сценарії multipart-завантаження усе стає ще веселіше, тому що файл — це body, і CSRF-токен теж десь має бути.

Spring Security окремо обговорює проблему multipart + CSRF як “chicken and egg”: щоб дістати CSRF token із body, потрібно прочитати body, а отже — файл уже потрапив на сервер. Тому для браузерних застосунків із JavaScript рекомендується передавати CSRF token у заголовку — так ви не змушуєте сервер читати body до перевірки CSRF. У практичному налагодженні це виглядає так: ви пробуєте POST /api/me/avatar, отримуєте 403 і починаєте перевіряти роль, хоча правильне питання — “а CSRF token узагалі був?”.

Ще одна тонкість, яка ламає очікування: якщо ви бачите 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 і перевіряєте, чому джерело автентифікації не заповнило контекст.

І маленька психологічна перемога: не намагайтеся “прочитати всі фільтри”. Ви не зобов’язані пам’ятати весь каталог Spring Security. Вам потрібно навчитися відповідати на запитання “а мій custom JWT filter взагалі викликався?” або “а CSRF-перевірка була?”. Цього достатньо, щоб 80% проблем перестали бути містикою.

7. Типові помилки під час налагодження безпеки

Помилка №1: вважати будь-яку відмову 403-ом, а потім шукати ролі.
Коли розробник називає будь-яку відмову “forbidden”, він сам собі ламає діагностику. 401 — це відсутність автентифікації, і там ролі взагалі ні до чого. У результаті людина годинами перевіряє hasRole(...), хоча насправді не передала заголовок Authorization.

Помилка №2: діагностувати SecurityContext на ендпойнті permitAll і робити висновок “сесії немає”.
Це класична пастка. Для permitAll Spring Security може не витягувати Authentication із session. Документація прямо зазначає цю поведінку. Якщо ви дивитеся на контекст на публічному ендпойнті, ви можете побачити anonymous навіть будучи залогіненим — і це не баг, а оптимізація або семантика.

Помилка №3: “фільтр не спрацював” → відразу переписуємо фільтр.
Найчастіше фільтр не спрацював не тому, що код фільтра поганий, а тому, що запит не потрапив у потрібний SecurityFilterChain (помилка securityMatcher) або фільтр стоїть не там за order. Спочатку доведіть, що ланцюжок і order коректні, а потім уже чіпайте код.

Помилка №4: неправильний порядок matcher-ів, який випадково відкриває або закриває все.
Широке правило на кшталт /api/** легко “поглинає” вужче /api/admin/**, якщо стоїть вище. У підсумку ви або раптово відкриваєте адмінку, або раптово все закриваєте. В обох випадках симптом виглядатиме як “Spring Security поводиться дивно”, хоча причина — чиста логіка порядку правил.

Помилка №5: плутати 403 через CSRF із 403 через ролі.
У гілці, що працює через сесію, відсутність CSRF-токена на POST/PATCH/DELETE часто виглядає як “немає прав”, хоча фактично це “запит не пройшов CSRF-перевірку”. У сценарії multipart-завантаження це ще болючіше: файл відлітає, а ви шукаєте, де загубилася роль. Spring Security окремо описує multipart+CSRF як особливий кейс і рекомендує передавання токена в заголовку, коли є JavaScript-клієнт.

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