1. Method security и AOP
Как только @EnableMethodSecurity включен, возникает честный вопрос: как аннотация вообще успевает остановить вызов метода до входа в бизнес-код?
Когда вы впервые видите @PreAuthorize, возникает естественная мысль: «Окей, аннотация стоит на методе — значит Java как-то перед вызовом метода сделает проверку». И вот тут важный поворот сюжета: Java так не умеет по умолчанию. Обычный вызов метода — это прямой прыжок в тело метода. Никакого «перед входом спросить охранника» в чистой Java не встроено. Spring это добавляет искусственно, и делает это через механизм, который называется AOP (Aspect-Oriented Programming).
AOP в Spring — это про ситуацию «я хочу выполнить дополнительное поведение вокруг вызова метода». Например, перед методом проверить права, после метода — залогировать событие, обернуть выполнение в транзакцию и т.д. Важный момент: AOP не изменяет байткод ваших классов навсегда, а чаще всего работает через обёртку вокруг объекта.
Method security как раз и требует поведения вокруг метода: до выполнения тела метода нужно вычислить выражение из @PreAuthorize, сравнить его с текущим пользователем, и если правило не выполняется — даже не запускать бизнес-код. То есть нам нужен перехватчик вызова. И Spring решает это через AOP-proxy: «ты не получаешь сервис напрямую, ты получаешь сервис в упаковке».
Небольшая, но полезная мысль: если вы понимаете proxy-модель method security, у вас автоматически становится понятнее и другая магия Spring, например @Transactional. Нам здесь нужен только один срез AOP: как до входа в метод вклинивается проверка доступа.
Spring proxy простыми словами
Слово proxy звучит как что-то из мира корпоративных динозавров, но на практике это очень простая идея. Proxy — это объект-посредник, который выглядит для вас как сервис, но на самом деле держит дверь и решает, что делать с каждым вызовом метода. Если всё хорошо — он передаёт вызов настоящему объекту. Если плохо — он останавливает вызов и кидает исключение или делает другое поведение.
Представьте, что сервис ReviewService — это директор, который может «опубликовать черновик». А @PreAuthorize — это правило: «пускать к директору только тех, у кого есть пропуск draft:publish». Proxy в этой аналогии — секретарь директора. Вы можете сколько угодно стучаться в дверь, но секретарь сначала посмотрит на ваш пропуск. Если пропуска нет — до директора вы не дойдёте, и никакой «опубликовать» не выполнится.
В Spring это означает практическую вещь: когда вы в коде пишете так:
private final ReviewService reviewService;
внутри Spring-контейнера в это поле часто попадает не «чистый» ReviewService, а proxy-объект, который выглядит как ReviewService, но внутри содержит ссылку на реальный ReviewService и набор перехватчиков (interceptors). Один из таких перехватчиков — method security.
Именно поэтому иногда при логировании вы видите странные имена классов вида $$SpringCGLIB$$.... Это не потому, что Spring злой. Это потому, что Spring честно показывает: да, я создал обёртку.
3. Путь вызова защищённого метода
Снаружи всё выглядит идеально простым: контроллер вызывает сервис, сервис делает дело. Но когда включена method security, между вызовом и телом метода появляется полоса препятствий. И это хорошо: безопасность — это как ремень безопасности. Он тоже мешает, пока не спасёт.
Представим наш проектный сценарий: endpoint публикации черновика. Контроллер принимает запрос и делегирует в сервис, где и происходит бизнес-операция. Код, упрощённо, может выглядеть так:
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class EditorController {
// Важно: сюда инжектится НЕ обязательно "чистый" сервис, а часто proxy-объект
private final ReviewService reviewService;
EditorController(ReviewService reviewService) {
// Spring передаст сюда bean из контекста (и это может быть прокси)
this.reviewService = reviewService;
}
@PostMapping("/api/editor/drafts/{id}/publish")
void publish(@PathVariable Long id) {
// Ключевой момент лекции: вызов идёт на proxy, и уже он решает, можно ли пускать дальше
reviewService.publish(id);
}
}
А сервис:
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
@Service
class ReviewService {
// Security-правило проверяется ДО входа в метод — но только если вызов прошёл через proxy
@PreAuthorize("hasAuthority('draft:publish')")
public void publish(Long draftId) {
// Тут будет бизнес-логика публикации.
// Если доступа нет, выполнение до этой строки не дойдёт.
}
}
Теперь самое важное: когда контроллер вызывает reviewService.publish(id), он вызывает метод на proxy, а не на «голом» объекте. И путь становится примерно таким, сильно упрощённо, но честно по смыслу:
flowchart TD
A[HTTP запрос] --> B[Controller]
B --> C[ReviewService proxy]
C --> D[Method security interceptor]
D -->|allowed| E["ReviewService.publish(...) тело метода"]
D -->|denied| F["AccessDeniedException / 403"]
E --> G[Repository / DB]
Proxy вызывает security-интерцептор. Интерцептор берёт текущую аутентификацию из SecurityContext и проверяет правило. Если правило не проходит — метод даже не стартует.
В результате возникает очень прикладной эффект: если у пользователя нет draft:publish, то статус черновика не поменяется, даже если кто-то случайно откроет второй endpoint или вызовет сервис не через ожидаемый контроллер. И это как раз то, ради чего мы тащили method security в сервисный слой.
4. Откуда берётся proxy
После понимания «между вызовом и методом есть прокси» логичный вопрос: «Окей, а кто этот прокси создаёт?» Ответ: Spring-контейнер. Прокси появляется только для тех объектов, которые Spring создаёт как beans и которыми он управляет.
Поэтому есть золотое правило, которое звучит слишком просто, чтобы быть правдой, но оно правда: если вы создаёте сервис вручную через new, то method security работать не будет. Потому что вы обошли Spring, а значит обошли и создание proxy, и подключение перехватчиков.
Сравните два подхода.
Вот так — нормально (Spring создаёт bean, оборачивает proxy, вы инжектите):
import org.springframework.stereotype.Service;
@Service
class AdminUserService {
// Spring управляет этим объектом: может оборачивать его в proxy и добавлять перехватчики
}
А вот так — «вроде бы работает» до первого сюрприза:
class SomeUtility {
void doStuff() {
// Создали руками -> Spring не участвовал -> никаких прокси и перехватчиков тут не появится
AdminUserService s = new AdminUserService(); // proxy не будет
// Method security не сработает, даже если на методах есть аннотации,
// потому что Spring не перехватывает вызовы на "ручных" объектах.
}
}
В реальном проекте вы, конечно, редко создаёте @Service через new прямо в контроллере. Но похожая проблема встречается в более хитрой форме: когда кто-то выносит бизнес-логику в простой класс без аннотаций и создаёт его вручную, а потом удивляется, что security-аннотации не работают.
Ещё один практический нюанс: Spring AOP, а значит и method security, перехватывает только те методы, которые можно обернуть. В типичном proxy-мире private методы не перехватываются, а final-методы и final-классы могут стать проблемой, особенно если proxy строится через CGLIB. Поэтому «поставлю @PreAuthorize на приватный helper» — часто стратегия, которая даёт красивый код, но нулевую защиту.
5. Self-invocation и обход proxy
Вот здесь начинаются самые весёлые баги. Self-invocation — это ситуация, когда метод внутри класса вызывает другой метод этого же класса. В обычной Java это просто: один метод вызывает другой. Но в Spring AOP это означает: вызов происходит по this, то есть напрямую, без proxy.
Давайте покажем это на примере. Предположим, вы защищаете publish(...), но делаете другой метод, который вызывает его внутри того же класса:
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
@Service
class ReviewService {
@PreAuthorize("hasAuthority('draft:publish')")
public void publish(Long draftId) {
// publish draft
// В нормальном сценарии сюда попадём только после проверки прав (если вызов шёл через proxy)
}
public void publishFromSameClass(Long draftId) {
// Self-invocation: вызов идёт внутри объекта по this, proxy не участвует
publish(draftId); // self-invocation: может обойти proxy
}
}
Почему это «может обойти»? Потому что proxy перехватывает вызовы, которые проходят через него. А publishFromSameClass(...) вызывает publish(...) напрямую, как будто вы написали this.publish(draftId). И если какой-то код вызовет publishFromSameClass(...), то publish(...) может выполниться без проверки @PreAuthorize — не потому что Spring забыл, а потому что Spring физически не видел этот вызов: он происходил внутри объекта.
Это очень важный инженерный урок: method security — не магия аннотаций, а механика вызова через proxy.
Часто self-invocation появляется не специально, а как результат хороших намерений: вы пытаетесь переиспользовать код, выделяете часть логики в отдельный метод — и вдруг безопасность перестаёт срабатывать. Никакой мистики: вы просто переместили вызов внутрь класса и случайно вышли из зоны действия proxy.
6. Дизайн сервисов для method security
После self-invocation возникает вопрос: «И что теперь, нельзя вызывать методы внутри сервиса?» Можно. Просто нужно помнить, где проходит граница, которую реально видит proxy.
Практически удобная стратегия для нашего Secure Content Platform API выглядит так: аннотациями защищаем публичные entry methods, которые вызываются извне, обычно из контроллеров или из других сервисов, а внутренние helper-методы оставляем без security-аннотаций и используем их как чистую бизнес-кухню.
Например, так:
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
@Service
class ReviewService {
@PreAuthorize("hasAuthority('draft:publish')")
public void publish(Long draftId) {
// Внешний вызов (через proxy) -> проверка прав -> только потом внутренняя логика
doPublish(draftId); // внутренний helper без аннотаций
}
void doPublish(Long draftId) {
// Фактическая логика публикации без аннотаций.
// Этот метод может вызываться только из уже проверенных entry methods.
}
}
Теперь проверка точно сработает, потому что внешний код вызывает publish(...) через proxy, proxy делает проверку, и только потом доходит до doPublish(...).
Если же вам нужно, чтобы два разных entry methods имели разные правила доступа, и они должны вызывать общую часть логики, это тоже решается: общая часть остаётся helper-методом или выносится в отдельный компонент без правил, если нужно.
А если ситуация сложнее, и вы действительно хотите, чтобы один защищённый метод вызывался другим защищённым методом, тогда самый простой учебно-правильный путь — разнести эти методы по разным Spring-bean'ам, чтобы вызов между ними шёл через proxy.
Например, выносим публикационную механику в отдельный сервис:
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
@Service
class PublishService {
@PreAuthorize("hasAuthority('draft:publish')")
public void publish(Long draftId) {
// Здесь лежит защищённая операция.
// Важно: она будет проверяться только при вызове через proxy (то есть извне bean'а).
}
}
А оркестрацию оставляем в другом сервисе:
import org.springframework.stereotype.Service;
@Service
class ModerationFlowService {
private final PublishService publishService;
ModerationFlowService(PublishService publishService) {
// Инжектится bean PublishService, который на деле может быть прокси
this.publishService = publishService;
}
public void approveAndPublish(Long draftId) {
// Вызов между разными bean'ами -> шанс пройти через proxy и не обойти security
publishService.publish(draftId); // вызов через другой bean -> proxy работает
}
}
Идея простая: пока вызов идёт между двумя разными Spring-managed сервисами, у Spring есть шанс вклиниться через proxy. Внутри одного класса — шанса нет, потому что вы в прямом смысле разговариваете сам с собой.
Иногда лучше один раз увидеть, чем десять раз прочитать. И тут нам помогает простая диагностика: вывести в лог класс того объекта, который реально инжектится в контроллер или сервис. В method security-эпоху вы часто увидите не красивый ReviewService, а сгенерированный класс.
Можно добавить маленький ApplicationRunner в учебном проекте, чтобы при старте напечатать класс:
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
class ProxyDebugConfig {
@Bean
ApplicationRunner showReviewServiceClass(ReviewService reviewService) {
// В аргументе придёт тот объект, который реально лежит в контексте (часто это proxy)
return args -> System.out.println(reviewService.getClass());
// Например:
// class com.example.securecontent.content.ReviewService$$SpringCGLIB$$0
}
}
Важно относиться к этому как к микроскопу, а не как к постоянному коду в продакшене. Но для обучения это очень полезно: вы прямо видите, что в reviewService лежит обёртка.
Если вы внезапно увидели обычный класс без $$... и при этом method security не работает, это может быть подсказкой: либо method security не включена, либо объект не является Spring-bean'ом, либо вы не проходите через proxy при вызове, например из-за self-invocation. И вот тут у вас появляется главное оружие разработчика: не вера, а диагностика.
7. Типичные ошибки при proxy и method security
Ошибка №1: воспринимать @PreAuthorize как встроенную возможность Java.
Новички часто думают, что аннотация сама по себе живёт на методе и гарантированно выполнится перед вызовом. В реальности @PreAuthorize работает только потому, что Spring перехватывает вызов через AOP и proxy. Если вызов не попал в proxy-механику, аннотация превращается в украшение, а не в защиту.
Ошибка №2: вызывать защищённый метод через self-invocation и ожидать, что проверка сработает.
Когда метод класса вызывает другой метод этого же класса, вызов обычно идёт напрямую через this, то есть proxy не участвует. В результате можно получить ситуацию «аннотация есть, но проверки нет». Лечится это не заклинаниями, а архитектурой: защищайте entry methods, выносите общую логику в helper без аннотаций или разделяйте вызовы на разные beans.
Ошибка №3: создавать сервис вручную через new или выносить бизнес-логику в не-bean и ставить на неё security-аннотации.
Method security работает только на объектах, которыми управляет Spring. Если объект создан руками, Spring не создаёт для него proxy, не подключает перехватчики и не знает, что вы вообще рассчитываете на security. В учебном проекте это особенно опасно: кажется, что пока работает, а потом правила доступа внезапно пропадают.
Ошибка №4: ставить @PreAuthorize на private методы и удивляться, что ничего не происходит.
Часто хочется спрятать внутренности и аннотировать небольшой приватный helper, чтобы публичный метод оставался чистым. Но proxy-модель Spring AOP обычно не перехватывает private-методы. В результате вы получаете красивый код и нулевую защиту. Надёжнее аннотировать публичный entry method, а внутренние детали держать обычными методами без аннотаций.
Ошибка №5: делать final класс или final метод сервиса ради дисциплины и случайно ломать proxy-перехват.
Иногда разработчики любят final как символ «всё под контролем». Но многие proxy-механики строятся на переопределении методов, особенно class-based proxy. Финальные классы и методы могут мешать Spring вклиниться в вызов. На уровне курса проще придерживаться базовой практики: сервисы обычно не делают final, а защищают дисциплиной архитектуры и тестами, а не запретами на наследование.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ