1. Смысл жизненного цикла сессии
Если смотреть на приложение глазами новичка, всё кажется простым: есть логин, есть защищённые эндпоинты, пользователь вошёл — значит «он внутри». Но в реальности самые странные баги и самые нервные вопросы от пользователей начинаются именно на жизненном цикле: «почему меня выкинуло», «почему после logout всё ещё работает», «почему в одном табе я залогинен, а в другом нет». Чтобы перестать гадать, нам нужно увидеть четыре фазы: до логина, после логина, после logout и после истечения.
Мини-схема, которую держим в голове
stateDiagram-v2
[*] --> Anonymous: "нет аутентификации"
Anonymous --> Authenticated: "login"
Authenticated --> Authenticated: "серия запросов (cookie JSESSIONID)"
Authenticated --> Anonymous: "logout"
Authenticated --> Anonymous: "session timeout (истечение)"
Anonymous --> Anonymous: "публичные запросы"
Эта диаграмма выглядит почти слишком простой, но как раз в этом её сила: у вас в голове появляется «дорожная карта». Теперь давайте пройдём по ней на нашем проекте Secure Content Platform API.
2. Login: что меняется при успешном входе
Механика удержания пользователя между запросами уже собрана: после успешного логина Spring Security формирует Authentication, кладёт его в SecurityContext и сохраняет контекст в сессии. Поэтому здесь нас интересует не повторное объяснение пары HttpSession/JSESSIONID, а то, как это состояние живёт дальше: появляется на login, переживает серию запросов и исчезает на logout или timeout.
Чтобы у нас был простой индикатор текущего пользователя внутри API, удобно иметь эндпоинт /api/me, который показывает имя пользователя из Authentication.
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class MeController {
@GetMapping("/api/me")
String me(Authentication authentication) {
// Spring Security подставляет текущую аутентификацию из SecurityContext
// (в защищённых эндпоинтах она, как правило, не null)
return authentication.getName(); // например: "alice"
}
}
Пока сессия жива, /api/me на каждом запросе будет видеть одного и того же пользователя. Внутри контроллера нет собственной памяти — память у server-side сессии и SecurityContext.
3. Серия запросов после логина
После логина обычно начинается самая «жизненная» часть: пользователь кликает по приложению туда‑сюда, делает десятки запросов, и мы не хотим, чтобы каждый запрос снова требовал пароль. В session-based модели ровно это и происходит: один раз подтвердили личность, после чего сервер узнаёт вас по текущей сессии.
Чтобы наблюдать это без новых сущностей, удобно смотреть на одну открытую debug-ручку, которая сразу показывает текущее состояние сессии и пользователя.
import jakarta.servlet.http.HttpSession;
import java.security.Principal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class SessionStateController {
@GetMapping("/debug/session/state")
String state(HttpSession session, Principal principal) {
// Principal будет null, если запрос пришёл как anonymous (нет аутентификации)
String user = (principal == null) ? "anonymous" : principal.getName();
// Важно: сам факт наличия sessionId НЕ означает, что пользователь аутентифицирован.
// Сессия может быть "пустой" (без SecurityContext).
return "sid=" + session.getId() + ", user=" + user;
}
}
Теперь сценарий становится очень наглядным. До логина /debug/session/state показывает user=anonymous, после логина — реальное имя и текущий sessionId. Этого достаточно, чтобы увидеть главное: контроллер ничего не «помнит», а каждый запрос просто получает уже восстановленный контекст.
Если вы попробуете дёрнуть /debug/session/state несколько раз подряд после логина, пользователь не «пересобирается» заново в контроллере. Сервер просто находит текущую сессию, поднимает из неё SecurityContext, и вы остаетесь тем же пользователем, пока эта сессия жива.
4. Logout: что умирает в сессии
Logout часто воспринимают как «ну где-то там что-то нажали». Но для backend-разработчика logout — это чёткая граница: мы должны сделать так, чтобы аутентифицированное состояние больше не считалось действительным. И здесь полезно разделить две вещи: аккаунт пользователя в вашей системе — это запись (пусть даже in-memory), а текущая аутентификация — это временное состояние.
Когда пользователь нажимает logout, мы не удаляем UserAccount (и вообще никакого UserAccount у нас пока нет, мы на in-memory пользователях), мы разрываем связку «браузер ↔ сессия ↔ SecurityContext». В идеале это означает, что серверная сессия инвалидируется, SecurityContext очищается, и браузер перестаёт носить JSESSIONID как действительный ключ.
Для logout правила доступа можно оставить прежними: /api/public/** и /debug/** открыты, всё остальное по-прежнему требует аутентификации. Меняется не карта доступа, а судьба текущей сессии в момент выхода.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
// Публичные endpoint'ы и debug-зона доступны без логина
.requestMatchers("/api/public/**", "/debug/**").permitAll()
// Всё остальное требует аутентификации
.anyRequest().authenticated()
)
.formLogin(Customizer.withDefaults())
.logout(logout -> logout
// На сервере: делаем текущую HttpSession недействительной
.invalidateHttpSession(true)
// На клиенте: просим браузер удалить cookie с идентификатором сессии
.deleteCookies("JSESSIONID")
)
.build();
}
}
Что здесь важно понять именно ментально (а не только «скопировать и забыть»): deleteCookies("JSESSIONID") — это действие на уровне ответа клиенту, а invalidateHttpSession(true) — действие на сервере. Самый важный смысл — сервер должен перестать считать старую сессию источником доверенного security-состояния.
5. Истечение сессии: таймаут без активности
Logout — это сознательное действие пользователя. Но есть ещё одна причина, почему ваш идеальный пользователь внезапно становится anonymous: истечение сессии по таймауту. То есть пользователь вошёл, ушёл пить чай, забыл вкладку на ночь, а потом вернулся — и система говорит «войдите снова». Это не «Spring сломался» и не «у нас баг в SecurityContext», это нормальная часть lifecycle.
У любой сессии есть срок жизни без активности (inactivity timeout). Контейнер сервлета (и Spring Boot поверх него) хранит сессию, пока её трогают запросами. Если запросов нет достаточно долго, сессия удаляется как просроченный йогурт из холодильника. И когда браузер потом присылает старый JSESSIONID, сервер уже не может найти сессию, а значит — не может поднять SecurityContext.
Чтобы увидеть это на практике (и быстро, а не ждать 30 минут), можно временно поставить короткий timeout в application.yml.
server:
servlet:
session:
# Для демонстрации: короткий таймаут простоя сессии
timeout: 30s
После этого сценарий становится почти театральным. Логинитесь, делаете пару запросов на /api/me, убеждаетесь, что вы аутентифицированы, затем ничего не трогаете 30–40 секунд и снова открываете /api/me. С высокой вероятностью вы увидите, что приложение снова хочет логин: прежний SecurityContext не восстановился, потому что прежней сессии уже нет. А если вместо защищённого /api/me в этот момент посмотреть на /debug/session/state, картина будет такой же по смыслу: новый sessionId и user=anonymous.
6. Новый sessionId после выхода — это нормально
Когда вы начинаете наблюдать /debug/session/state, возникает эффект «призраков»: вы вышли из системы, а /debug/session/state всё равно показывает какой-то sid=.... И тут легко сделать неправильный вывод: «сессия всё ещё жива». На самом деле чаще всего вы наблюдаете другое явление: после logout старая сессия стала недействительной, но при следующем запросе сервер просто создал новую, пустую сессию (или создал её ваш код, потому что вы попросили HttpSession).
Понимать это удобно через маленькую таблицу — она помогает не путать «есть какая-то сессия» и «есть аутентифицированная сессия».
| Момент времени | Что в браузере | Что на сервере | user в /debug/session/state |
|---|---|---|---|
| До логина | может быть JSESSIONID=A или вообще ничего | либо нет сессии, либо пустая | anonymous |
| После логина | JSESSIONID=B | есть сессия, внутри SecurityContext | alice |
| После logout | cookie удалили или обновили | старая сессия инвалидирована | anonymous |
| После таймаута | браузер шлёт старый JSESSIONID | сессии уже нет, создаётся новая | anonymous |
Особенно коварный момент для новичка: если вы в permitAll endpoint’е принимаете HttpSession как параметр, Spring/Servlet контейнер часто создаёт сессию «по факту обращения» (потому что вы явно попросили session). Это может приводить к ощущению, что «сессия всегда есть». Но это не значит, что аутентификация всегда есть. А нас интересует именно это.
7. Один контроллер: до и после логина
В реальном приложении рядом с защищёнными endpoint’ами обычно живут и публичные. И там очень легко попасть в ловушку: в голове уже сидит «пользователь же есть», а потом вы открываете публичную страницу — и Principal внезапно null. Это нормально: в публичном запросе аутентифицированного пользователя может не быть.
Поэтому полезно писать код так, чтобы он корректно жил в обоих мирах. Например, можно сделать публичный диагностический endpoint, который возвращает anonymous или имя.
import java.security.Principal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class PrincipalDebugController {
@GetMapping("/debug/principal")
String whoami(Principal principal) {
// В публичном endpoint'е principal вполне может быть null — это нормальная ситуация
return (principal == null) ? "anonymous" : principal.getName();
}
}
Это не противоречит /debug/whoami из прошлой лекции, где Authentication на anonymous-запросе давал anonymousUser. Там мы смотрели прямо в SecurityContext, и Spring Security мог положить туда AnonymousAuthenticationToken. А здесь мы берём Principal через servlet API, и для anonymous request он часто просто отсутствует, поэтому null. Оба варианта говорят об одном и том же: пользователь ещё не аутентифицирован.
Этот кусочек хорошо показывает идею: текущий пользователь — это не «глобальная переменная приложения», а часть контекста конкретного запроса. После logout и после истечения сессии это снова станет anonymous, и ваш код должен пережить это спокойно, без драматичного падения.
8. Типичные ошибки в session lifecycle
Ошибка №1: думать, что logout «удаляет пользователя».
Logout завершает только текущую аутентифицированную сессию и очищает SecurityContext. Учётная запись (даже in-memory) никуда не девается. Если после logout вы снова вводите логин/пароль — вы снова войдёте, потому что пользователь существует, просто прежняя «привилегированная связь» оборвана.
Ошибка №2: путать «cookie удалили» и «сессия действительно умерла».
Cookie — это просто то, что браузер отправляет в запросе. Удаление JSESSIONID помогает клиенту перестать ссылаться на старую сессию, но ключевая гарантия — серверная инвалидизация. Если сервер не инвалидировал сессию, а вы лишь «почистили cookie», вы получите странные состояния при разных клиентах и вкладках.
Ошибка №3: считать истечение сессии багом, а не частью модели.
Если пользователь не делал запросов и сессия истекла, то потеря SecurityContext — нормальное поведение. Важно научиться диагностировать это не эмоциями («всё сломалось»), а фактами: таймаут, отсутствие активности, новая сессия без контекста, снова redirect на login.
Ошибка №4: создавать сессию на каждом публичном запросе случайно.
Инъекция HttpSession в permitAll контроллер или вызов request.getSession() в публичной зоне часто создаёт сессию даже там, где она не нужна. В маленьком учебном проекте это незаметно, но как привычка это опасно: вы тратите память, усложняете понимание «есть сессия / нет сессии» и иногда даже портите поведение кэшей и публичных страниц.
Ошибка №5: пытаться логировать или отдавать наружу JSESSIONID как «полезную информацию».
JSESSIONID — технический идентификатор, который не должен превращаться в «вот вам идентификатор пользователя». В учебных отладочных эндпоинтах мы иногда показываем session.getId() для понимания механики, но в реальном API это лишняя информация, которая помогает атакующему и почти никогда не помогает пользователю.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ