JavaRush /Курси /Spring Security /API без серверних сесій

API без серверних сесій

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

1. Stateful-модель: чому виникає перехід

Якщо ви щойно без проблем зібрали form login + session і все працює, бажання «перейти на stateless» може звучати як типова інженерна тривога: «а раптом десь є модніша технологія». Тож давайте спокійно: session-based модель не «погана», вона просто не завжди збігається з тим, чого очікує API-клієнт. Саме ця невідповідність зазвичай і запускає міграцію.

Session-based security — це дуже зручна угода між клієнтом і сервером. Клієнт, зазвичай браузер, один раз проходить login flow, потім зберігає cookie (наприклад, JSESSIONID) і далі поводиться розслаблено: «я вже входив, ви мене памʼятаєте». Сервер справді памʼятає, бо зберігає стан сесії (у памʼяті або в сховищі) і кожен наступний запит повʼязує саме з цим станом.

Щоб побачити, що «сам endpoint не винен», візьмімо наш знайомий приклад: /api/me. Він може жити і у session-based світі, і в stateless світі — URL від цього не змінюється, змінюється лише спосіб довести, що запит роблю саме я.

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

// Контролер нічого не знає про сесії, куки чи токени: він описує лише бізнес-сенс ендпоінта.
@RestController
public class MeController {

    @GetMapping("/api/me")
    public String me() {
        // Тут повертаємо умовну відповідь.
        // Визначення того, хто саме робить запит, — завдання security-рівня.
        return "профіль";
    }
}

У цьому коді немає жодного слова про сесію, cookie чи токени. Контролер описує бізнес-сенс: «дай мені поточного користувача». А ось як саме система вирішить, хто цей «поточний», — це вже робота security-рівня.

Питання «чому хочеться змінювати» виникає не через контролер і навіть не через Spring Security DSL, а через оточення: які клієнти до нас приходять, як вони вміють зберігати стан, як масштабується сервіс, як влаштовані розгортання та балансувальники, яким ми хочемо бачити контракт API.

2. Зберігання стану автентифікації

Потрібно чесно визнати: більша частина плутанини навколо «stateful vs stateless» виникає через слово state. Одразу хочеться сперечатися: «а в нас база даних — це теж state!». Так, звісно. Коли ми говоримо про stateless security, то майже завжди обговорюємо не «весь стан застосунку», а стан автентифікації між HTTP-запитами. Тобто ось цю штуку: користувач уже увійшов — отже, наступний запит можна вважати його.

У stateful-моделі відповідь проста й зручна: стан «користувач увійшов» зберігається на сервері, зазвичай у HttpSession. Клієнт носить із собою лише «номерок» — cookie, за яким сервер дістає потрібну памʼять. Це як браслетик у готелі «все включено»: ви один раз показали документи на стійці реєстрації, а потім ходите з браслетом, і бармен не влаштовує вам мініспівбесіду на тему «а ви точно ви?».

У stateless-моделі філософія протилежна: сервер не тримає таку памʼять між запитами і не розраховує на неї. Отже, кожен захищений запит має принести щось, що дозволить заново відновити поточного користувача на час цього запиту. Це не означає, що ви логінитиметеся щоразу через форму. Це означає, що кожен запит несе доказ.

Поки нам достатньо зафіксувати лише каркас:

  • у stateful session-based моделі сервер упізнає користувача, бо має server-side памʼять;
  • у stateless-моделі сервер упізнає користувача, бо запит несе credentials або замінник credentials (наприклад, токен), і сервер за ними будує Authentication на кожен запит.

SecurityContext при цьому нікуди не зникає. Він і далі потрібен як «поличка» для поточного користувача всередині оброблення конкретного запиту. Змінюється не сам факт його існування, а те, звідки він заповнюється.

Нам дуже допомагає місток, який ми вже проходили, — HTTP Basic. Це модель для кожного запиту в максимально прямолінійному вигляді: «ось логін і пароль у заголовку — перевіряй щоразу». Повноцінний stateless-базис вимагає ще й явної відмови від опори на session як на памʼять про вхід, але для розуміння самого принципу Basic дуже корисний.

import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Bean
SecurityFilterChain basicChain(HttpSecurity http) throws Exception {
    return http
            // Будь-який запит має бути автентифікований.
            .authorizeHttpRequests(a -> a.anyRequest().authenticated())
            // Увімкнути HTTP Basic: credentials приходять у кожному запиті.
            // Для цього фрагмента нам важливий саме принцип повторної перевірки на кожному запиті.
            .httpBasic(basic -> {})
            .build();
}

Тобто Basic корисний саме як місток: запит сам приносить credentials і не розраховує, що сервер уже щось памʼятає про попередній вхід.

3. Stateless у REST API: навіщо це потрібно

Якби stateless був просто «крутішим», ми б завершили лекцію на слові «модно» і пішли пити чай. Але stateless зʼявляється тому, що має конкретні переваги за певних умов. І майже завжди ці умови лежать у трьох площинах: які клієнти приходять, які продуктові вимоги зʼявилися та як живе сервіс в інфраструктурі.

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

Модель Де живе «я вже увійшов» Що клієнт зазвичай носить між запитами
Session / form login На сервері (session) Cookie з session id
HTTP Basic Ніде — перевірка щоразу Authorization: Basic ...
Stateless на основі токена У клієнта Authorization: Bearer ... (ідея, а не реалізація)

Зверніть увагу: в останньому рядку я навмисно написав «ідея, а не реалізація». Поки нам важлива сама логіка переходу: чому клієнт починає приносити proof доступу сам, а не розраховує на памʼять сервера.

Клієнтські причини: не всі клієнти — браузери, і не всі хочуть cookie

REST API майже ніколи не живе у світі «лише браузер». Навіть якщо у вас є вебінтерфейс, поруч зазвичай зʼявляються мобільні клієнти, CLI-утиліти, інтеграції, сервіси-споживачі, внутрішні адмінки, фонові джоби. Для багатьох із них cookie-орієнтована session-модель або незручна, або виглядає так, ніби ви зайшли не у свій домен.

Так, технічно майже будь-який HTTP-клієнт може зберігати cookie. Але на практиці це перетворюється на додаткові домовленості: «після входу збережи сховище cookie», «надсилай cookie ось так», «врахуй SameSite, домен, шлях…». І ось уже API, яке має бути простим і передбачуваним, починає вимагати від клієнта поведінки браузера.

Stateless-підхід зазвичай звучить простіше: «у кожному запиті передай заголовок Authorization». Це особливо природно для API-клієнтів на кшталт curl, Postman, сервісів інтеграції, мобільних застосунків. Клієнт сам зберігає те, що йому потрібно, і явно прикладає це до запиту. Жодної магії на кшталт «браузер сам надіслав cookie».

Продуктові причини: API стає контрактом, а не «частиною сайту»

Коли проєкт зростає, API починає жити як окремий продукт. Зʼявляються документація, різні клієнти, версії, зовнішні інтеграції, іноді партнери. І в цей момент команда зазвичай починає цінувати не лише «працює», а й «передбачувано працює».

Session-based модель у browser-сценарії часто ховає частину магії: «ти вже залогінений, просто заходь». Для сайту це зручно, для API-контракту — іноді занадто неявно. Stateless, навпаки, робить контракт формальнішим: «якщо немає заголовка або даних — буде 401; якщо є — ми спробуємо відновити користувача; якщо прав недостатньо — буде 403». З погляду клієнта це простіше налагоджувати і простіше будувати повторювані запити.

Ще один суто продуктовий момент: в API може бути багато різних frontend-ів (SPA, mobile, desktop), і в кожного свої вподобання щодо зберігання стану. Cookie-підхід може бути нормальним для одного клієнта й болючим для іншого. Stateless зазвичай сприймається як «універсальна мова» для багатьох клієнтів.

Інфраструктурні причини: масштабування, балансування, розгортання

Це причина, яка найменше стосується коду, але саме вона часто стає вирішальною.

Session-based модель припускає, що десь на сервері є памʼять, привʼязана до конкретного користувача або браузера. Це може бути памʼять конкретного екземпляра застосунку або спільне сховище сесій. І ось тут починаються цікаві інженерні історії:

Якщо у вас один інстанс застосунку, усе просто. Але щойно ви запускаєте два або десять інстансів, щоб витримати навантаження, зробити rolling update або просто пережити падіння одного екземпляра, постає питання: «а де моя сесія?». Якщо сесія зберігається в памʼяті інстанса, то наступний запит, який потрапить на інший інстанс, не впізнає користувача. Тоді ви або робите sticky sessions, коли балансувальник намагається надсилати користувача завжди на один інстанс, або виносите сесії в спільне сховище і вчите всі інстанси ходити туди.

Sticky sessions іноді працюють нормально, але вони додають крихкості: один інстанс помер — і «прикріплені» до нього користувачі раптом опинилися розлогіненими. Спільне сховище сесій розвʼязує частину проблем, але додає інший шар інфраструктури, latency і ще один компонент, який може захворіти в пʼятницю ввечері. А пʼятниця, як відомо, створена для багів.

Stateless security спрощує цю частину картини: якщо сервер не зберігає auth-state між запитами, то будь-який інстанс, отримавши запит, здатний сам відновити користувача за даними із запиту. Це робить горизонтальне масштабування і розгортання простішими: запит можна надіслати куди завгодно, і він усе одно буде оброблений коректно.

Важливо розуміти: це не «магічне прискорення». Stateless часто означає, що сервер на кожен запит робить додаткову роботу — наприклад, перевіряє підпис токена або завантажує user details, — але ця робота локальна і не потребує «памʼяті між запитами». У реальній інфраструктурі це іноді виявляється вигіднішим, ніж керування session-state на кластері.

4. Що залишається незмінним у stateless

На тлі слів «stateless», «token», «нова модель» у новачка легко виникає відчуття, що зараз ми перепишемо половину проєкту: ролі перейменуємо, @PreAuthorize викинемо, ownership checks забудемо, а контролери стануть іншими. Насправді правильна міграція влаштована майже навпаки: бізнес-правила доступу мають пережити зміну auth-моделі без переписування. Ми змінюємо те, як користувач входить, а не те, які в нього права.

Якщо у нас уже є access matrix, уже є зони /api/public/**, /api/me/**, /api/editor/**, /api/admin/**, уже є method security на сервісах і ownership checks, то все це й далі має сенс. Ролі USER/EDITOR/ADMIN не змінюються через те, що ми перестали зберігати сесію. Authority draft:publish не стає «недійсною» через те, що client тепер носить щось у заголовку.

Важливо схопити сам принцип: ми не починаємо інший проєкт і не переписуємо домен із нуля. Ми продовжуємо той самий Secure Content Platform API: той самий домен, ті самі ендпоінти, ті самі бізнес-обмеження, та сама архітектура шарів. Змінюється лише механізм, який дозволяє кожному запиту приходити з контекстом поточного користувача.

Хороший маркер того, що ви мислите правильно: контролери й сервіси майже не мають знати, яка у вас модель входу. Вони хочуть бачити Authentication/principal і ухвалювати рішення на основі ролей, authorities та ownership. Якщо під час переходу до stateless у вас раптом зʼявляється потреба «перевіряти токен у кожному контролері», це означає, що ви розгортаєте проєкт у неправильний бік і розмазуєте security-рівень по бізнес-коду.

5. Protected endpoint: різні моделі входу

Дуже корисно хоча б раз у житті побачити, що модель автентифікації — це як турнікет на вході. Будівля — тобто ваші контролери й сервіси — залишається тією самою, але турнікет може працювати по-різному: за перепусткою, за паспортом, за QR-кодом. Якщо переплутати ці речі, ви почнете будувати будівлю заново лише заради заміни турнікета, а це, мʼяко кажучи, дорого.

Подивімося на два вже знайомі варіанти входу в захищений endpoint.

Browser-oriented session-based варіант (form login):

import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Bean
SecurityFilterChain browserChain(HttpSecurity http) throws Exception {
    return http
            // Вимагаємо автентифікацію для всіх запитів.
            .authorizeHttpRequests(a -> a.anyRequest().authenticated())
            // Form Login зазвичай означає: користувач входить через форму,
            // а далі браузер носить session cookie, і сервер "памʼятає" результат входу.
            .formLogin(form -> {})
            .build();
}

Тут логіка така: один раз увійшли через форму, далі сервер упізнає вас через session cookie. Клієнтський досвід для браузера майже ідеальний: користувач «увійшов і забув».

Базовий варіант, зручний для API (HTTP Basic):

import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Bean
SecurityFilterChain basicChain(HttpSecurity http) throws Exception {
    return http
            // Будь-який запит вимагає автентифікації.
            .authorizeHttpRequests(a -> a.anyRequest().authenticated())
            // HTTP Basic: у кожному запиті знову приходять credentials,
            // і сервер їх повторно перевіряє, без опори на server-side сесію як на памʼять про вхід.
            .httpBasic(basic -> {})
            .build();
}

Тут кожен запит несе credentials, і сервер щоразу їх перевіряє. Не най«фінальніша» модель продукту, але як місток до stateless-мислення — чесна й корисна: сервер не зберігає «результат входу», а ухвалює рішення на кожному запиті.

Сенс цих двох фрагментів у межах сьогоднішньої лекції не в тому, щоб обрати «який кращий», а в тому, щоб побачити: protected endpoint може існувати за різних моделей входу, а відмінність полягає в тому, хто зберігає auth-state і як він прикладається до запиту.

6. Типові помилки в міркуваннях про stateless

Помилка № 1: вважати stateless «сучасним за визначенням», а stateful — «застарілим за визначенням».

Це одна з найшкідливіших ментальних схем. Session-based модель чудово підходить для багатьох browser-oriented систем і не стає «поганою» лише тому, що десь існує токен. Правильний критерій — хто ваш клієнт і як він реально живе, а не дата публікації статті про JWT.

Помилка № 2: думати, що stateless — це «взагалі без стану».

Stateless security майже завжди означає лише одне: сервер не зберігає автентифікаційну памʼять між запитами. У вас усе одно є база даних, транзакції, бізнес-стан, статуси чернеток і публікацій. Якщо ви починаєте сперечатися: «але ж у нас є state», — ви сперечаєтеся не з тим визначенням.

Помилка № 3: змінювати access matrix разом зі зміною моделі входу.

Іноді під час міграції люди починають «перевидумувати ролі», «перейменовувати зони», «переробляти method security», хоча завдання було лише в тому, щоб перестати опиратися на session. Це створює відчуття, ніби token-based security вимагає іншого домену та іншого дизайну прав. На рівні fundamentals це майже завжди хибний крок.

Помилка № 4: намагатися перенести security-логіку в контролери.

Найпростіший спосіб «зробити stateless» у голові новачка — у кожному контролері почати діставати заголовки і щось перевіряти. Але це руйнує архітектуру Spring Security: автентифікація має жити на security-рівні, а контролери та сервіси мають отримувати вже готового current user через стандартні механізми.

Помилка № 5: очікувати, що stateless сам вирішить питання входу і повторних запитів.

Перестати зберігати сесію — це не «новий метод входу». Це зміна політики зберігання auth-state. Якщо ви вимкнули опору на session, системі потрібен інший спосіб відновлювати користувача на кожному запиті. Поки достатньо побачити саму залежність: без такого механізму будь-який наступний protected request знову виглядатиме як anonymous.

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