JavaRush /Курси /Spring Security /SecurityContext і по...

SecurityContext і поточний користувач

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

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(), і ці точки мають бути видимими в конфігурації.

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