1. Обмеження anyRequest()
Коли ви вперше пишете security-конфігурацію, дуже хочеться сказати застосунку щось просте й чесне: «нехай усі запити вимагають входу» або «нехай усе буде відкрито, потім розберемося». Це нормальна стадія дорослішання. Але в API є неприємна звичка бути корисним уже сьогодні, а не потім.
Уявіть наш проєкт Secure Content Platform API. У нього є публічні статті (/api/public/**), які має читати будь-який анонімний користувач. А є особиста зона (/api/me/**), яка за змістом не має бути доступною без автентифікації. Якщо ми пишемо лише:
http.authorizeHttpRequests(auth -> auth
// Правило за замовчуванням: кожен запит вимагає входу
// (і публічні кінцеві точки теж випадково потрапляють сюди)
.anyRequest().authenticated()
);
то ми «випадково закриваємо» і публічні статті. А якщо зробимо навпаки:
http.authorizeHttpRequests(auth -> auth
// Інша крайність: кожен запит доступний без входу
// (і особиста зона теж випадково стає публічною)
.anyRequest().permitAll()
);
то ми «випадково відкриваємо» особисту зону. Виходить не security, а дверний замок із двома режимами: «усім вхід вільний» і «ніхто не пройде». У реальному API хочеться бодай розділити його на зони, і саме тут зʼявляється requestMatchers(...).
2. Модель requestMatchers
Щоб authorizeHttpRequests перестав виглядати як набір заклинань, корисно тримати в голові просту картинку: кожне правило складається з двох частин. Перша відповідає на запитання «які запити ми зараз описуємо?», а друга — «що ми вимагаємо від користувача для цих запитів?».
requestMatchers(...) — це як вказівник на групу запитів. Він обирає запити за шляхом URL (path), а далі ви призначаєте для вибраної групи вимогу: permitAll(), authenticated() або щось більш привілейоване (в іншій лекції). Важливо: на цьому етапі ми не заглядаємо в JSON-тіло запиту, не аналізуємо, «що там за користувач у базі», і не читаємо query-параметри. Ми просто відповідаємо на одне запитання: чи потрапляє запит у цю групу URL.
Для наочності можна уявити це так:
flowchart TD
R[HTTP-запит] --> A[authorizeHttpRequests]
A --> M{"requestMatchers збігся?"}
M -->|Так| D["застосовуємо правило: permitAll/authenticated/..."]
M -->|Ні| N[переходимо до наступного правила]
Зверніть увагу: requestMatchers — це не «авторизація», а вибір цілі. Він не вирішує, чи можна входити; він вирішує, «на які двері ми зараз дивимося».
3. Точний шлях: зіставляємо один конкретний URL
Найзрозуміліший і найбезпечніший варіант — точний шлях. Це ситуація «ось конкретні двері, і на них конкретний замок». Такий варіант зручний для окремих кінцевих точок, які ви хочете відкрити або захистити окремо від усього іншого.
Наприклад, у нас є особиста кінцева точка /api/me. Ми хочемо, щоб вона була доступною лише автентифікованому користувачеві. Конфігурація виглядає дуже читабельно:
http.authorizeHttpRequests(auth -> auth
// Точний збіг: правило застосовується лише до /api/me
.requestMatchers("/api/me").authenticated()
// Усе інше залишаємо публічним (щоб приклад був контрастним)
.anyRequest().permitAll()
);
Тут важливі два практичні моменти, які новачки часто додумують неправильно.
Перший момент: matcher дивиться на шлях, тобто на частину URL після хоста й порту. Якщо клієнт викликав http://localhost:8080/api/me, то шлях — це /api/me. Ми не пишемо в matcher http://localhost:8080 — це не те місце.
Другий момент: точний шлях — справді точний. Якщо у вас є /api/me/profile, він не збігатиметься з /api/me. Так само /api/me/ (із кінцевим слешем) може бути окремою адресою. На практиці краще вибрати єдиний стиль URL (зазвичай без trailing slash) і дотримуватися його, щоб не влаштовувати собі квест: «чому один URL працює, а інший — ні?».
4. Групи URL: шаблон /** як «піддерево» API
Майже будь-який проєкт швидко приходить до того, що кінцевих точок багато, і захищати кожну точним matcherʼом — це як охороняти місто, ставлячи по охоронцю біля кожних дверей. Працює, але втомитеся ви раніше, ніж зловмисник. Тому в конфігурації потрібні групи URL.
Найкорисніше й найчастіше вживане групування — це піддерево шляху, тобто «все, що починається з цього префікса». У Spring Security це зазвичай виражається шаблоном /**. Наприклад, публічна зона проєкту — це все, що починається з /api/public/.
http.authorizeHttpRequests(auth -> auth
// Усе «під» /api/public/ — публічне
.requestMatchers("/api/public/**").permitAll()
// Будь-який інший URL — вимагає входу
.anyRequest().authenticated()
);
Читається майже як українською: «усе під /api/public/** — публічне, усе інше — вимагає входу».
У практичному сенсі /** зручно читати як «піддерево цього префікса». Тобто /api/public/articles, /api/public/articles/hello-world, /api/public/anything/really/deep — усе це добре лягає в одну зону замість десятка окремих правил.
І ось тут зʼявляється тонкість, через яку в багатьох виникає перше відчуття: «Spring Security мене ненавидить особисто». Суть проста: якщо вам важлива повністю однозначна політика, кореневу кінцеву точку зони та її вкладені шляхи краще розрізняти явно.
5. Різниця /api/public і /api/public/**
Легко подумати, що якщо ви відкрили /api/public/**, то автоматично розібралися і з /api/public. Але тут краще не запамʼятовувати одну «магічну формулу» на всі випадки. Точна семантика залежить від того, який matcher бере участь у конфігурації і як у застосунку зіставляються шляхи.
Для прикладної роботи корисніше інше правило: якщо зона включає і кореневу кінцеву точку, і вкладені шляхи, і ви не хочете залишати місце для двозначності, перелічіть обидва явно. Так, це на один рядок довше. Зате через місяць ви самі собі скажете дякую, а не: «хто знову домовився з URL на словах, а не в коді?».
Цю різницю зручніше тримати не як «універсальний закон про /**», а як практичний чек-лист:
| Matcher | Що він описує без двозначності | Коли додати сусідній matcher |
|---|---|---|
| "/api/public" | кореневу кінцеву точку /api/public | Коли в зони є ще вкладені шляхи |
| "/api/public/**" | вкладені шляхи під /api/public/ | Коли коренева /api/public теж має явно входити в ту саму зону |
| "/api/me" | кореневу кінцеву точку /api/me | Коли поруч є /api/me/profile, /api/me/avatar та інші вкладені URL |
| "/api/me/**" | вкладені шляхи особистої зони | Коли коренева /api/me теж має читатися як частина тієї самої політики |
Звідси народжується практичний прийом: якщо вам потрібно покрити і корінь, і піддерево, просто перелічіть обидва явно. Компʼютер — буквальне створіння, тож краще не сперечатися з ним подумки, а написати так, щоб політика читалася однозначно.
http.authorizeHttpRequests(auth -> auth
// Явно відкриваємо корінь публічної зони
.requestMatchers("/api/public").permitAll()
// І окремо відкриваємо все під цим префіксом
.requestMatchers("/api/public/**").permitAll()
// Усе інше — закрите
.anyRequest().authenticated()
);
З точки зору читача конфігурації це навіть плюс: ви прямо показуєте, що /api/public — окрема кінцева точка, а все під нею — окрема група.
Якщо покладатися лише на інтуїцію, легко отримати напівпублічну або напівзакриту зону. Для клієнта API це виглядає як: «а у вас публічна частина публічна лише частково». Не найкращий комплімент вашому сервісу.
6. Кілька шляхів в одному правилі: коли це робить конфіг читабельнішим
Іноді видно, що дві-три кінцеві точки належать до однієї зони за змістом, але не лежать «гарно» в одному піддереві. Або лежать, але ви хочете підкреслити, що вони — єдине ціле. Тоді зручно передати в requestMatchers(...) кілька шляхів за один раз.
Наприклад, у проєкті може бути кілька публічних кінцевих точок: список статей і читання статті за slug. Формально вони обидві в /api/public/**, але іноді хочеться явно зафіксувати: «ось ці дві кінцеві точки публічні», особливо на ранньому етапі навчання.
http.authorizeHttpRequests(auth -> auth
// Кілька шаблонів в одному правилі — коли вони логічно про одне й те саме
.requestMatchers("/api/public/articles", "/api/public/articles/*").permitAll()
// Усе інше закриваємо
.anyRequest().authenticated()
);
Тут я показав ще один елемент синтаксису — * як «один сегмент шляху». Це не обовʼязкова частина сьогоднішньої теми, але знати її корисно: * — це один шматок між слешами, а ** — скільки завгодно шматків. Якщо від цього стало трохи складніше — не переживайте: базова схема думок усе одно та сама. Точний шлях — для однієї адреси, /** — для піддерева.
При цьому важливо не перетворювати requestMatchers на «мішок випадкових рядків». Якщо ви перелічуєте десять шляхів в одному рядку, тому що «так менше коду», — ви економите на коді й витрачаєте на розуміння. Зазвичай краще групувати або за піддеревами, або за невеликими логічними наборами.
7. Що matchers не враховують
Новачки часто очікують від цих правил більше, ніж вони обіцяли. Це нормально: мозок хоче, щоб один інструмент вирішував усе. Але requestMatchers — інструмент вузький і чесний: він обирає запити за шляхом. Він не є «розумним фільтром» для всього запиту.
Найчастіша плутанина — query-параметри. Наприклад, запит:
GET /api/public/articles?sort=desc&page=2
З погляду правила шлях усе одно /api/public/articles. Query-параметри sort, page зазвичай не входять до умови збігу. Тому правило:
// Зіставляється за path, а не за query string
.requestMatchers("/api/public/articles").permitAll()
спрацьовуватиме і для .../articles?page=2. Це зазвичай добре: ви ж не хочете писати окреме правило на кожен варіант пагінації. Але це важливо розуміти, щоб не намагатися «сховати» щось за параметром на кшталт ?admin=true. Spring Security не ведеться на такі фокуси, і вам теж не варто.
Друга плутанина — тіло запиту. Якщо до вас приходить POST /api/drafts з JSON { "title": "...", "authorId": 123 }, matcher не заглядатиме всередину JSON і не перевірятиме, правильний там authorId чи ні. На рівні URL-правил ви можете вирішити лише: «цей endpoint вимагає входу/ролі/права». А перевірки рівня «це твій обʼєкт чи чужий» — це зовсім інша природа правила, і ми сьогодні туди не ліземо.
Третя плутанина — «matcher перевіряє контролер». Ні, matcher не порівнює шлях із @GetMapping, він порівнює шлях запиту з вашим шаблоном. Те, що у вас десь є контролер із таким мапінгом, само по собі не означає, що matcher щось зрозуміє. Він бачить лише запит.
8. Вбудовуємо в Secure Content Platform API: публічна зона і особиста зона
Зараз ми зберемо маленький, але вже змістовний шматок конфігурації для нашого проєкту. Логіка проста: публічні статті доступні всім, особиста зона вимагає автентифікації. Ми не обговорюємо ролі, привілеї та адмінку — нам важливо саме навчитися влучати правилами в потрібні URL.
Почнемо з мінімального контролера, щоб було що захищати (це навчальна заглушка, не фінальна бізнес-логіка):
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
// Публічний контролер: кінцеві точки з публічної зони
@RestController
class PublicController {
// Простий health-подібний ping, щоб перевірити доступ без входу
@GetMapping("/api/public/ping")
String ping() {
return "публічний pong";
}
}
І особиста кінцева точка:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
// Особистий контролер: кінцеві точки з зони «me»
@RestController
class MeController {
// Точка входу в особисту зону: цей URL має вимагати автентифікацію
@GetMapping("/api/me")
String me() {
return "приватний pong";
}
}
Якщо каркас ланцюжка вже зібрано, то потрібна нам розвилка виглядає так:
http.authorizeHttpRequests(auth -> auth
// Дозволяємо все, що лежить у публічній зоні API
.requestMatchers("/api/public/**").permitAll()
// Особиста зона: коренева кінцева точка і все, що під нею, вимагають автентифікації
.requestMatchers("/api/me", "/api/me/**").authenticated()
// Інші URL поки теж не робимо публічними
.anyRequest().authenticated()
);
Що вийшло в підсумку за змістом, якщо читати це очима:
— публічна зона доступна всім;
— коренева кінцева точка особистої зони і всі вкладені шляхи вимагають автентифікації;
— усе інше ми поки теж не робимо публічним.
Це вже не «усе закрито» і не «усе відкрито». Це перша маленька карта доступу, яку можна розширювати далі, залишаючись в одному зрозумілому стилі.
І так, ви можете перевірити, що правило ігнорує query-параметри:
curl -i "http://localhost:8080/api/public/ping?x=1"
Запит усе одно потрапить під /api/public/** і вважатиметься публічним, тому що шлях — /api/public/ping.
9. Типові помилки під час роботи з requestMatchers
У requestMatchers немає «складної магії», але буквальності там багато. І більшість помилок — це не «Spring Security дивний», а «я сам написав не те, що хотів, а прочитав так, ніби написав те». Нижче — найчастіші граблі, на які наступають майже всі (включно зі мною в минулому, коли я теж був молодий і вірив, що /** врятує світ).
Помилка №1: вважати, що "/api/me" і "/api/me/**" — це одне й те саме.
Це різні області збігу. Точний шлях описує одну адресу, а /** — усе всередині піддерева. Тому буває, що /api/me/profile захищений, а /api/me — ні (або навпаки). Правило просте: якщо вам потрібні і корінь, і піддерево, описуйте обидва явно, не покладаючись на інтуїцію.
Помилка №2: очікувати, що matcher враховує query-параметри.
Запити /api/public/articles?page=1 і /api/public/articles?page=999 зіставляються однаково, тому що шлях у них один і той самий. Спроба «сховати» смислову різницю в query-параметрах і сподіватися, що security зрозуміє, — це шлях до вразливостей і дивної поведінки клієнтів.
Помилка №3: забути початковий слеш і написати "api/public/**".
Шляхи в цих правилах пишуться як звичайні URL-шляхи — майже завжди з початковим /. Без нього ви отримуєте шаблон, який не відповідає вашим реальним запитам. Це дрібна описка, але вона легко перетворюється на «чому в мене все раптом закрилося/відкрилося?».
Помилка №4: збирати «мішок рядків» замість зон доступу.
Якщо в requestMatchers(...) ви перелічуєте десять не повʼязаних шляхів, ви робите конфігурацію короткою, але нечитабельною. За тиждень ви самі не згадаєте, чому в одному рядку опинилися /api/public/articles, /api/me, /health і /api/admin/users. Rules мають відображати зони: public, me, admin — тобто зміст, а не випадковість.
Помилка №5: ігнорувати стиль URL і отримати «двійників» /api/me і /api/me/.
Технічно це різні адреси. Іноді інфраструктура (проксі, клієнт, браузер) додає або прибирає слеш у кінці, і ви ловите «то працює, то не працює». Найпростіший спосіб жити спокійно — тримати єдиний стиль URL у застосунку й не робити «подвійні» мапінги без потреби.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ