1. Поточний користувач: проблема «хто це зробив?»
Якщо чесно, бекенд без уявлення про те, хто саме надсилає запит, схожий на офіс без перепусток: двері наче є, але або всі ходять куди хочуть, або охоронець, тобто ваш контролер, змушений щоразу перепитувати людину заново. До formLogin ми вже вміли закривати /api/me/** та інші зони правилом authenticated(), але цього ще не досить, щоб відповісти на прикладне запитання: «Який саме користувач увійшов і як побачити його в коді?»
У проєкті Secure Content Platform API це особливо відчутно на /api/me. За змістом ця кінцева точка має бути максимально простою вітриною: «ось хто ви» (username), «ось які у вас ролі/права» (roles/authorities). І тут важливий момент: ми не хочемо перетворювати MeController на мінідетектива, який сам дістає логін і пароль із запиту та вручну їх перевіряє. Це було б сумно, небезпечно і болісно для підтримки. Ми хочемо, щоб рівень безпеки виконав аутентифікацію, а контролер просто взяв результат.
2. Authentication після входу: «перепустка»
Коли користувач успішно входить через formLogin, Spring Security не просто ставить позначку «доступ дозволено». Він формує обʼєкт типу Authentication — це і є та «перепустка», яку система визнає дійсною. У ній є «хто ви» (principal), «що вам можна» (authorities) і кілька технічних полів, які допомагають безпеці працювати передбачувано.
Найкорисніше для початківця — перестати думати, що Authentication це «щось для фреймворка», і побачити, що це звичайний обʼєкт, із яким можна акуратно працювати в прикладному коді. Саме він пізніше опиняється в SecurityContext, а отже — стає доступним вашому застосунку.
Невелика таблиця-підказка: не для зазубрювання, а щоб швидше зорієнтуватися.
| Частина Authentication | Що означає по-людськи | Що зазвичай використовуємо в застосунку |
|---|---|---|
| getName() | логін/ідентифікатор користувача | майже завжди, особливо для /api/me |
| getAuthorities() | ролі та/або дозволи, які система визнала | часто, щоб показати «хто ви за правами» |
| getPrincipal() | обʼєкт користувача, часто UserDetails | іноді, якщо потрібно більше даних |
| getCredentials() | облікові дані, з якими входили | майже ніколи, і це добре |
| isAuthenticated() | «чи вважається аутентифікованим» | рідко, зазвичай ми перевіряємо доступ правилами, а не if-ами |
І ось головне: після успішного входу саме цей Authentication є «правдою» про користувача для поточного запиту.
3. SecurityContext: де зберігається логін
SecurityContext можна сприймати як кишеню піджака Spring Security, у якій лежить та сама перепустка Authentication. У цієї кишені є одне просте завдання: зберігати «хто зараз є користувачем» так, щоб у всіх місцях під час обробки запиту застосунок міг прочитати це однаково, без ручного перетягування параметрів між методами.
Тут нам потрібен один факт: після успішного входу Authentication опиняється в SecurityContext, і далі Spring MVC може передати його в контролер. Механізм, що утримує цей стан між кількома запитами браузера, поки можна не чіпати.
У вигляді невеликої схеми це виглядає приблизно так:
sequenceDiagram
participant B as Браузер
participant S as "Spring Security (фільтри)"
participant C as Контролер
participant SC as SecurityContext
B->>S: "POST /login (username+password)"
S->>S: "Перевірка облікових даних через AuthenticationManager/Provider"
S->>SC: "SecurityContext.authentication = (успішний Authentication)"
B->>S: GET /api/me
S->>C: Запит проходить, аутентифікація вже відома
C->>SC: Читає authentication
C-->>B: Відповідь "поточний користувач: maria"
Тут немає магії рівня «контролеру якось саме стало зрозуміло». Є конкретна логіка: рівень безпеки один раз виконав важку роботу, тобто перевірив пароль, зберіг результат у стандартизованому місці (SecurityContext), і тепер застосунок може цим користуватися.
4. Поточний користувач у контролері
Тепер найприємніше: вам не потрібно лізти в request headers, cookies та інші низькорівневі штуки, щоб дізнатися, хто є користувачем. У Spring MVC можна просто прийняти Authentication як параметр методу контролера. Це виглядає майже занадто просто, тому новачки часто не вірять і намагаються «зробити по-справжньому». Спойлер: саме це і є по-справжньому.
Почнемо з найпростішої /api/me, що повертає імʼя поточного користувача. У нашому проєкті це ідеальний перший індикатор, щоб наочно побачити: до входу і після входу застосунок бачить різні стани.
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 сам підставляє поточну аутентифікацію з SecurityContext.
// Тут ми НЕ читаємо заголовки/куки і НЕ перевіряємо пароль — усе це вже зробив рівень безпеки.
return "поточний користувач: " + authentication.getName(); // наприклад: "поточний користувач: maria"
}
}
Якщо ви увійшли під maria, то отримаєте поточний користувач: maria. Якщо ви увійшли під admin, отримаєте поточний користувач: admin. І це вже дуже сильний момент: ваш прикладний код не перевіряв пароль, нічого не знав про UserDetailsService, не чіпав PasswordEncoder, а просто отримав підсумок.
Щоб краще відчути різницю між «анонімний користувач» і «користувач, що увійшов», можна зробити маленьку налагоджувальну кінцеву точку в публічній зоні. Вона не обовʼязкова для бізнесу, але для навчання — просто корисний мікроскоп.
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class AuthDebugController {
@GetMapping("/api/public/auth-debug")
String authDebug(Authentication authentication) {
// У публічній кінцевій точці ви зможете побачити, як Spring поводиться "до" і "після" входу.
// Зазвичай до входу це AnonymousAuthenticationToken, після — UsernamePasswordAuthenticationToken.
return authentication.getClass().getSimpleName() + " -> " + authentication.getName();
// до входу: "AnonymousAuthenticationToken -> anonymousUser"
// після входу: "UsernamePasswordAuthenticationToken -> maria"
}
}
Зверніть увагу: це саме публічна кінцева точка (/api/public/**), інакше до входу ви на неї просто не потрапите і не побачите порівняння.
Іноді замість Authentication хочеться приймати просто імʼя користувача. Тоді можна використовувати java.security.Principal — це більш загальний інтерфейс із Java-світу. Але на рівні навчання Authentication зазвичай зрозуміліший, бо в ньому одразу є і імʼя, і authorities.
import java.security.Principal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class WhoAmIController {
@GetMapping("/api/me/who")
String who(Principal principal) {
// Principal — більш загальний інтерфейс: по суті, "хто це".
// Якщо вам потрібні права (authorities) — зручніше приймати Authentication.
return "principal name: " + principal.getName(); // наприклад: "principal name: maria"
}
}
З точки зору курсу корисно запамʼятати просте правило: коли ви хочете отримати «поточного користувача» на web-рівні, перший і найпрозоріший інструмент — це параметр методу контролера.
5. Authorities: показати без витоків
Коли ми вперше бачимо Authentication, рука тягнеться сказати: «Давайте все надрукуємо і подивимося». Для навчальних цілей це нормальний порив, але в реальному застосунку потрібно швидко виробити звичку: назовні ми віддаємо лише те, що справді потрібно клієнту, і ніколи не «зливаємо» зайвого.
Наприклад, для /api/me дуже логічно повертати не тільки username, а й список authorities. У нашому проєкті це допоможе пояснити, чому один користувач може зайти в /api/editor/**, а інший — ні.
Зробімо невеликий DTO. У Java 25 record — дуже зручний варіант для таких «коробочок даних». А далі повернемо JSON.
import java.util.List;
// DTO для відповіді /api/me: віддаємо рівно те, що потрібно клієнту, без технічних деталей Spring Security.
record MeResponse(String username, List<String> authorities) { }
Тепер контролер:
import java.util.List;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class MeController {
@GetMapping("/api/me")
MeResponse me(Authentication auth) {
// Authorities — це "що можна", а не "хто ви". Беремо їх із SecurityContext через Authentication.
List<String> authorities = auth.getAuthorities().stream()
// Перетворюємо GrantedAuthority на рядки виду ROLE_ADMIN / ROLE_EDITOR тощо.
.map(GrantedAuthority::getAuthority)
.toList();
// Повертаємо мінімальний, стабільний контракт API: username + authorities.
return new MeResponse(auth.getName(), authorities);
}
}
Тут важливо кілька моментів.
По-перше, ми працюємо з auth.getAuthorities(), а не намагаємося вгадати ролі за username. username — це «хто», а authorities — це «що можна».
По-друге, ми віддаємо назовні рядки виду ROLE_ADMIN або ROLE_EDITOR — залежно від того, як ви налаштовували ролі, — і це нормально для навчального API. Пізніше ви зможете вирішити, як саме подавати їх клієнту, але зараз нам важливіше побачити, що всередині рівня безпеки все вже є.
По-третє, ми свідомо не чіпаємо auth.getCredentials(). Навіть якщо вам здається, що «там усе одно хеш чи щось незрозуміле», ця звичка небезпечна. Хороший SecurityContext — це такий, із якого застосунок отримує identity та права, але не зберігає і не роздає secrets.
6. Звʼязка formLogin і /api/me
Щоб ця лекція не була «про абстрактні класи», давайте звʼяжемо дві речі в одну спостережувану поведінку: успішний вхід → перехід на захищену кінцеву точку → кінцева точка бачить поточного користувача.
Для цього зручно налаштувати defaultSuccessUrl("/api/me", true), щоб після входу браузер одразу потрапляв на кінцеву точку, яка явно показує SecurityContext у дії. У конфігурації, приблизно так, у вашому SecurityConfig, це може виглядати компактно:
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
// Публічні кінцеві точки доступні без входу, зокрема наш auth-debug.
.requestMatchers("/api/public/**").permitAll()
// /api/me/** вимагає аутентифікації: без входу сюди не потрапити.
.requestMatchers("/api/me/**").authenticated()
// На демопроєкті корисно все інше закрити, щоб випадково не відкрити зайве.
.anyRequest().denyAll()
)
.formLogin(form -> form
// Після успішного входу одразу переходимо на /api/me, щоб побачити поточного користувача.
.defaultSuccessUrl("/api/me", true)
// Важливо: форму входу потрібно дозволити всім, інакше можна отримати "вічний редирект".
.permitAll()
)
.build();
}
Тепер у вас виходить дуже прозора демонстраційна петля:
1) Ви в браузері відкриваєте /api/me.
2) Якщо ви не увійшли, Spring Security веде вас на вхід.
3) Ви вводите логін і пароль.
4) Після успішного входу ви одразу потрапляєте на /api/me, і контролер показує вам username. А якщо ви зробили JSON-версію, то ще й authorities.
Тут корисно відокремити результат від механізму зберігання. Для /api/me нам зараз потрібен саме результат: контролер отримує вже готовий Authentication і не збирає користувача заново. За лаштунками цього ефекту є окрема звʼязка HttpSession і ідентифікатора браузера (JSESSIONID): саме вона дозволяє новому запиту приходити вже не як «зовсім чужому». Без неї не було б ані відчуття «я вже увійшов», ані осмисленого logout.
У цей момент у вас має клацнути в голові: /api/me — це не «особлива кінцева точка», вона звичайна. Просто рівень безпеки подбав про те, що коли контролер викликається, поточний користувач уже відомий і доступний як Authentication.
Якщо ви хочете сильніше відчути, що це одна й та сама кінцева точка для різних користувачів, зайдіть трьома різними обліковими записами (USER, EDITOR, ADMIN) і подивіться, що змінюється в authorities. Це чудово закріплює різницю між «хто ви» та «що можна»: username буде різний, authorities теж відрізнятимуться, а код контролера — один.
7. Типові помилки під час роботи з SecurityContext
Помилка №1: намагатися «перевірити пароль» у контролері.
Іноді студенти, побачивши Authentication в аргументі методу, усе одно намагаються дістати із запиту логін і пароль та порівняти їх «для надійності». На практиці це ламає архітектуру: ви дублюєте роботу DaoAuthenticationProvider, створюєте нові точки витоку даних і перетворюєте контролер на security-комбайн. Контролер має ухвалювати бізнес-рішення, а не підтверджувати особу — це завдання рівня безпеки.
Помилка №2: очікувати, що Authentication буде null «до входу».
Інтуїтивно здається, що до входу «користувача немає», отже authentication == null. У типовому сценарії Spring Security це не так: для неаутентифікованих запитів часто створюється anonymous authentication, і authentication.getName() може бути "anonymousUser". Тому не варто писати бізнес-логіку в стилі «якщо null — гість»: краще виражати доступ через permitAll()/authenticated() у конфігурації, а в /api/me просто вимагати authenticated().
Помилка №3: віддавати назовні зайве з Authentication.
Є спокуса повернути в /api/me весь authentication.toString() або серіалізувати principal «як є». Це швидко призводить до неочікуваних проблем: по-перше, ви можете випадково засвітити технічні поля; по-друге, привʼяжете API-контракт до внутрішніх класів Spring Security, і він стане крихким. Для публічного API краще повернути мінімальний DTO: username + authorities — і то усвідомлено.
Помилка №4: плутати «username» і «роль».
Новачки іноді роблять перевірку: «якщо username == admin, то пускаємо». Це виглядає смішно, доки не стає страшно. username — це ідентичність, а права мають описуватися roles/authorities і правилами доступу в SecurityFilterChain. Навіть якщо у вас у демо є користувач admin, це не означає, що «імʼя admin = право адміністратора».
Помилка №5: закрити /api/me, але забути відкрити login/logout.
У конфігурації легко випадково написати занадто широке правило, наприклад anyRequest()authenticated(), і забути, що точка входу теж має бути досяжною. Тоді ви отримуєте «вічний редирект»: користувач не може потрапити на сторінку входу, бо «спочатку увійди». Лікується просто: у formLogin і logout має бути permitAll(), і ці точки мають бути видимими в конфігурації.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ