Spring AOP: proxy и путь вызова

Spring Security
19 уровень , 2 лекция
Открыта

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, а защищают дисциплиной архитектуры и тестами, а не запретами на наследование.

1
Задача
Spring Security, 19 уровень, 2 лекция
Недоступна
Отобразить proxy у защищённого сервиса
Отобразить proxy у защищённого сервиса
1
Задача
Spring Security, 19 уровень, 2 лекция
Недоступна
Показать self-invocation на одном сервисе
Показать self-invocation на одном сервисе
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ