1. Що саме переводимо в stateless
Коли ви чуєте слово stateless, дуже хочеться зробити різкий рух: «Усе, сесії скасовуємо, куки викидаємо, завтра живемо по-новому». Але в реальному проєкті так зазвичай не роблять: система складається із зон, і в кожної зони є свій тип клієнта та свої очікування. Нам, як уважним інженерам, завдання скромніше й корисніше: перевести API‑зону в stateless baseline так, щоб решта логіки застосунку не перетворилася на кашу.
У нашому Secure Content Platform API «API‑зона» — це, по суті, усе під /api/**. Там живуть публічні читання, особиста зона, чернетки, редакторські операції та адмінка. Головна думка лекції: ми не змінюємо саму матрицю доступу (хто куди має право), ми змінюємо лише те, на що сервер спирається між запитами, коли намагається зрозуміти, від кого саме прийшов запит.
Щоб це не здавалося надто абстрактним, зафіксуймо все в невеликій таблиці. Вона не про JWT і не про токени, а тільки про зони та правила — те, що має залишитися незмінним.
| Зона | Приклади кінцевих точок | Доступ (за задумом) |
|---|---|---|
| Публічна | GET /api/public/articles | permitAll() |
| Вхід | POST /api/auth/login | permitAll() (точка входу має бути публічною) |
| Мій профіль | GET /api/me, PATCH /api/me/profile | authenticated() + внутрішня перевірка власника |
| Чернетки | GET /api/drafts, PATCH /api/drafts/{id} | authenticated() + перевірки власника |
| Редактор | POST /api/editor/drafts/{id}/publish | роль EDITOR (і вище) |
| Адміністрування | PATCH /api/admin/users/{id}/lock | роль ADMIN |
Якщо ви спіймаєте себе на думці «у stateless треба переробити ролі, права і @PreAuthorize» — це чудовий сигнал зупинитися й видихнути. Stateless — це не про «змінили правила». Stateless — це про «змінили спосіб входу в ті самі правила».
Якщо зібрати поточний стан в одну фразу: /api/** більше не спирається на серверну пам’ять аутентифікації, /api/auth/login залишається публічною точкою входу, а спосіб підтвердити кожен наступний захищений запит ми ще не вибрали. Це і є чесний стан API‑зони на цьому кроці.
2. Межа між session і stateless
Найнебезпечніша архітектурна помилка тут — спробувати жити у двох світах одночасно без явної межі. Це як носити зимову куртку й шорти в одну й ту саму погоду: формально можна, але оточення ставитиме запитання, а ви самі почнете одночасно мерзнути й пітніти. Нам важливо чітко сказати: «Ось стан проєкту з session-based моделлю, ось стан проєкту зі stateless API».
У проєкті є два прості способи не змішувати моделі. Перший — розвести їх по різних конфігураціях: session-based і stateless не мають зустрічатися в одному SecurityFilterChain. Другий — використовувати Spring Profiles і явно вмикати потрібну конфігурацію через spring.profiles.active. Profiles тут зручні тим, що ізолюють два різні режими: попередню session-гілку і новий stateless-варіант, а не намагаються втримати в одному ланцюжку все одразу.
Покажу варіант із profiles, бо новачкам його найпростіше читати.
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
@Configuration
@Profile("security-session")
class SessionSecurityConfig {
// Ця конфігурація вмикається лише в режимі session-based.
// Тут залишається «вчорашня» безпека з HttpSession.
}
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
@Configuration
@Profile("security-stateless")
class StatelessSecurityConfig {
// Ця конфігурація вмикається лише в режимі stateless baseline.
// Тут ми спеціально відключаємо опору на HttpSession.
}
Профіль можна вмикати хоча б так (приклад для локального запуску):
spring:
profiles:
active: security-stateless
Чому я так наполягаю на «межі»? Бо змішування зазвичай ламає все одразу: десь з’являється HTML-сторінка входу в JSON API, десь JSESSIONID раптово з’являється «бо так вийшло», а десь ви вимикаєте CSRF глобально, «щоб Postman не сварився». У результаті ви не вивчаєте модель безпеки — ви вивчаєте магію та ритуали. А ми тут якраз проти магії.
3. Налаштування SecurityFilterChain у stateless-режимі
Зараз буде найнасиченіший фрагмент — зміни в SecurityFilterChain. Важливо налаштувати все так, щоб API‑зона перестала розраховувати на серверну session-пам’ять, але водночас URL-правила, ролі та захист на рівні методів залишилися читабельними. Якщо зробити це акуратно, проєкт не розвалиться; він просто почне чесно вимагати аутентифікацію на кожному захищеному запиті.
Почнімо з базового каркаса: ми явно описуємо доступ за шляхами і вмикаємо SessionCreationPolicy.STATELESS. У прикладі я навмисно не додаю все одразу, щоб ви бачили, де саме знаходиться ключовий перемикач.
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
@Bean
SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception {
// Ключовий перемикач: не створювати й не використовувати HttpSession для SecurityContext.
http.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
// Важливо: сам по собі STATELESS не “логінить” користувача — він лише відключає “пам’ять про логін”.
return http.build();
}
Цей фрагмент робить одну річ: каже Spring Security «не спирайся на HttpSession як на сховище результату аутентифікації». Сама аутентифікація при цьому нізвідки не з’являється. Тобто STATELESS — це не «увімкнути логін», а «вимкнути пам’ять про логін».
Тепер додаймо наші правила доступу. Тут ми переносимо вже знайому матрицю на рівні запитів: public і login — публічні, editor/admin — за ролями, решта — тільки для аутентифікованих.
import org.springframework.security.web.SecurityFilterChain;
// Правила доступу за URL: public/login відкриті, editor/admin за ролями, решта — лише для authenticated.
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**", "/api/auth/login").permitAll() // login має бути доступним без аутентифікації
.requestMatchers("/api/editor/**").hasRole("EDITOR") // ролі перевіряються на рівні фільтрів
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated() // усе, що не потрапило вище, закриваємо
);
Зверніть увагу на деталь, яка постійно ламає проєкти в новачків: /api/auth/login має бути явно permitAll(). Якщо ви закриєте кінцеву точку входу, застосунок буде дуже безпечним… просто тому, що ніхто не зможе увійти. Це як поставити двері сейфа перед входом у сейф і викинути ключ.
Тепер зберімо все в цілісніший фрагмент. Я показую майже фінальний варіант для stateless baseline. Профіль і пакет можете підлаштувати під свою структуру.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@Profile("security-stateless")
class StatelessSecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// Спочатку описуємо матрицю доступу за URL.
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**", "/api/auth/login").permitAll() // точка входу має бути публічною
.requestMatchers("/api/editor/**").hasRole("EDITOR")
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
);
// Потім явно фіксуємо stateless-поведінку (без зберігання SecurityContext у сесії).
http.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
}
На цьому кроці ми формально перевели API‑зону в stateless-режим. Але є ще одна річ, яку не можна відкотити назад: наш API вже налаштований віддавати JSON-помилки 401/403. Це має працювати й тут, інакше ви раптово побачите редиректи або HTML-сторінки, а Postman дивитиметься на вас німим осудом.
Якщо у вас уже є AuthenticationEntryPoint і AccessDeniedHandler з блоку про помилки, дружні до REST, ми просто підключаємо їх у exceptionHandling.
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
// Робимо помилки “API-сумісними”: без HTML/redirect, з очікуваними 401/403 у JSON-форматі.
http.exceptionHandling(ex -> ex
.authenticationEntryPoint(restAuthenticationEntryPoint) // 401: коли користувач не аутентифікований
.accessDeniedHandler(restAccessDeniedHandler) // 403: коли прав не вистачає
);
Тут важливо тримати фокус на самій зміні моделі. Сервер перестає спиратися на сесію і чесно вимагає, щоб кожен захищений запит можна було аутентифікувати заново. Конкретний спосіб перенесення цього підтвердження ми поки не змішуємо з цим кроком: спершу потрібно побачити сам ефект відмови від серверної пам’яті.
Якщо хочете вручну перевірити захищені кінцеві точки до появи такого підтвердження, можна тимчасово ввімкнути HTTP Basic як діагностичний міст. Це не модель, на якій зараз будується API‑зона; це просто спосіб перевірити, що STATELESS, ролі та JSON-помилки поводяться передбачувано.
import static org.springframework.security.config.Customizer.withDefaults();
// Тимчасовий діагностичний міст для ручної перевірки protected endpoints.
http.httpBasic(withDefaults());
Якщо ви вирішите додати цей міст, обов’язково проговоріть собі сенс: це не «ми передумали й назавжди обрали Basic», а «ми хочемо зараз перевірити, що stateless-режим працює, а авторизація за ролями та правилами не зламалася».
4. Поведінка API в stateless-режимі
Перехід у stateless майже завжди створює в новачка враження, що застосунок «зламався». І найпідступніше тут те, що він справді починає поводитися не так, як у session-світі. Але це не баг, а саме той ефект, який ми хотіли отримати: сервер перестає пам’ятати минуле, а отже, щасливе «я один раз увійшов — і все працює» більше не діє.
Подивімося на це на найпростішій кінцевій точці — /api/me. Вона була у нас і в session-гілці, і в загальному дизайні проєкту. Важливо: сам endpoint не змінюється, змінюється контекст доступу.
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class MeController {
@GetMapping("/api/me")
String me(@AuthenticationPrincipal UserDetails user) {
// У stateless-моделі цей user з’являється лише якщо запит приніс валідну аутентифікацію.
// Тобто “пам’ять про логін” тут не спрацює — потрібне підтвердження на кожному запиті.
return user.getUsername(); // наприклад: "alice"
}
}
У session-моделі сценарій виглядав так: ви входили через form login, сервер створював або використовував HttpSession, клав туди SecurityContext, і наступні запити з JSESSIONID автоматично вважалися «від того самого користувача».
У stateless baseline картина інша: якщо ви зробили POST /api/auth/login, сервер, звісно, може перевірити облікові дані через AuthenticationManager і повернути вам JSON-відповідь «так, ви пройшли аутентифікацію». Але на наступному GET /api/me, якщо клієнт не додав жодного підтвердження аутентифікації, Spring Security чесно скаже: «Вибачте, не знаю вас» — і поверне 401.
Ось мінімальна послідовність, яку корисно тримати в голові (без JWT, без деталей, лише логіка):
sequenceDiagram
participant C as Клієнт
participant S as Spring Security
C->>S: "POST /api/auth/login (імʼя користувача + пароль)"
S-->>C: "200 OK (відповідь на вхід)"
C->>S: "GET /api/me (без підтвердження)"
S-->>C: "401 Unauthorized (немає аутентифікації)"
Тобто до цього моменту картина навмисно неповна, але вже чесна: вхід як подія у нас є, серверної пам’яті більше немає, а сам наступний захищений запит ще має принести окреме підтвердження доступу. Саме звідси й виникає головне інженерне питання stateless-моделі: що клієнт прикладатиме до кожного захищеного запиту замість сесійної пам’яті.
Якщо у вас увімкнений HTTP Basic як тимчасовий міст, ви можете побачити, що /api/me знову починає працювати — але вже в stateless-логіці: облікові дані йдуть у кожному запиті, а сервер не має пам’ятати вас за сесією.
Приклад запиту, який добре показує різницю:
curl -i -u alice:password http://localhost:8080/api/me
І тут особливо приємно бачити, що @AuthenticationPrincipal не «зламався». Він працює так само: якщо в запиті є аутентифікований користувач, ви його отримуєте. Просто тепер у stateless-світі цей користувач має з’являтися на кожному запиті заново — із самих даних запиту.
А ось editor/admin-зони на цьому кроці мають поводитися дуже передбачувано: якщо ви не аутентифіковані — отримаєте 401, якщо аутентифіковані, але не EDITOR — отримаєте 403. Це якраз та сама дисципліна, яку ми вибудовували через AuthenticationEntryPoint і AccessDeniedHandler.
5. Захист на рівні методів і перевірки власника
Після перемикання в stateless у багатьох з’являється бажання «переробити все заново». Це дуже людське відчуття: якщо змінилася базова поведінка входу, здається, що і захист на рівні сервісів теж має змінитися. Але саме в цьому й сила правильної архітектури: method security, перевірки власника та доступ до поточного користувача залишаються на своїх місцях. Змінюється лише те, як Authentication з’являється в контексті поточного запиту.
Нагадую головний принцип: URL-правила — перша лінія оборони, а захист на рівні методів — захист бізнес-дій. Stateless не скасовує жодної з цих ліній. Те, що publish доступний лише EDITOR, і далі виражається там, де виконується publish-операція, — у сервісі.
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
@Service
class DraftService {
@PreAuthorize("hasRole('EDITOR')")
public void publish(Long draftId) {
// Бізнес-дію захищаємо на рівні сервісу, а не “переносимо” перевірки в контролер.
// Stateless змінює те, як з’являється Authentication, але не те, де живуть бізнес-правила.
}
}
Те саме стосується перевірок власника. Якщо ваш сервіс має гарантувати «користувач може оновити лише свою чернетку», це правило не має мігрувати в контролер тільки тому, що ви змінили модель зберігання стану аутентифікації. Так, у stateless-моделі вам потрібно на кожному запиті мати коректного поточного користувача, інакше перевірити право власності неможливо. Але це не означає, що логіка перевірки власника має переїхати в web-шар.
Важлива тонкість, яку варто окремо проговорити: SecurityContextHolder і @AuthenticationPrincipal не стають «забороненими». Вони залишаються нормальними інструментами. Просто тепер вам треба звикнути до думки: якщо запит прийшов без аутентифікації, то контролер узагалі не повинен виконуватися (його зупинить ланцюжок фільтрів раніше), або ви отримаєте Authentication як anonymous/empty (залежно від конфігурації). Тобто проблема не в тому, що Spring зламав вашого поточного користувача, а в тому, що в stateless ви більше не можете розраховувати на пам’ять про вчорашній логін.
6. Типові помилки під час переведення проєкту в stateless baseline
Наприкінці цієї лекції корисно зафіксувати граблі, на які найчастіше наступають. І хороша новина: більшість із них не про «складну безпеку», а про просту неуважність і спробу змішати дві моделі одразу.
Помилка №1: закрити /api/auth/login разом з усіма захищеними endpoint’ами.
Це класика: ви пишете anyRequest().authenticated(), вище забуваєте permitAll() для кінцевої точки входу — і отримуєте систему, де вхід можливий лише після входу. Виглядає як вічний двигун, але у світі безпеки це просто глухий кут. Лікується одним рухом: кінцева точка входу завжди оголошується публічною й помітною в конфігурації.
Помилка №2: очікувати, що stateless = «один раз увійшли, а далі сервер пам’ятає».
У stateless baseline сервер за змістом не зобов’язаний зберігати стан аутентифікації між запитами. Якщо ви перевірили пароль на POST /api/auth/login, це не означає, що наступний GET /api/me стане аутентифікованим «по інерції». Якщо ви бачите 401 на наступному запиті — це не привід вимикати безпеку, це привід згадати, що ми самі попросили STATELESS.
Помилка №3: спробувати залишити браузерну семантику в API-зоні.
Іноді в stateless-конфігурації забувають вимкнути або не чіпають те, що призводить до HTML-сторінок, редиректів або інших «браузерних» реакцій. Результат — Postman отримує неочікуваний HTML. Якщо у вас уже є REST-friendly AuthenticationEntryPoint і AccessDeniedHandler, підключайте їх і не давайте API відкотитися в минуле.
Помилка №4: змішати session-гілку і stateless-гілку в одному SecurityFilterChain без чіткої межі.
Технічно можна написати величезну конфігурацію «на всі випадки життя», але методично це майже завжди провал: незрозуміло, чому то працює, то ні; десь створилася сесія; десь зникла; десь CSRF раптово стріляє у ногу. Для курсу (і для більшості проєктів на старті) краще мати явну межу: профіль, гілку, тег — що завгодно, аби було зрозуміло, який світ зараз увімкнено.
Помилка №5: тягнути перевірки власника назад у контролери «бо тепер інакше не вийде».
Коли зникає session-пам’ять, з’являється спокуса «перевіряти все одразу в контролері», адже «там же у нас request». Це архітектурний відкат назад. Stateless змінює тільки спосіб появи Authentication на запиті; він не скасовує того, що business-правила мають жити поруч із бізнес-операцією. Якщо сервіс раніше був захищений @PreAuthorize, він має таким і залишитися.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ