1. Зв’язка formLogin і матриці доступу
Найчастіша пастка новачків звучить так: «Ну я ж додав formLogin(), отже тепер усе «по-дорослому» захищено». Насправді це трохи схоже на домофон у під’їзді й висновок, що всі проблеми безпеки у квартирі вже вирішені. Домофон — це лише вхід до будівлі. Матриця доступу — це те, які двері всередині взагалі відчиняються і кому.
formLogin відповідає на запитання «як стати автентифікованим?» — тобто пройти перевірку логіна й пароля та перейти у стан автентифікованого користувача. А матриця доступу відповідає на запитання «що можна робити після того, як ви увійшли?». Ці два шари мають працювати разом, але не змішуватися: вхід не повинен підміняти собою правила доступу, а правила доступу не повинні випадково блокувати вхід і вихід.
У нашому проєкті Secure Content Platform API це видно особливо добре. У нас є публічні статті, особиста зона (/api/me/**), привілейовані зони редактора (/api/editor/**) та адміністратора (/api/admin/**). formLogin робить шлях до цих зон зрозумілим для браузера, але самі зони, як і раніше, мають залишатися під контролем суворих URL-правил.
2. Зони доступу та поведінка без входу
Перш ніж писати конфігурацію, корисно на хвилину зупинитися і «покласти політику на стіл». Spring Security чудово вміє зачиняти двері, але не вміє читати думки. Якщо ви не сформулювали, що саме і як захищати, отримаєте або надто закриту систему, або надто відкриту: у першому випадку навіть публічні статті вимагатимуть логіна, у другому — адмінка стане доступною будь-кому, хто увійшов.
Нижче — мінімальна матриця доступу для цього етапу курсу: поведінка зручна для сесії й браузера, а вбудована сторінка входу цілком допустима. Зверніть увагу: ми навмисно тримаємося в межах чотирьох зон і не розширюємо домен.
| Зона | Приклади endpoint’ів | Хто має мати доступ | Що відбувається в браузері без входу |
|---|---|---|---|
| Public | GET /api/public/articles, GET /api/public/articles/{slug} | Будь-хто, включно з анонімним користувачем | 200 OK одразу, без редиректів |
| Me (особиста зона) | GET /api/me, (пізніше /api/me/profile, /api/me/avatar) | Будь-який автентифікований користувач | редирект на /login |
| Editor | /api/editor/** | Лише користувач із роллю EDITOR | редирект на /login (якщо anonymous), або 403 (якщо увійшов, але не editor) |
| Admin | /api/admin/** | Лише користувач із роллю ADMIN | редирект на /login (якщо anonymous), або 403 (якщо увійшов, але не admin) |
| Вхід/вихід | /login, /logout | Мають бути доступні | вхід має відкриватися завжди, інакше ви «заблокуєте двері в охорону» |
На цьому етапі ролі вважаємо незалежними. Тобто ADMIN не починає автоматично підпадати під hasRole("EDITOR"), доки ви явно не надасте йому обидві ролі або не налаштуєте ієрархію ролей.
Цю саму думку зручно побачити як невеликий сценарний граф:
flowchart TD
A["Анонімний користувач"] -->|GET /api/public/articles| P["200 OK"]
A -->|GET /api/me| L["302 -> /login"]
L -->|"POST /login (облікові дані)"| S["Автентифікований"]
S -->|GET /api/me| M["200 OK"]
S -->|"GET /api/admin/users (роль USER)"| F["403 Forbidden"]
S -->|"GET /api/admin/users (роль ADMIN)"| AD["200 OK"]
Тут немає жодної «магії JWT», немає складних протоколів. Є зрозуміла інженерна логіка: спочатку вхід, потім правила.
3. SecurityFilterChain як карта доступу
Коли проєкт росте, security-конфігурація зазвичай ламається двома способами. Перший — її починають «лагодити» точковими рядками, і вона перетворюється на шаровий пиріг із requestMatchers, де вже ніхто не розуміє, що реально відкрито. Другий — її бояться чіпати, бо незрозуміло, що зламається. Гарна новина: на нашому поточному рівні це лікується дисципліною і читабельністю.
Ідея проста: ми пишемо SecurityFilterChain так, щоб його можна було прочитати зверху вниз як оголошення політики доступу. Спочатку — публічне, потім особиста зона, далі — привілейовані зони, а в самому кінці — «запобіжник» denyAll().
Нижче приклад цілісної конфігурації для нашого навчального проєкту. Вона спеціально трохи явніша, ніж «найкоротше, що працює»: ми залишаємо /error відкритим, а вхід і вихід відкриваємо безпосередньо через formLogin(...).permitAll() і logout(...).permitAll(). Так карта доступу читається зверху вниз, а login/logout не розповзаються по двох місцях.
package com.example.securecontent.security.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// Публічно оголошуємо «карту доступу» за URL: її зручно читати зверху вниз.
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/error").permitAll() // службовий маршрут краще не закривати наглухо
.requestMatchers("/api/public/**").permitAll() // публічна зона
.requestMatchers("/api/me/**").authenticated() // особиста зона: будь-який автентифікований користувач
.requestMatchers("/api/editor/**").hasRole("EDITOR") // зона редактора: потрібна роль EDITOR
.requestMatchers("/api/admin/**").hasRole("ADMIN") // зона адміністратора: потрібна роль ADMIN
.anyRequest().denyAll() // запобіжник: усе інше закрито
);
// formLogin відповідає лише за "як увійти", а не за права доступу до URL.
http.formLogin(form -> form
.defaultSuccessUrl("/api/me", true) // після входу переходимо в /api/me — зручно для перевірки
.failureUrl("/login?error") // робимо помилку видимою в браузері
.permitAll() // сторінка входу й обробка входу доступні анонімним користувачам
);
// logout теж має бути досяжним, інакше ви закриєте двері зсередини.
http.logout(logout -> logout
.logoutSuccessUrl("/login?logout")
.permitAll() // потік виходу відкриваємо разом із самим logout
);
return http.build();
}
}
Якщо ви пізніше змінюєте loginPage, loginProcessingUrl або додаєте ресурси своєї сторінки входу, ці шляхи можна відкрити і явно через requestMatchers(...). Але для поточного базового рівня простіше тримати вхід і вихід поруч із formLogin і logout: так менше шансів розʼїхатися у двох місцях конфігурації.
Тут важливо вловити кілька сенсів, не забігаючи наперед. По-перше, formLogin не «відкриває» вам editor чи admin. Він лише дає спосіб стати автентифікованим користувачем. По-друге, hasRole("ADMIN") і hasRole("EDITOR") — це вже авторизація, тобто перевірка права доступу. По-третє, denyAll() в кінці — це зручний спосіб не відкрити випадково нові endpoint’и, які ви додасте завтра і забудете захистити. Spring Security і так любить secure-by-default, але коли ви самі пишете карту доступу, «запобіжник» не зайвий.
Нарешті, маленький практичний нюанс: дозвіл /error часто економить нерви. Якщо ви повністю закрили все і не дозволили error-маршрут, іноді можна отримати дивні «петлі» й неочевидні відповіді. Це не «фіча дня», а просто спокійний базовий рівень для навчального проєкту.
4. Перевірка матриці
Облікові записи для перевірки матриці: USER, EDITOR, ADMIN
Коли ми перевіряємо доступ, нам потрібні не абстрактні «користувач із роллю X», а конкретні облікові записи, під якими можна зайти в браузер і вручну пройти сценарій. Облікові записи в памʼяті — ідеальний варіант для цього етапу: вони прибирають шум бази даних і дають змогу зосередитися на механіці formLogin та URL-правил. А ще вони швидко показують, де ви самі себе обманули в конфігурації.
Ось приклад UserDetailsService для поточного етапу. Пароль password однаковий спеціально: ми вчимося механіці Spring Security, а не тренуємо пам’ять на «вгадай пароль редактора».
package com.example.securecontent.security.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
@Configuration
public class InMemoryUsersConfig {
@Bean
UserDetailsService userDetailsService(PasswordEncoder encoder) {
// Три облікові записи — мінімальний набір для перевірки всієї матриці (user/editor/admin).
// Ролі тут незалежні, без прихованої ієрархії.
UserDetails user = User.builder()
.username("user")
// Пароль кодуємо тим самим PasswordEncoder, який налаштований у застосунку.
.password(encoder.encode("password"))
.roles("USER")
.build();
UserDetails editor = User.builder()
.username("editor")
.password(encoder.encode("password"))
.roles("EDITOR")
.build();
UserDetails admin = User.builder()
.username("admin")
.password(encoder.encode("password"))
.roles("ADMIN")
.build();
// InMemoryUserDetailsManager зберігає користувачів у памʼяті: зручно для ручної перевірки в браузері.
return new InMemoryUserDetailsManager(user, editor, admin);
}
}
Тут варто проговорити — і запам’ятати — одну річ, яка потім багато разів рятуватиме вас від «чому 403?!»: метод roles("ADMIN") додає роль у форматі ROLE_ADMIN. Тому hasRole("ADMIN") очікує рядок без префікса. Якщо написати hasRole("ROLE_ADMIN"), ви отримаєте дивне відчуття, що роль «є, але не працює». Спойлер: роль є, ви просто порівнюєте різні рядки.
Мінімальні контролери зон
Дуже легко «налаштувати security» і не зрозуміти, чи вона працює, якщо endpoint’и нічого не повертають. На поточному кроці курсу нам корисні контролери-заглушки, які повертають простий текст і, де доречно, ім’я поточного користувача. Це дає миттєвий зворотний зв’язок: ви бачите не лише «доступ є», а й «хто саме зараз вважається користувачем».
Приклад публічного endpoint’а:
package com.example.securecontent.content;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class PublicArticlesController {
@GetMapping("/api/public/articles")
String list() {
// Заглушка для перевірки permitAll(): важливо бачити "200 OK" без входу.
return "публічні статті"; // заглушка: тут згодом буде реальний список
}
}
Приклад особистої зони /api/me, де ми отримуємо поточного користувача через Authentication (це найпростіший «термометр» того, що SecurityContext справді заповнений):
package com.example.securecontent.profile;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class MeController {
@GetMapping("/api/me")
String me(Authentication authentication) {
// Authentication приходить із SecurityContext: якщо ви не увійшли, security не пустить вас сюди.
return "поточний користувач: " + authentication.getName();
}
}
Зона редактора (знову ж, примітивно, але показово):
package com.example.securecontent.content;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class EditorController {
@GetMapping("/api/editor/review-queue")
String queue(Authentication authentication) {
// Цей endpoint має працювати лише для ролі EDITOR.
return "черга на перегляд для редактора: " + authentication.getName();
}
}
Зона адміністратора:
package com.example.securecontent.admin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class AdminController {
@GetMapping("/api/admin/users")
String users() {
// Тут достатньо простого тексту: важливо побачити, що USER/EDITOR отримують 403, а ADMIN — 200.
return "список користувачів лише для адміністратора (заглушка)";
}
}
Це все ще один застосунок і один домен. Ми не робимо окремі «демо-контролери» заради курсу: ми просто поки що повертаємо рядки, щоб бачити поведінку. Потім ці endpoint’и повертатимуть нормальні DTO, але сьогодні важливіше побачити security-сенс, а не красу JSON.
Перевірка матриці в браузері: очікувана поведінка
Перевірка через браузер — це не «розвага» і не «просто потикати». Це найшвидший спосіб наочно відчути різницю між станами anonymous і authenticated та побачити, як formLogin реально стає мостом до ваших захищених endpoint’ів. Особливо корисно перевіряти в режимі інкогніто, щоб не заплутатися в збереженому стані.
Нижче — зручна таблиця «що має відбутися», якщо ви підняли застосунок локально на стандартному http://localhost:8080:
| Сценарій | Хто ви зараз | Куди йдете | Очікування | Чому так |
|---|---|---|---|---|
| Публічне читання | анонімний користувач | GET /api/public/articles | 200 OK і текст публічні статті | це permitAll() |
| Спроба зайти в особисту зону без входу | анонімний користувач | GET /api/me | редирект на /login | endpoint потребує authenticated() |
| Успішний вхід | вводимо user/password | /login | після входу потрапляємо на /api/me | defaultSuccessUrl("/api/me", true) |
| Спроба зайти в editor як звичайний user | увійшли як user | GET /api/editor/review-queue | 403 Forbidden | автентифікація є, ролі немає |
| Вхід редактором | вводимо editor/password | /login | після входу: поточний користувач: editor | той самий потік, інший обліковий запис |
| Editor зона | увійшли як editor | GET /api/editor/review-queue | 200 OK | hasRole("EDITOR") |
| Спроба editor зайти в адмінку | увійшли як editor | GET /api/admin/users | 403 Forbidden | не та роль |
| Вхід адміністратором | вводимо admin/password | /login | після входу: поточний користувач: admin | тепер маєте роль ADMIN |
| Адмінка | увійшли як admin | GET /api/admin/users | 200 OK | hasRole("ADMIN") |
Важливий момент, який варто проговорити прямо: у цій гілці поведінки редиректи на /login — це нормально. Ми свідомо використовуємо модель, зручну для браузера, тому що вона найкраще показує механіку. Коли ви перевіряєте той самий API не в браузері, а, наприклад, у Postman, редиректи й HTML можуть виглядати незвично — але це саме «браузерна семантика». На цьому етапі курсу нас цікавить її наочність.
5. Ризик спільного authenticated() для /api/**
Мозок розробника любить спрощувати. І одна з найспокусливіших думок звучить так: «Ну, давайте все під /api/** зробимо authenticated() — і готово». Це справді зручно… рівно до першої адміністративної операції. Після цього виявляється, що у вас «захищено», але захищено занадто грубо: будь-який користувач, який увійшов, стає майже адміністратором просто тому, що URL-патерн у вас широкий.
Ось класичний «майже правильний» варіант, який часто з’являється в проєкті на емоціях:
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/public/**").permitAll()
.requestMatchers("/api/**").authenticated() // Занадто широке правило: захопить і /api/admin/** теж.
.anyRequest().denyAll()
);
return http.build();
}
Якщо у вас є GET /api/admin/users, він теж потрапляє під /api/**. І якщо ви не додали точніше правило вище, адмінка стане доступною будь-якому користувачу, який увійшов. Так, увійшовшому — але це все одно величезна діра для нашої матриці доступу.
Коректна логіка тут проста: спочатку правила для привілейованих зон, потім більш загальні. Приклад:
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/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN") // Спочатку найсуворіше.
.requestMatchers("/api/editor/**").hasRole("EDITOR") // Потім редакторська зона.
.requestMatchers("/api/me/**").authenticated() // І лише потім "звичайна" особиста зона.
.anyRequest().denyAll()
);
return http.build();
}
Зверніть увагу, що ми навіть не використали /api/** як загальний патерн. І це нормально. У маленькому навчальному проєкті краще мати кілька явних рядків, ніж один «універсальний» рядок, який потім випадково перетворюється на «універсальну діру».
6. Типові помилки access matrix і formLogin
Ця частина здається нудною рівно до першого разу, коли ви втрачаєте пів години на «чому мене редиректить на логін, хоча я вже увійшов». Помилки тут майже завжди не в «складній криптографії», а в дрібних невідповідностях між наміром — матрицею доступу — і фактом: порядком matcher’ів і занадто широкими правилами. Гарна новина: усі ці помилки діагностуються логікою, а не шаманством.
Помилка №1: «Закрили» /login або /logout своїм же denyAll().
Це виглядає комічно: ви поставили охоронця, але заборонили людям заходити в будку охорони. Зазвичай це трапляється, коли конфігурація закінчується anyRequest().denyAll(), а поруч із formLogin або logout зник permitAll(). Якщо ви змінювали loginPage, loginProcessingUrl або logoutUrl, перевірте, що відкриті саме нові точки, а не лише дефолтні /login і /logout.
Помилка №2: занадто широке правило «усе під /api/** — просто authenticated()».
Так ви неминуче зруйнуєте межу editor/admin зон. Спочатку це навіть здається зручним: «ну хоч працює». Потім ви додаєте адміністративний endpoint і раптово розумієте, що роль ADMIN у вас декоративна. На фундаментальному рівні це лікується лише дисципліною: спочатку суворіші зони, потім більш загальні.
Помилка №3: плутанина hasRole і hasAuthority (і особливо префікса ROLE_).
Якщо користувача створено через .roles("ADMIN"), його authority буде ROLE_ADMIN. Для перевірки ролі використовуйте hasRole("ADMIN"), без префікса. Якщо використати hasAuthority("ADMIN"), ви перевірятимете рядок ADMIN, якого в користувача немає. І це буде той самий випадок: «у нього ж є адмін, чому 403?».
Помилка №4: спроба пояснити все одним користувачем.
Іноді студент тестує лише user/user і робить висновок «усе працює». Потім приходить сценарій editor/admin — і виявляється, що конфігурацію не перевірено у важливих гілках. На цьому етапі дешеві ліки — три облікові записи: user, editor, admin. Це не «зоопарк», це мінімальний тестовий набір для вашої матриці.
Помилка №5: змішали в голові «хто увійшов» і «що можна».
Authentication.getName() і взагалі «поточний користувач» — це про автентифікацію. hasRole(...) і заборони доступу — це про авторизацію. Коли ви починаєте перевіряти ролі «в контролері через if», бо «так швидше», ви втрачаєте те, заради чого будували карту доступу. Сьогодні тримаємо просте правило: вхід робить користувача автентифікованим, а доступ до зон, як і раніше, контролює authorizeHttpRequests.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ