1. Сенс життєвого циклу сесії
Якщо дивитися на застосунок очима новачка, усе здається простим: є логін, є захищені кінцеві точки, користувач увійшов — отже, «він усередині». Але насправді найдивніші баги й найнапруженіші запитання від користувачів починаються саме на життєвому циклі: «чому мене викинуло», «чому після logout усе ще працює», «чому в одній вкладці я вже увійшов, а в іншій — ні». Щоб перестати гадати, нам потрібно побачити чотири фази: до входу, після входу, після logout і після спливу тайм-ауту.
Мінісхема, яку тримаємо в голові
stateDiagram-v2
[*] --> Anonymous: "немає аутентифікації"
Anonymous --> Authenticated: "вхід"
Authenticated --> Authenticated: "серія запитів (cookie JSESSIONID)"
Authenticated --> Anonymous: "logout"
Authenticated --> Anonymous: "сплив часу сесії"
Anonymous --> Anonymous: "публічні запити"
Ця діаграма виглядає майже занадто простою, але саме в цьому її сила: у вас у голові зʼявляється «дорожня карта». Тепер давайте пройдемося по ній у нашому проєкті Secure Content Platform API.
2. Login: що змінюється під час успішного входу
Механіка збереження користувача між запитами вже налаштована: після успішного логіну Spring Security формує Authentication, кладе його в SecurityContext і зберігає контекст у сесії. Тому тут нас цікавить не повторне пояснення пари HttpSession/JSESSIONID, а те, як цей стан живе далі: зʼявляється після входу, переживає серію запитів і зникає після 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 на кожному запиті бачитиме одного й того самого користувача. Усередині контролера немає власної памʼяті — памʼять є в серверній сесії та SecurityContext.
3. Серія запитів після входу
Після входу зазвичай починається най«життєвіша» частина: користувач клацає по застосунку туди-сюди, робить десятки запитів, і ми не хочемо, щоб кожен запит знову вимагав пароль. У сесійній моделі саме це й відбувається: один раз підтвердили особу, після чого сервер упізнає вас за поточною сесією.
Щоб спостерігати це без зайвих сутностей, зручно використати одну відкриту 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
// Публічні кінцеві точки та 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», це нормальна частина життєвого циклу.
У будь-якої сесії є строк життя без активності (inactivity timeout). Контейнер сервлетів (і Spring Boot поверх нього) зберігає сесію, доки до неї звертаються запити. Якщо запитів немає достатньо довго, сесія видаляється, як прострочений йогурт із холодильника. І коли браузер потім надсилає старий JSESSIONID, сервер уже не може знайти сесію, а отже — не може підняти SecurityContext.
Щоб побачити це на практиці (і швидко, а не чекати 30 хвилин), можна тимчасово задати короткий тайм-аут у 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 кінцевій точці приймаєте HttpSession як параметр, Spring/Servlet-контейнер часто створює сесію «за фактом звернення» (бо ви явно попросили session). Це може створювати відчуття, що «сесія завжди є». Але це не означає, що аутентифікація завжди є. А нас цікавить саме це.
7. Один контролер: до і після входу
У реальному застосунку поруч із захищеними кінцевими точками зазвичай живуть і публічні. І там дуже легко потрапити в пастку: у голові вже сидить «користувач же є», а потім ви відкриваєте публічну сторінку — і Principal раптом null. Це нормально: у публічному запиті аутентифікованого користувача може не бути.
Тому корисно писати код так, щоб він коректно жив у двох світах. Наприклад, можна зробити публічну діагностичну кінцеву точку, яка повертає 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) {
// У публічній кінцевій точці principal цілком може бути null — це нормальна ситуація
return (principal == null) ? "anonymous" : principal.getName();
}
}
Це не суперечить /debug/whoami з минулої лекції, де Authentication на anonymous-запиті давав anonymousUser. Там ми дивилися прямо в SecurityContext, і Spring Security міг покласти туди AnonymousAuthenticationToken. А тут ми беремо Principal через servlet API, і для анонімного запиту він часто просто відсутній, тож null. Обидва варіанти говорять про одне й те саме: користувач ще не аутентифікований.
Цей шматок добре показує ідею: поточний користувач — це не «глобальна змінна застосунку», а частина контексту конкретного запиту. Після logout і після спливу сесії це знову стане anonymous, і ваш код має пережити це спокійно, без драматичних падінь.
8. Типові помилки в життєвому циклі сесії
Помилка №1: думати, що logout «видаляє користувача».
Logout завершує лише поточну аутентифіковану сесію й очищує SecurityContext. Обліковий запис (навіть in-memory) нікуди не зникає. Якщо після logout ви знову вводите логін/пароль — ви знову увійдете, тому що користувач існує, просто попередній «привілейований звʼязок» розірвано.
Помилка №2: плутати «cookie видалили» і «сесія справді померла».
Cookie — це просто те, що браузер надсилає в запиті. Видалення JSESSIONID допомагає клієнту перестати посилатися на стару сесію, але ключова гарантія — серверна інвалідація. Якщо сервер не зробив сесію недійсною, а ви лише «очистили cookie», ви отримаєте дивні стани при різних клієнтах і вкладках.
Помилка №3: вважати сплив сесії багом, а не частиною моделі.
Якщо користувач не робив запитів і сесія спливла, то втрата SecurityContext — нормальна поведінка. Важливо навчитися діагностувати це не емоціями («усе зламалося»), а фактами: тайм-аут, відсутність активності, нова сесія без контексту, знову redirect на login.
Помилка №4: ненавмисно створювати сесію на кожному публічному запиті.
Інʼєкція HttpSession у permitAll контролері або виклик request.getSession() у публічній зоні часто створює сесію навіть там, де вона не потрібна. У маленькому навчальному проєкті це непомітно, але як звичка це небезпечно: ви витрачаєте памʼять, ускладнюєте розуміння «є сесія / немає сесії» і іноді навіть псуєте поведінку кешів і публічних сторінок.
Помилка №5: намагатися логувати або віддавати назовні JSESSIONID як «корисну інформацію».
JSESSIONID — це технічний ідентифікатор, який не має перетворюватися на «ось вам ідентифікатор користувача». У навчальних debug-ендпоінтах ми іноді показуємо session.getId() для розуміння механіки, але в реальному API це зайва інформація, яка допомагає зловмиснику й майже ніколи не допомагає користувачу.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ