1. Current user и доверенный идентификатор
Когда начинаешь писать REST API, очень хочется сделать всё «симметрично»: раз есть GET /api/users/{id}, значит и PATCH /api/users/{id} выглядит логично. Но как только появляются self-операции вроде «обновить свой профиль» или «посмотреть мои данные», симметрия превращается в ловушку: клиент начинает сообщать серверу, кто он такой. А клиент — существо творческое. Особенно если это не ваш фронтенд, а условный «Петя с curl’ом».
URL-правила и @PreAuthorize уже умеют отвечать на вопрос «какому типу пользователя вообще можно действие». Но как только операция звучит как «покажи мои данные» или «обнови мой профиль», этого мало: системе нужно знать не просто роль, а кто именно делает запрос прямо сейчас.
В безопасности есть простое правило, которое спасает нервы: идентичность пользователя нельзя брать из параметров запроса для действий “от своего имени”. Если endpoint означает «я делаю это как текущий пользователь», то текущий пользователь должен определяться не из URL, не из query, не из body, а из security-контекста, который Spring Security уже собрал после аутентификации.
Представьте, что вы делаете endpoint “обновить мой профиль” и принимаете userId из тела:
// userId из request-body — НЕ доверенный источник для self-операций.
// Этот пример показан как анти-паттерн: клиент может подставить чужой id.
public record UpdateProfileRequest(Long userId, String displayName) { }
Это примерно как если бы на входе в офис охранник спрашивал: «Ваш пропуск?» — а вы отвечали: «Я сам себе охрана, вот у меня в JSON написано, что я директор». Формально вы что-то “передали”, но доверять этому нельзя.
Поэтому здесь важно понять, где лежит источник истины про current user, и научиться доставать его двумя корректными способами: через @AuthenticationPrincipal (красиво на входе контроллера) и через SecurityContextHolder (когда мы уже глубже в коде и параметра метода нет).
2. Ментальная модель: SecurityContext → Authentication → principal
Если у вас в голове нет короткой схемы «где живёт пользователь», то дальше вы начнёте либо тащить SecurityContextHolder в каждый второй класс, либо (что хуже) начнёте доверять userId из запроса. Поэтому давайте очень спокойно и по-человечески зафиксируем, что именно происходит в обычном servlet / Spring MVC приложении с Spring Security, когда запрос уже прошёл аутентификацию.
Внутри Spring Security есть центральная идея: на время обработки запроса система формирует SecurityContext, а в нём хранит Authentication. Authentication — это «конверт» с информацией: кто пользователь (principal), какие у него права (authorities), и вообще аутентифицирован он или нет. И дальше Spring MVC может “подставлять” эту информацию в контроллеры, а сервисы (если очень надо) могут её прочитать.
flowchart TD
A[HTTP request] --> B[SecurityFilterChain]
B --> C[Authentication established]
C --> D[SecurityContext populated]
D --> E[Controller method]
D --> F["SecurityContextHolder.getContext()"]
То есть current user появляется не потому, что контроллер что-то вытащил из тела запроса, а потому что фильтры Spring Security (плюс AuthenticationManager/AuthenticationProvider, которые участвуют в проверке пользователя) уже сделали свою работу и положили результат в SecurityContext.
Важно ещё одно: principal — это не обязательно ваш JPA-Entity. В Spring Security principal обычно — это объект, который реализует UserDetails (или хотя бы содержит нужные security-поля). Он должен быть относительно компактным и безопасным: минимум данных, никаких “жизненных историй пользователя” и, пожалуйста, без пароля в открытом виде (да и в хеше лучше не таскать без нужды).
А теперь переходим к практике: как этот principal достать.
3. @AuthenticationPrincipal в контроллере
Когда студент впервые видит SecurityContextHolder, он часто испытывает странную радость: «О! Там можно достать пользователя откуда угодно!» И дальше начинается сезон охоты на global state. Но на уровне контроллера у нас есть гораздо более читаемый и “вежливый” способ: @AuthenticationPrincipal. Он делает зависимость от текущего пользователя явной прямо в сигнатуре метода, а значит код проще читать, тестировать и сопровождать.
Мысленно это похоже на аргумент метода @RequestBody, только вместо тела запроса Spring MVC подставляет вам principal из SecurityContext. И это классно, потому что в коде сразу видно: «этот endpoint работает от имени текущего пользователя». Никаких тайных вызовов глобального holder’а, никаких сюрпризов. Если когда-нибудь вы забудете, откуда в сервис прилетает userId, IDE вам не поможет. А вот сигнатура контроллера поможет.
Минимальный AppUserPrincipal
Хочется сразу запихнуть в principal всё: профиль, любимый цвет, детские травмы и список черновиков. Но principal — это не «пакет данных для фронтенда», это security-представление пользователя. Для owner-based доступа нам критически нужны устойчивые идентификаторы, и обычно это 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, который мы будем передавать дальше для owner-check.
Чтобы связать это с уже пройденным материалом: если вы делали 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) {
// Загружаем пользователя по login identifier (обычно username/email).
UserAccount a = userAccountRepository.findByUsername(username).orElseThrow();
// Важно: именно тут можно "вложить" userId в principal/UserDetails,
// чтобы дальше owner-check работал по числовому id из БД.
return new AppUserDetails(a.getId(), a.getUsername(), a.getPasswordHash(), a.getRoles());
}
}
Здесь важна не реализация, а факт: вы можете сделать так, чтобы principal знал userId. И тогда owner-based правила становятся не гаданием «а чей это draft?», а честным сравнением 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 умеет выражение (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-зон лучше держать чёткие request-level правила, чтобы не заставлять каждый контроллер делать мини-версию 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 (request-scoped в servlet-модели).
Authentication a = SecurityContextHolder.getContext().getAuthentication();
// Важно: тут мы ожидаем наш тип principal, который положили при аутентификации.
// В реальном коде такие места обычно усиливают проверками и более аккуратной обработкой.
AppUserPrincipal p = (AppUserPrincipal) a.getPrincipal();
// Возвращаем trusted userId для owner-based проверок.
return p.getUserId();
}
}
Да, тут есть приведение типов — и это не идеально. В реальном коде вы добавите проверки и более аккуратную обработку случая, когда principal не того типа. Но здесь важна архитектурная мысль: лучше один CurrentUserService, чем 37 мест с SecurityContextHolder.
И да, это всё ещё зависимость от security. Но она локализована. Мы не скрыли её “внутри репозитория”, мы сделали явный adapter.
Данные из 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 из базы), удобный для owner-check.
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"));
Здесь мы не доводим business-решение до конца. Здесь важно другое: Authentication — это место, где уже лежит security-минимум, и он доступен без запросов в БД.
5. getName() и userId
Одна из самых частых причин будущих багов в owner-based access — путаница “как именно идентифицируется пользователь”. Для Spring Security на базовом уровне важен username (или email), потому что именно по нему обычно происходит загрузка UserDetails и именно он участвует в стандартном username/password flow. Поэтому authentication.getName() чаще всего возвращает то, чем пользователь “представился при входе”.
Но в доменной модели проекта у нас есть UserAccount.id (числовой ключ) и много сущностей, которые ссылаются на владельца через ownerUserId или authorId. И вот там важен именно userId. Это стабильная штука: username может теоретически поменяться (в учебном проекте мы часто не делаем смену username, но в реальности такое бывает), а id остаётся тем же.
Практический вывод простой: если вы сравниваете владельца объекта (например, draft.authorId) с текущим пользователем, вам нужно сравнивать один и тот же тип идентификатора. Если у вас authorId — Long, то current user тоже лучше представлять как Long userId. Это делает owner-check честным и быстрым: без лишних запросов, без “а вдруг username изменился”.
Именно поэтому так полезно однажды (в CustomUserDetailsService) положить userId в principal. Тогда в контроллере или сервисе вы достаёте principal.getUserId() и уверенно используете это как trusted identifier. А authentication.getName() остаётся полезным для логов, диагностики и каких-то UI-ответов, где хочется показать человеку “username”.
6. Границы слоёв: controller/service/repo
Когда начинаешь внедрять current user в бизнес-операции, есть риск “размазать” безопасность по всему приложению. Это похоже на ситуацию, когда вы обнаружили, что в Java есть static, и решили, что теперь всё будет static. Да, оно будет… но вы не будете этому рады. Поэтому важно развести зоны ответственности: где уместен @AuthenticationPrincipal, где допустим SecurityContextHolder, а где security вообще быть не должно.
Удобно зафиксировать это в простой таблице, чтобы мозг не каждый раз изобретал новые правила:
| Слой | Нормальный способ получить current user | Почему это ок | Чего лучше избегать |
|---|---|---|---|
| Controller | @AuthenticationPrincipal | Явная зависимость в сигнатуре, читаемо | Доверять userId из request для self-операций |
| Service | Передать currentUserId параметром или использовать CurrentUserService | Меньше глобального состояния, проще тестировать | Вызовы SecurityContextHolder в каждом методе “просто потому что можно” |
| Repository | Не знать о current user вообще | Репозиторий про данные, не про безопасность | 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 для self-операций из URL, query или body.
Это самая опасная ошибка, потому что она создаёт уязвимость почти незаметно. Вы можете думать, что “у нас же всё закрыто authenticated()”, но 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 — числовой идентификатор в базе данных. Для owner-check вам почти всегда нужен userId, чтобы сравнивать его с authorId/ownerUserId. Путаница здесь приводит к “вечно неработающим” проверкам, когда всё вроде правильно, но сравниваются разные сущности разными типами.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ