JavaRush /Курси /Spring Security /Поточний користувач: princ...

Поточний користувач: principal і context

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

1. Current user і довірений ідентифікатор

Коли ви починаєте писати REST API, дуже хочеться зробити все «симетрично»: якщо є GET /api/users/{id}, то й PATCH /api/users/{id} ніби теж виглядає логічно. Але щойно зʼявляються операції від свого імені, наприклад «оновити свій профіль» або «подивитися мої дані», симетрія перетворюється на пастку: клієнт починає повідомляти серверу, ким він є. А клієнт — істота творча. Особливо якщо це не ваш фронтенд, а умовний «Петя з curlʼом».

URL-правила і @PreAuthorize уже вміють відповідати на питання, кому взагалі дозволено ту чи іншу дію. Але щойно операція звучить як «покажи мої дані» або «онови мій профіль», цього замало: системі потрібно знати не просто роль, а хто саме робить запит просто зараз.

У безпеці є просте правило, яке рятує нерви: ідентичність користувача не можна брати з параметрів запиту для дій від свого імені. Якщо endpoint означає «я роблю це як поточний користувач», то поточного користувача слід визначати не з URL, не з query і не з body, а з security-контексту, який Spring Security уже зібрав після автентифікації.

Уявіть, що ви робите endpoint «оновити мій профіль» і приймаєте userId із тіла:

// userId із request-body — НЕ довірене джерело для операцій від свого імені.
// Цей приклад показано як антипатерн: клієнт може підставити чужий id.
public record UpdateProfileRequest(Long userId, String displayName) { }

Це приблизно так, ніби на вході до офісу охоронець питав би: «Ваш пропуск?» — а ви відповідали: «Я сам собі охоронець, ось у JSON написано, що я директор». Формально ви щось «передали», але довіряти цьому не можна.

Тому важливо зрозуміти, де лежить джерело істини про поточного користувача, і навчитися діставати його двома коректними способами: через @AuthenticationPrincipal (зручно прямо в контролері) і через SecurityContextHolder (коли ви вже глибше в коді, а параметра методу немає).

2. Ментальна модель: SecurityContextAuthenticationprincipal

Якщо у вас у голові немає короткої схеми «де живе користувач», далі ви або почнете тягнути SecurityContextHolder у кожен другий клас, або, що гірше, почнете довіряти userId із запиту. Тому давайте спокійно й по-людськи зафіксуємо, що відбувається у звичайному servlet / Spring MVC застосунку з Spring Security, коли запит уже пройшов автентифікацію.

Усередині Spring Security є центральна ідея: на час обробки запиту система формує SecurityContext, а в ньому зберігає Authentication. Authentication — це «конверт» з інформацією: хто користувач (principal), які в нього права (authorities) і чи автентифікований він узагалі. Далі Spring MVC може «підставляти» цю інформацію в контролери, а сервіси, якщо дуже треба, можуть її прочитати.

flowchart TD
    A[HTTP-запит] --> B[Ланцюг фільтрів Spring Security]
    B --> C[Authentication встановлено]
    C --> D[SecurityContext заповнено]
    D --> E[Метод контролера]
    D --> F["SecurityContextHolder.getContext()"]

Тобто current user зʼявляється не тому, що контролер щось витягнув із тіла запиту, а тому, що фільтри Spring Security разом із AuthenticationManager і AuthenticationProvider, які беруть участь у перевірці користувача, уже зробили свою роботу й поклали результат у SecurityContext.

Важливо ще одне: principal — це не обовʼязково ваша JPA-сутність. У Spring Security principal зазвичай — це обʼєкт, який реалізує UserDetails або принаймні містить потрібні security-поля. Він має бути відносно компактним і безпечним: мінімум даних, жодних «життєвих історій користувача» і, будь ласка, без пароля у відкритому вигляді. Та й хеш краще не тягнути без потреби.

А тепер переходимо до практики: як цей principal дістати.

3. @AuthenticationPrincipal у контролері

Коли студент уперше бачить SecurityContextHolder, він часто відчуває дивну радість: «О! Тоді користувача можна дістати звідки завгодно!» А далі починається сезон полювання на глобальний стан. Але на рівні контролера у нас є значно читабельніший і ввічливіший спосіб: @AuthenticationPrincipal. Він робить залежність від поточного користувача явною просто в сигнатурі методу, а отже код легше читати, тестувати й супроводжувати.

Ментально це схоже на аргумент методу @RequestBody, тільки замість тіла запиту Spring MVC підставляє вам principal із SecurityContext. І це чудово, тому що в коді одразу видно: «цей endpoint працює від імені поточного користувача». Жодних таємних викликів глобального holderʼа, жодних сюрпризів. Якщо колись ви забудете, звідки в сервіс прилітає userId, IDE вам не допоможе. А ось сигнатура контролера — допоможе.

Мінімальний AppUserPrincipal

Хочеться одразу запхати в principal усе: профіль, улюблений колір, дитячі травми і список чернеток. Але principal — це не «пакет даних для фронтенду», а security-представлення користувача. Для доступу на основі власника нам критично потрібні стійкі ідентифікатори, і зазвичай це userId із БД плюс username або email — залежно від того, який login identifier ви обрали.

Мінімальний приклад такого обʼєкта може виглядати так:

// Principal — це "хто користувач" у термінах security-контексту.
// Тут лежить trusted userId, який прийшов під час автентифікації, а не з запиту клієнта.
public class AppUserPrincipal {
    private final Long userId;
    private final String username;

    public AppUserPrincipal(Long userId, String username) {
        // Зберігаємо лише мінімально потрібні поля для ідентифікації.
        this.userId = userId;
        this.username = username;
    }

    public Long getUserId() { return userId; }
    public String getUsername() { return username; }
}

У реальному проєкті principal часто «ховається» всередині вашого UserDetails, який повертає CustomUserDetailsService. Але для розуміння нам достатньо однієї ідеї: усередині principal живе trusted userId, який ми передаватимемо далі для перевірки на основі власника.

Щоб повʼязати це з уже пройденим матеріалом: якщо ви робили CustomUserDetailsService, саме там ви вирішуєте, який обʼєкт стане principal. У спрощеному вигляді це виглядає приблизно так:

import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;

class AppUserDetailsService implements UserDetailsService {
    @Override
    public UserDetails loadUserByUsername(String username) {
        // Завантажуємо користувача за ідентифікатором входу (зазвичай username/email).
        UserAccount a = userAccountRepository.findByUsername(username).orElseThrow();

        // Важливо: саме тут можна "вкласти" userId у principal/UserDetails,
        // щоб далі перевірка на основі власника працювала за числовим id із БД.
        return new AppUserDetails(a.getId(), a.getUsername(), a.getPasswordHash(), a.getRoles());
    }
}

Тут важлива не реалізація, а факт: ви можете зробити так, щоб principal знав userId. І тоді перевірки на основі власника стають не здогадкою «а чия це чернетка?», а чесним порівнянням authorId і currentUserId.

Приклад: GET /api/me без зайвих параметрів

У нашому проєкті є endpoint GET /api/me. Його сенс якраз у тому, що він не повинен приймати userId. Це self-endpoint: «покажи мені, хто я». Тому він ідеально демонструє @AuthenticationPrincipal.

Зробімо просту відповідь:

// DTO відповіді, яку можна безпечно повернути клієнту.
public record MeResponse(Long id, String username) { }

І контролер:

import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
class MeController {

    @GetMapping("/api/me")
    MeResponse me(@AuthenticationPrincipal AppUserPrincipal principal) {
        // principal уже достовірний: його сформував security-шар.
        // Жодних userId із URL/body/query ми тут не приймаємо.
        return new MeResponse(principal.getUserId(), principal.getUsername());
    }
}

Зверніть увагу на приємний ефект: контролер узагалі не цікавиться, що прийшло в запиті. Йому не потрібно читати Authorization header, не потрібно парсити куки, не потрібно приймати userId параметром. Усе це вже зробив security-шар, а контролер просто використовує результат — поточного користувача, якому довіряє система.

Є ще один зручний трюк, якщо ви хочете не тягнути весь principal у метод, а взяти лише конкретне поле. @AuthenticationPrincipal підтримує expression (SpEL), і інколи це робить код дуже лаконічним:

import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;

@GetMapping("/api/me/id")
Long myId(@AuthenticationPrincipal(expression = "userId") Long userId) {
    // У метод потрапляє лише потрібне поле з principal, без залежності від усього класу.
    return userId;
}

Це не обовʼязкова техніка, але корисна: контролер отримує рівно те, що йому потрібно, і менше залежить від конкретного класу principal.

Неавтентифікований користувач

Новачки часто запитують: «А що, якщо principal буде null?» Це хороше запитання, бо воно змушує вас думати не лише про happy path. Реальність така: якщо endpoint захищений як authenticated(), то anonymous-запит зазвичай не дійде до контролера так, щоб вам довелося вручну перевіряти principal. Спрацює security і поверне 401 через AuthenticationEntryPoint, який ми вже обговорювали раніше.

Але є два нюанси, які варто памʼятати. По-перше, якщо ви випадково відкрили endpoint правилом permitAll(), то principal може виявитися null, або ви можете отримати anonymous principal — залежно від того, як налаштовано anonymous authentication. По-друге, якщо ви пишете код, який може викликатися не лише з web-запиту, наприклад якийсь внутрішній сервіс, то SecurityContext узагалі може бути порожнім.

Тому практична рекомендація звучить так: на рівні API-зон краще тримати чіткі правила на рівні запиту, щоб не змушувати кожен контролер робити мініверсію security. А @AuthenticationPrincipal використовуйте як зручний вхід до current user там, де доступ уже гарантовано правилами.

4. SecurityContextHolder у сервісах

Яким би корисним нам не здавався @AuthenticationPrincipal, він живе у світі контролерів. А далі починається справжнє життя: сервіси викликають інші сервіси, зʼявляються доменні операції, спільні утиліти, і не завжди зручно протягувати principal параметром уздовж ланцюжка викликів. Саме тут зʼявляється SecurityContextHolder — прямий доступ до SecurityContext, а отже і до Authentication, з будь-якого місця коду.

Але тут легко скоїти «гріх новачка»: почати викликати SecurityContextHolder взагалі всюди. Код працюватиме… поки ви не спробуєте його тестувати, повторно використовувати або поки у вас не зʼявиться місце, де контекст несподівано порожній. Тому до SecurityContextHolder треба ставитися як до гострого ножа: він потрібен, але ним не варто одночасно чистити картоплю, відкривати листи й чухати спину.

Головна ідея така: якщо current user потрібен у сервісі, найчастіше краще передати туди trusted userId параметром із контролера. А SecurityContextHolder залишити як запасний шлях, коли інакше виходить зовсім незручно.

CurrentUserService як обгортка

Щоб не розмазувати виклики SecurityContextHolder.getContext() по всьому проєкту, зазвичай роблять маленький сервіс-адаптер, який стає єдиною точкою контакту бізнес-коду з security-контекстом. Це помітно покращує читабельність і зменшує ризик, що якийсь репозиторій раптом почне «читати поточного користувача», а репозиторію це взагалі не належить.

Мінімальний приклад:

import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;

@Service
class CurrentUserService {

    Long userId() {
        // Дістаємо Authentication із поточного SecurityContext (у servlet-моделі він прив’язаний до запиту).
        Authentication a = SecurityContextHolder.getContext().getAuthentication();

        // Важливо: тут ми очікуємо наш тип principal, який поклали під час автентифікації.
        // У реальному коді такі місця зазвичай підсилюють перевірками та акуратнішою обробкою.
        AppUserPrincipal p = (AppUserPrincipal) a.getPrincipal();

        // Повертаємо trusted userId для перевірок на основі власника.
        return p.getUserId();
    }
}

Так, тут є приведення типів — і це не ідеально. У реальному коді ви додасте перевірки та акуратнішу обробку випадку, коли principal не того типу. Але тут важлива архітектурна думка: краще один CurrentUserService, ніж 37 місць із SecurityContextHolder.

І так, це все ще залежність від security. Але вона локалізована. Ми не сховали її «всередині репозиторію», ми зробили явний адаптер.

Дані з Authentication

Іноді для рішення достатньо authentication.getName(). Іноді потрібна роль або authority. Іноді — саме userId, тому що в базі ContentItem.authorId — число, і порівнювати його зі строковим username не хочеться, та й неправильно.

Ось невеликий фрагмент, який показує, що лежить у Authentication, і чому getName() — не «універсальний ідентифікатор усього на світі»:

import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;

// Authentication береться з security-контексту, який формується фільтрами до контролера.
Authentication a = SecurityContextHolder.getContext().getAuthentication();

// getName() зазвичай = login identifier (username/email), а не id із БД.
System.out.println(a.getName()); // наприклад: "alice"

// userId — прикладний ідентифікатор (зазвичай Long із бази), зручний для перевірок на основі власника.
System.out.println(((AppUserPrincipal) a.getPrincipal()).getUserId()); // наприклад: 42

І ось тут дуже важливе спостереження: getName() — це зазвичай login identifier (username/email), а userId — це прикладний ідентифікатор у вашій БД. Вони не зобовʼязані збігатися і майже ніколи не збігаються.

Якщо вам потрібно ухвалити рішення за правами, можна подивитися authorities:

import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;

Authentication a = SecurityContextHolder.getContext().getAuthentication();

// Authorities — це "права/ролі" із security-шару. Вони вже є без додаткових запитів до БД.
boolean canPublish = a.getAuthorities().stream()
        .anyMatch(x -> x.getAuthority().equals("draft:publish"));

Тут ми не доводимо бізнес-рішення до кінця. Тут важливо інше: Authentication — це місце, де вже лежить security-мінімум, і він доступний без запитів до БД.

5. getName() і userId

Одна з найчастіших причин майбутніх багів у перевірках на основі власника — плутанина щодо того, як саме ідентифікується користувач. Для Spring Security на базовому рівні важливий username або email, бо саме за ним зазвичай завантажується UserDetails і саме він бере участь у стандартному username/password flow. Тому authentication.getName() найчастіше повертає те, чим користувач представився під час входу.

Але в доменній моделі проєкту в нас є UserAccount.id (числовий ключ) і багато сутностей, які посилаються на власника через ownerUserId або authorId. І ось там важливий саме userId. Це стабільна штука: username теоретично може змінитися. У навчальному проєкті ми часто не робимо зміну username, але в реальності таке буває, а id залишається тим самим.

Практичний висновок простий: якщо ви порівнюєте власника обʼєкта, наприклад draft.authorId, із поточним користувачем, порівнюйте один і той самий тип ідентифікатора. Якщо у вас authorIdLong, то current user теж краще представляти як Long userId. Це робить перевірку на основі власника чесною і швидкою: без зайвих запитів, без «а раптом username змінився».

Саме тому так корисно один раз, у CustomUserDetailsService, покласти userId у principal. Тоді в контролері або сервісі ви дістаєте principal.getUserId() і впевнено використовуєте його як trusted identifier. А authentication.getName() залишається корисним для логів, діагностики та UI-відповідей, де хочеться показати людині username.

6. Межі шарів: controller/service/repo

Коли ви починаєте впроваджувати current user у бізнес-операції, є ризик розмазати безпеку по всьому застосунку. Це схоже на ситуацію, коли ви виявили, що в Java є static, і вирішили, що тепер усе буде static. Так, буде… але радості це не додасть. Тому важливо розвести зони відповідальності: де доречний @AuthenticationPrincipal, де допустимий SecurityContextHolder, а де security взагалі не повинно бути.

Зручно зафіксувати це в простій таблиці, щоб мозок щоразу не вигадував нові правила:

Шар Нормальний спосіб отримати поточного користувача Чому це ок Чого краще уникати
Контролер @AuthenticationPrincipal Явна залежність у сигнатурі, читабельно Довіряти userId із запиту для операцій від свого імені
Сервіс Передати currentUserId параметром або використати CurrentUserService Менше глобального стану, простіше тестувати Виклики SecurityContextHolder у кожному методі «просто тому, що можна»
Репозиторій Взагалі не знати про поточного користувача Репозиторій про дані, а не про безпеку SecurityContextHolder усередині репозиторію (це майже завжди погана ідея)

Чому репозиторій не повинен знати про security? Тому що репозиторій — це інфраструктурний шар доступу до даних. Якщо він починає залежати від того, хто зараз користувач, ви отримуєте приховані залежності, дивні ефекти в тестах і складність із повторним використанням. Сьогодні це здається «зручним», а за тиждень ви виявите, що репозиторій неможливо нормально використати у фоновій задачі, в admin-операції або в тесті, бо йому раптом потрібен SecurityContext.

Якщо вам потрібно «показувати лише мої чернетки», нормальна архітектурна точка — сервіс: він приймає currentUserId (trusted) і викликає репозиторій із параметром. Приблизно так:

import org.springframework.stereotype.Service;

@Service
class DraftQueryService {

    List<ContentItem> myDrafts(Long currentUserId) {
        return contentRepository.findAllByAuthorId(currentUserId);
    }
}

Цей підхід виглядає трохи більш багатослівним, ніж SecurityContextHolder усередині репозиторію, зате він залишається чесним: метод явно говорить, що йому потрібен currentUserId. А отже, його легко тестувати і легко читати.

7. Типові помилки під час роботи з current user

Помилка №1: брати userId для операцій від свого імені з URL, query або body.
Це найнебезпечніша помилка, бо вона створює вразливість майже непомітно. Ви можете думати, що «у нас же все закрито authenticated()», але автентифікований користувач цілком може спробувати оновити чужий профіль або дістати чужу чернетку, просто підставивши інший id. Для self-endpointʼів джерело істини — SecurityContext, а не параметри запиту.

Помилка №2: використовувати SecurityContextHolder як глобальну змінну і тягнути його в кожен клас.
Технічно це працює, але архітектурно перетворює застосунок на кашу: залежності стають прихованими, тести — крихкими, а читання коду нагадує квест «знайди, звідки взявся userId». Набагато краще тримати SecurityContextHolder в одному-двох місцях, наприклад у CurrentUserService, або передавати trusted currentUserId параметром.

Помилка №3: викликати SecurityContextHolder у репозиторії.
Репозиторій має бути максимально простим і передбачуваним: параметри на вході — запит до даних на виході. Коли репозиторій починає читати «поточного користувача», він стає неявно привʼязаним до web-контексту і security-фільтрів. Це майже гарантовано вистрілить дивними проблемами в тестах і під час повторного використання коду.

Помилка №4: сліпо приводити principal до кастомного типу, не контролюючи, що там лежить.
У різних сценаріях principal може бути різного типу, а в разі неавтентифікованого запиту — узагалі бути null/anonymous. Якщо ви робите (AppUserPrincipal) authentication.getPrincipal() без розуміння контексту, ви можете зловити ClassCastException у найнесподіваніший момент. Мінімальний захист — тримати такі приведення типів в одному місці й робити їх свідомо.

Помилка №5: плутати authentication.getName() і userId.
getName() зазвичай повертає строковий login identifier, наприклад username, а userId — числовий ідентифікатор у базі даних. Для перевірок на основі власника вам майже завжди потрібен userId, щоб порівнювати його з authorId/ownerUserId. Плутанина тут призводить до «вічно непрацюючих» перевірок, коли все ніби правильно, але порівнюються різні сутності різними типами.

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