1. Проблема: плутанина двох матчерів
Якщо ви колись дивилися на security-конфіг і ловили відчуття «тут три різні matcher-и, і всі виглядають однаково, а сенс — як у різних кнопок у кабіні літака», то ви не самі. У новачка зазвичай є цілком чесна логіка: раз ми пишемо requestMatchers("/api/**"), отже ми “під’єднали” безпеку до цих URL. А потім з’являється securityMatcher("/api/**"), і мозок удає, що це те саме, тільки «по-дорослому». У результаті конфіг починає нагадувати DSL Франкенштейна: місцями працює, місцями взагалі не чіпає запити, а ви налагоджуєте це на відчуттях, ніби налаштовуєте Wi‑Fi у підʼїзді.
Найнеприємніша частина цієї плутанини в тому, що помилка часто виглядає «майже нормально». Ви тестуєте /api/me — захищено, ок. Тестуєте /api/admin/users — теж захищено, ок. А потім раптово з’ясовується, що /swagger-ui/index.html тепер доступний усім, або /actuator/info без автентифікації, або взагалі якийсь GET /error почав повертати інформацію, яку ви не планували відкривати. І це не тому, що Spring Security поганий чи «магічний», а тому, що ви налаштували охорону лише на один вхід… і забули, що в будівлі ще є вікна.
Після того як у карту потрапили не тільки /api/**, а й docs з actuator, природно виникає запитання: може, час рознести все по різних SecurityFilterChain? Іноді — так. Але спочатку важливо зняти зайву тривогу: public API, docs і actuator уже можна коректно захистити одним читабельним chain. Split по chains потрібен лише тоді, коли зони застосунку справді настільки різняться за політикою, що один конфіг перестає читатися. Для завдань цієї лекції нормальний робочий варіант — один chain; multi-chain — усвідомлена альтернатива, а не обов’язковий новий рівень “правильності”.
Завдання цієї лекції — побудувати чітку ментальну модель: securityMatcher відповідає за вибір ланки, а requestMatchers — за правила доступу всередині вибраної ланки. І окремо важливо зрозуміти, що anyRequest() завершує лише поточну ланку, а не весь застосунок.
2. securityMatcher vs requestMatchers
Ззовні ці два методи схожі: обидва «щось матчать», обидва приймають патерни шляхів, і обидва начебто беруть участь у безпеці. Але їхній сенс — на різних поверхах архітектури. Якщо вам потрібен швидкий якір, то ось людське формулювання: securityMatcher визначає, які запити потраплять у цю ланку фільтрів, а requestMatchers — яке правило доступу застосувати всередині цієї ланки.
Уявіть торговельний центр. securityMatcher — це вибір, у який торговельний центр ви взагалі зайшли (ТЦ «API», ТЦ «Docs», ТЦ «Actuator»). А requestMatchers — це охорона всередині: куди можна зайти всім, куди тільки за перепусткою, а куди не можна навіть адміністратору, тому що «там серверна, і там живе кіт, який не любить людей».
Найнаочніше це можна порівняти в невеликій таблиці:
| Що це | Питання, на яке відповідає | Де застосовується | Типова помилка |
|---|---|---|---|
| securityMatcher(...) | «На які запити взагалі поширюється ця ланка?» | На рівні вибору SecurityFilterChain | Зробити chain лише для /api/** і забути про інші URL → частина застосунку опиняється поза Spring Security |
| requestMatchers(...) | «Яке правило доступу застосувати до запиту всередині ланки?» | Усередині authorizeHttpRequests(...) | Намагатися requestMatchers-ами “розділити ланки”, хоча насправді розділяються лише правила |
Давайте подивимося на мінімальний приклад, щоб відчути “межу впливу”:
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Bean
SecurityFilterChain oneChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
// Дозволяємо публічну частину API без автентифікації
.requestMatchers("/api/public/**").permitAll()
// Усе інше в межах цієї ланки потребує входу
.anyRequest().authenticated()
);
// Формуємо ланку фільтрів Spring Security для застосунку
return http.build();
}
Тут немає securityMatcher. Це означає: ця ланка — “за замовчуванням” — застосовується до всіх запитів, доки ми не починаємо ділити їх на різні chains. А requestMatchers("/api/public/**") — це просто приватне правило всередині цієї єдиної ланки.
Тепер порівняймо з таким кодом:
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Bean
SecurityFilterChain apiOnly(HttpSecurity http) throws Exception {
// Обмежуємо область дії chain: лише запити під /api/**
http.securityMatcher("/api/**");
// У межах вибраної області дії вимагаємо автентифікацію для будь-якого запиту
http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated());
return http.build();
}
Це вже зовсім інша пісня: ми створили chain, яка застосовується лише до /api/**. І якщо більше ланок немає — усе, що не /api/**, може пройти повз Spring Security взагалі. Це не «ще одне правило», а “вибір зони дії”.
3. Вибір ланки: FilterChainProxy і порядок
Щоб не жити у світі заклинань DSL, потрібно один раз чітко уявити собі механіку вибору ланки. У servlet‑застосунку запит приходить у загальний ланцюг фільтрів контейнера, а далі Spring Security (через FilterChainProxy) вибирає, яка SecurityFilterChain буде застосована. У FilterChainProxy є список ланок, і він шукає першу ланку, чий matcher підходить до поточного запиту. Наші securityMatcher(...) якраз і формують цей «matcher ланки».
Дуже корисно думати про це як про простий алгоритм “перший підійшов — той і працює”. Схематично це можна зобразити так:
flowchart TD
R["HTTP-запит"] --> FCP["FilterChainProxy"]
FCP --> C1{"Ланка №1: securityMatcher збігається?"}
C1 -- так --> A1["Застосувати фільтри ланки №1, а потім правила authorizeHttpRequests"]
C1 -- ні --> C2{"Ланка №2: securityMatcher збігається?"}
C2 -- так --> A2["Застосувати фільтри ланки №2, а потім правила authorizeHttpRequests"]
C2 -- ні --> CN["Жодна ланка не підійшла"]
CN --> P["Продовжити без фільтрів Spring Security"]
Остання гілка — ключ до нервового тіку: “no chain matched” означає, що Spring Security не застосував жодну ланку. Це не означає «доступ заборонено». Це означає «Spring Security взагалі не брав участі». А далі запит піде так, ніби security в застосунку немає (якщо на іншому рівні ви його не обмежили).
Звідси випливає найважливіше практичне правило: коли ви використовуєте securityMatcher і ділите застосунок на кілька chains, вам майже завжди потрібна дефолтна “замикальна” ланка, яка зловить усе інше. Вона може або все заборонити (denyAll()), або вимагати автентифікацію, або дозволяти щось точково — але вона має існувати, інакше ви залишаєте «сіру зону» запитів.
Тепер про порядок. Якщо ланок кілька, Spring має розуміти, в якому порядку їх перевіряти. На фундаментальному рівні це вирішує @Order. Чим менше число — тим вищий пріоритет. І тут теж є проста інженерна логіка: більш специфічна ланка має перевірятися раніше, ніж більш загальна. Інакше загальна ланка матчиться першою і «з’їдає» запит.
Мініприклад “специфічна раніше за загальну”:
import org.springframework.context.annotation.Bean;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Bean
@Order(1)
SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
// Ця ланка застосовується лише до /api/**
http.securityMatcher("/api/**");
// Будь-який запит у межах /api/** має бути автентифікований
http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated());
return http.build();
}
А тепер “загальна” ланка без securityMatcher (тобто вона підходить до всього, що не перехоплено раніше):
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Bean
SecurityFilterChain defaultChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
// Замикальне правило: якщо запит дійшов сюди, ми його закриваємо
.anyRequest().denyAll()
);
return http.build();
}
Ця пара вже гарантує: або запит потрапив у /api/** і обробився api-ланкою, або потрапив у default-ланку і був закритий.
4. Одна SecurityFilterChain: найкращий варіант
У розробника-початківця є природне бажання “зробити гарно” — рознести все по різних ланках, як по папках на робочому столі. Проблема в тому, що в security “гарно” дуже швидко перетворюється на “незрозуміло і небезпечно”, якщо ви ще не впевнені, що точно закрили всі маршрути. Тому перше питання тут не “скільки chains уміє Spring Security”, а “чи поміщається карта доступу в один читабельний файл”. Якщо після public/user/privileged/docs/actuator конфіг усе ще читається зверху вниз, split просто не потрібен. На базовому рівні найчастіше правильна відповідь — одна chain, але структурована логічними блоками.
Одна SecurityFilterChain хороша тим, що ви читаєте конфігурацію як карту доступу в одному місці. Ви бачите public-зону, бачите authenticated-зону, бачите privileged-зону, бачите docs і actuator, і бачите фінальне anyRequest().denyAll(). Такий конфіг простіше супроводжувати і складніше випадково “проткнути дірку” між ланками.
Наприклад, для нашого проєкту в одній chain це може виглядати так (скорочено, лише щоб показати ідею):
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Bean
SecurityFilterChain appChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
// Публічна GET-кінцева точка (наприклад, каталог статей)
.requestMatchers(HttpMethod.GET, "/api/public/articles/**").permitAll()
// Усе інше в межах /api/** закриваємо автентифікацією
.requestMatchers("/api/**").authenticated()
// Документація: у цьому прикладі теж вимагаємо auth
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").authenticated()
// Health часто відкривають для моніторингу
.requestMatchers("/actuator/health").permitAll()
// Інші actuator-ендпоінти закриваємо повністю
.requestMatchers("/actuator/**").denyAll()
// Будь-які невідомі маршрути забороняємо (передбачувана поведінка)
.anyRequest().denyAll()
);
return http.build();
}
Тут важливо те, що ви не “ділите світ” на різні ланки. Ви просто всередині однієї ланки описуєте різні зони правилами, і це повністю відповідає request-level авторизації, яку ми будуємо впродовж усього уроку.
Коли ж одна chain починає «тріщати по швах»? Зазвичай це відбувається не тому, що «так заведено у сеньйорів», а тому, що справді з’являються різні зони застосунку, які сильно відрізняються за політикою, і їх простіше підтримувати окремо. У нашому курсі це розуміння важливе, але застосовувати його потрібно акуратно: не заради “просунутості”, а заради ясності.
5. Дві SecurityFilterChain: API і default
Якщо одна chain справді перестала читатися і ви все ж вирішуєте розділити ланки, то починати варто з найзрозумілішого розрізу: «API-зона» і «не API». У нашому проєкті це природно: більшість бізнес-endpoint’ів живуть під /api/**, а docs і actuator живуть окремо. Такий split може зробити конфіг читабельнішим, але це вже альтернативний архітектурний вибір, а не обов’язковий rewrite того, що й так зрозуміло працює однією chain.
Спочатку покажемо небезпечний приклад, який виглядає «логічно», але залишає дірку:
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Bean
SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
// Ланка буде застосовуватися лише до /api/**
http.securityMatcher("/api/**");
// І лише всередині /api/** ми вимагаємо автентифікацію
http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated());
return http.build();
}
На перший погляд усе чудово: API захищено. Але все, що НЕ /api/** (наприклад, /swagger-ui/**, /v3/api-docs/**, /actuator/**, /error) — може залишитися без Spring Security. І важливо пам’ятати неприємну деталь Spring Boot: щойно ви визначили свій SecurityFilterChain bean, дефолтна автоконфігурація ланки безпеки від Boot, як правило, відступає. Тобто “default security” вас більше не рятує: ви самі дорослі, ви самі відповідаєте.
Тепер — правильний каркас із двох ланок.
Перша, API-ланка, максимально специфічна і тому має вищий пріоритет:
import org.springframework.context.annotation.Bean;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Bean
@Order(1)
SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
// Ланка №1: відповідає лише за /api/**
http.securityMatcher("/api/**");
http.authorizeHttpRequests(auth -> auth
// Точковий виняток: частина API публічна
.requestMatchers(HttpMethod.GET, "/api/public/articles/**").permitAll()
// Усе інше в API потребує автентифікації
.anyRequest().authenticated()
);
return http.build();
}
Друга — дефолтна, яка ловить усе інше. Тут ми можемо явно задати правила для docs і actuator (як обговорювали в минулій лекції), а решту закрити:
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Bean
SecurityFilterChain defaultChain(HttpSecurity http) throws Exception {
// Ланка №2 (дефолтна): застосовується до всього, що не перехопили більш пріоритетні ланки
http.authorizeHttpRequests(auth -> auth
// Документація: у цьому прикладі закриваємо автентифікацією
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").authenticated()
// Health залишаємо відкритим
.requestMatchers("/actuator/health").permitAll()
// Інші actuator-ендпоінти забороняємо
.requestMatchers("/actuator/**").denyAll()
// Замикаємо весь інший світ
.anyRequest().denyAll()
);
return http.build();
}
Зверніть увагу на “замикальне” правило. Воно в дефолтній ланці робить вашу систему передбачуваною: невідомий маршрут не має раптово ставати публічним лише тому, що ви про нього забули.
Тепер ще один нюанс, який дуже часто забувають: налаштування обробників помилок (AuthenticationEntryPoint, AccessDeniedHandler) і взагалі exceptionHandling(...) — це конфігурація всередині конкретної chain. Тобто якщо ви раніше зробили REST-friendly JSON-помилки, а тепер розділили ланки, вам потрібно переконатися, що в API-ланці ці обробники підключені. Інакше ви несподівано отримаєте інший формат 401/403 в одній зоні застосунку і “як вийде” в іншій.
Мінішаблон, як не розмазувати однакове налаштування по двох ланках, можна зробити через невеликий метод:
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
private void useJsonSecurityErrors(HttpSecurity http,
AuthenticationEntryPoint entryPoint,
AccessDeniedHandler deniedHandler) throws Exception {
http.exceptionHandling(ex -> ex
// 401: що віддаємо, коли користувач не автентифікований
.authenticationEntryPoint(entryPoint)
// 403: що віддаємо, коли користувач автентифікований, але доступу немає
.accessDeniedHandler(deniedHandler)
);
}
І далі в apiChain(...) ви викликаєте цей метод (не показую повний метод цілком, щоб не перетворювати приклад на простирадло). Сенс простий: якщо ви ділите ланки, спільні “контрактні” речі потрібно підключати послідовно, інакше клієнт API буде дивуватися, а ви будете дивуватися разом із ним.
6. Кілька chains: здоровий глузд
Бажання розбити все на кілька SecurityFilterChain схоже на бажання розбити застосунок на три контролери за допомогою мікросервісів: технічно можливо, але часто передчасно. На базовому рівні головний критерій має бути не “крутість”, а зниження ризику й підвищення читабельності. Якщо одна chain читається зверху вниз як карта доступу і при цьому не перетворюється на кашу — залишайте одну. Це чесний, хороший, дорослий вибір.
Кілька chains стають осмисленими, коли зони справді відрізняються настільки, що в одній chain ви починаєте постійно “тримати контекст у голові”. Наприклад, API-зона живе за правилами поведінки, зручної для REST, а десь є окрема зона з іншими вимогами, і рознести їх простіше, ніж безкінечно дописувати умови. Але навіть тоді multi-chain додає новий клас помилок: неправильний @Order, надто широкий securityMatcher(...), відсутність дефолтної ланки, дублювання налаштувань.
Окрема пастка — віра в магію anyRequest(). anyRequest закриває лише те, що потрапило в поточну ланку. Якщо ланка не збігається, anyRequest взагалі не має жодної влади. Тому при multi-chain конфігурації обов’язково тримайте в голові два питання: “яка ланка вибрана?” і “які правила всередині неї?”.
І так: якщо ви колись упіймали себе на думці “не розумію, чому цей URL взагалі не захищений, я ж написав anyRequest().authenticated()”, то в 9 випадках із 10 ви або не потрапили в потрібну ланку, або не потрапили ні в яку. У Spring Security тут усе дуже чесно: не збіглося — не застосувалося.
7. Типові помилки під час роботи з SecurityFilterChain
Помилка №1: плутати requestMatchers і securityMatcher, бо «обидва ж matchers».
Це найпопулярніша причина дивної поведінки. requestMatchers — це правила доступу всередині ланки, а securityMatcher — фільтр того, чи взагалі потрапить запит у ланку. Якщо тримати в голові цю фразу, половина містики зникає. Якщо не тримати — ви будете лагодити доступ “підбором рядків”.
Помилка №2: написати ланку лише для /api/** і забути, що в застосунку є інші маршрути.
Сервіс Spring Boot рідко складається тільки з /api/**. Є /error, є docs, іноді є actuator, буває статичний вміст. Якщо ви зробили securityMatcher("/api/**") і не зробили дефолтну chain — ви потенційно залишили частину застосунку без Spring Security. Це не “не той код відповіді”, а класична архітектурна діра.
Помилка №3: вірити, що anyRequest() закриває весь застосунок.
anyRequest завершує лише правила всередині вибраної ланки. Якщо ланка вибрана неправильно (або не вибрана взагалі), anyRequest перетворюється на красивий рядок, який ні на що не впливає. Це дуже часта причина фрази “але в мене ж anyRequest().denyAll()… чому воно доступне?!”.
Помилка №4: неправильний @Order, через який загальна ланка “з’їдає” запити раніше за специфічну.
Якщо у вас є chain без securityMatcher(...) (тобто вона збігається “на все”), і ви не поставили більш специфічній ланці вищий пріоритет — найімовірніше, застосовуватиметься загальна ланка. Результат — правила “не спрацьовують”, хоча вони написані правильно. Насправді вони просто живуть у ланці, яку ніколи не вибирають.
Помилка №5: розділити chains і забути про узгодженість 401/403 та JSON-контракту помилок.
REST-friendly обробники (AuthenticationEntryPoint і AccessDeniedHandler) живуть всередині chain. Якщо в API-ланці вони підключені, а в іншій ланці — ні, клієнти почнуть отримувати різний формат помилок. Це не “дрібниця інтерфейсу”, а річ, що ламає передбачуваність API. Навіть якщо docs/actuator ви вирішите захищати інакше, намагайтеся робити це усвідомлено й однаково.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ