JavaRush /Курсы /Spring Security /API без server-side сессий

API без server-side сессий

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;

// Контроллер ничего не знает про сессии/куки/токены: он описывает только бизнес-смысл endpoint'а.
@RestController
public class MeController {

    @GetMapping("/api/me")
    public String me() {
        // Здесь возвращаем условный ответ.
        // Определение "кто именно делает запрос" (текущий пользователь) — задача security-слоя.
        return "profile";
    }
}

В этом коде нет ни слова про сессию, cookie или токены. Контроллер описывает бизнес-смысл: “дай мне текущего пользователя”. А вот как именно система решит, кто этот “текущий” — это уже работа security-слоя.

Вопрос “почему хочется менять” появляется не из-за контроллера и даже не из-за Spring Security DSL, а из-за окружения: какие клиенты к нам приходят, как они умеют хранить состояние, как масштабируется сервис, как устроены деплои и балансировщики, как мы хотим видеть контракт API.

2. Хранение состояния аутентификации

Нужно честно признаться: большая часть путаницы вокруг “stateful vs stateless” происходит из-за слова state. Сразу хочется спорить, “а у нас база данных — это тоже state!”. Да, конечно. Когда мы говорим про stateless security, мы почти всегда обсуждаем не “всё состояние приложения”, а состояние аутентификации между HTTP-запросами. То есть вот эту штуку: “пользователь уже вошёл — значит следующий запрос можно считать от него”.

У stateful-модели ответ простой и приятный: состояние “пользователь вошёл” хранится на сервере, обычно в HttpSession. Клиент носит с собой только “номерок” (cookie), по которому сервер достаёт нужную память. Это как браслетик в отеле all-inclusive: вы один раз показали документы на ресепшене, а потом ходите с браслетом, и бармен не устраивает вам мини-собеседование на тему “а вы точно вы?”.

У stateless-модели философия обратная: сервер не держит такую память между запросами и не рассчитывает на неё. Значит, каждый защищённый запрос должен принести что-то, что позволит заново восстановить текущего пользователя “на время этого запроса”. Это не означает, что вы будете логиниться “каждый раз через форму”, это означает, что каждый запрос несёт доказательство.

Пока нам достаточно зафиксировать только каркас:

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

SecurityContext при этом никуда не исчезает. Он по-прежнему нужен как “полочка” для текущего пользователя внутри обработки конкретного запроса. Меняется не факт его существования, а то, откуда он заполняется.

Нам очень помогает мост, который мы уже проходили: HTTP Basic. Это per-request модель в максимально прямолинейном виде: “вот логин/пароль в заголовке — проверяй каждый раз”. Полный stateless baseline требует ещё и явного отказа опираться на 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 token-based У клиента Authorization: Bearer ... (идея, не реализация)

Обратите внимание: в последней строке я нарочно написал “идея, не реализация”. Пока нам важна сама логика перехода: почему клиент начинает приносить proof доступа сам, а не рассчитывает на память сервера.

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

REST API почти никогда не живёт в мире “только браузер”. Даже если у вас есть веб-интерфейс, рядом обычно появляются мобильные клиенты, CLI-утилиты, интеграции, сервисы-потребители, внутренние админки, фоновые джобы. Для многих из них cookie-ориентированная session-модель либо неудобна, либо выглядит как “мы не в свой домен зашли”.

Да, технически почти любой HTTP-клиент может хранить cookie. Но на практике это превращается в дополнительные договорённости: “после логина сохрани cookie jar”, “присылай cookie так-то”, “учти SameSite, домен, путь…”. И вот уже API, которое должно быть простым и предсказуемым, начинает требовать от клиента поведения браузера.

Stateless-подход обычно звучит проще: “в каждом запросе передай заголовок Authorization”. Это особенно естественно для API-клиентов вроде curl, Postman, сервисов интеграции, мобильных приложений. Клиент сам хранит то, что ему нужно, и явно прикладывает это к запросу. Никакой магии “браузер сам отправил cookie”.

Продуктовые причины: API становится контрактом, а не “частью сайта”

Когда проект растёт, API начинает жить как отдельный продукт. Появляется документация, разные клиенты, версии, внешние интеграции, иногда партнёры. И в этот момент команда обычно начинает ценить не только “работает”, но и “предсказуемо работает”.

Session-based модель в browser-сценарии часто прячет часть магии: “ты уже залогинен, просто заходи”. Для сайта это удобно, для API-контракта — иногда слишком неявно. Stateless, наоборот, делает контракт более формальным: “если нет заголовка/данных — будет 401; если есть — мы попробуем восстановить пользователя; если прав не хватает — будет 403”. С точки зрения клиента это проще отлаживать и проще строить повторяемые запросы.

Ещё один чисто продуктовый момент: у API может быть множество разных frontends (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-friendly baseline вариант (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.

1
Задача
Spring Security, 21 уровень, 0 лекция
Недоступна
Browser-oriented вход для `/api/me`
Browser-oriented вход для `/api/me`
1
Задача
Spring Security, 21 уровень, 0 лекция
Недоступна
Тот же protected endpoint с явными credentials в запросе
Тот же protected endpoint с явными credentials в запросе
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ