Spring AOP: proxy і шлях виклику

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

1. Безпека методів і AOP

Щойно @EnableMethodSecurity увімкнено, виникає чесне запитання: як анотація взагалі встигає зупинити виклик методу до виконання бізнес-логіки?

Коли ви вперше бачите @PreAuthorize, виникає природна думка: «Гаразд, анотація стоїть на методі — отже, Java якось перед викликом методу зробить перевірку». І ось тут важливий поворот сюжету: Java так не вміє за замовчуванням. Звичайний виклик методу — це прямий стрибок у тіло методу. Жодного вбудованого «перед входом запитати в охоронця» у чистій Java немає. Spring додає це штучно і робить через механізм, який називається AOP (Aspect-Oriented Programming).

AOP у Spring — це ситуація, коли ви хочете виконати додаткову поведінку навколо виклику методу. Наприклад, перед методом перевірити права, після методу — залогувати подію, обгорнути виконання в транзакцію тощо. Важливий момент: AOP не переписує ваші класи назавжди; найчастіше він працює через обгортку навколо об’єкта.

Безпека методів якраз і потребує поведінки навколо методу: до виконання тіла методу потрібно обчислити вираз із @PreAuthorize, порівняти його з поточним користувачем і, якщо правило не виконується, навіть не запускати бізнес-код. Тобто нам потрібен перехоплювач виклику. І Spring вирішує це через AOP-proxy: «ви не отримуєте сервіс безпосередньо, ви отримуєте сервіс у пакуванні».

Невелика, але корисна думка: якщо ви розумієте proxy-модель безпеки методів, у вас автоматично стає зрозумілішою й інша магія Spring, наприклад @Transactional. Нам тут потрібен лише один зріз AOP: як до входу в метод вклинюється перевірка доступу.

Spring proxy простими словами

Слово proxy звучить як щось із корпоративного світу, але на практиці це дуже проста ідея. Proxy — це об’єкт-посередник, який зовні виглядає як сервіс, але насправді стоїть на вході й вирішує, що робити з кожним викликом методу. Якщо все добре — він передає виклик справжньому об’єкту. Якщо ні — зупиняє виклик, кидає виняток або запускає іншу дію.

Уявіть, що сервіс ReviewService — це директор, який може «опублікувати чернетку». А @PreAuthorize — це правило: «пускати до директора тільки тих, у кого є перепустка draft:publish». Proxy у цій аналогії — секретар директора. Ви можете скільки завгодно стукати у двері, але секретар спочатку подивиться на вашу перепустку. Якщо перепустки немає — до директора ви не дійдете, і ніяке «опублікувати» не виконається.

У Spring це означає практичну річ: коли ви в коді пишете так:

private final ReviewService reviewService;

усередині Spring-контейнера в це поле часто потрапляє не «чистий» ReviewService, а proxy-об’єкт, який виглядає як ReviewService, але всередині містить посилання на реальний ReviewService і набір перехоплювачів (interceptors). Один із таких перехоплювачів — безпека методів.

Саме тому іноді під час логування ви бачите дивні імена класів на кшталт $$SpringCGLIB$$.... Це не тому, що Spring злий. Це тому, що Spring чесно показує: так, я створив обгортку.

3. Шлях виклику захищеного методу

Ззовні все здається ідеально простим: контролер викликає сервіс, сервіс робить справу. Але коли ввімкнено method security, між викликом і тілом методу з’являється смуга перешкод. І це добре: безпека — це як ремінь безпеки. Він теж заважає, поки не врятує.

Уявімо наш сценарій проєкту: кінцева точка для публікації чернетки. Контролер приймає запит і делегує його в сервіс, де і відбувається бізнес-операція. Код, спрощено, може виглядати так:

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 із контексту (і це може бути proxy)
        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 {

    // Правило безпеки перевіряється ДО входу в метод — але тільки якщо виклик пройшов через proxy
    @PreAuthorize("hasAuthority('draft:publish')")
    public void publish(Long draftId) {
        // Тут буде бізнес-логіка публікації.
        // Якщо доступу немає, виконання сюди не дійде.
    }
}

Тепер найважливіше: коли контролер викликає reviewService.publish(id), він викликає метод на proxy, а не на «голому» об’єкті. І шлях далі виглядає приблизно так — сильно спрощено, але за змістом чесно:

flowchart TD
    A[HTTP запит] --> B[Контролер]
    B --> C[ReviewService proxy]
    C --> D[Перехоплювач безпеки методів]
    D -->|дозволено| E["Тіло методу ReviewService.publish(...)"]
    D -->|відхилено| F["AccessDeniedException / 403"]
    E --> G[Репозиторій / БД]

Proxy викликає security-перехоплювач. Перехоплювач бере поточну автентифікацію з SecurityContext і перевіряє правило. Якщо правило не проходить — метод навіть не стартує.

У підсумку маємо дуже практичний ефект: якщо в користувача немає draft:publish, стан чернетки не зміниться, навіть коли хтось випадково відкриє іншу кінцеву точку або викличе сервіс не через очікуваний контролер. І це якраз те, заради чого ми тягнули method security у сервісний шар.

4. Звідки береться proxy

Після розуміння «між викликом і методом стоїть proxy» логічне запитання: «Гаразд, а хто цей proxy створює?» Відповідь: Spring-контейнер. Проксі з’являється лише для тих об’єктів, які Spring створює як beanʼи й якими керує.

Тому є золоте правило, яке звучить надто просто, щоб бути правдою, але воно справді працює: якщо ви створюєте сервіс вручну через new, то method security працювати не буде. Тому що ви обійшли Spring, а отже обійшли і створення proxy, і підключення перехоплювачів.

Порівняймо два підходи.

Ось так — нормально: Spring створює bean, обгортає його в proxy, а ви інжектуєте його:

import org.springframework.stereotype.Service;

@Service
class AdminUserService {
    // Spring керує цим об’єктом: може обгортати його в proxy і додавати перехоплювачі
}

А ось так — «ніби працює», доки не трапиться перший сюрприз:

class SomeUtility {
    void doStuff() {
        // Створили вручну -> Spring не брав участі -> жодних proxy та перехоплювачів тут не з’явиться
        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) {
        // Опублікувати чернетку.
        // У нормальному сценарії сюди потрапимо тільки після перевірки прав (за умови, що виклик ішов через 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 забув, а тому, що він фізично не бачив цей виклик: він відбувався всередині об’єкта.

Це дуже важливий інженерний урок: method security — не магія анотацій, а механізм виклику через proxy.

Часто self-invocation з’являється не спеціально, а як наслідок добрих намірів: ви намагаєтеся повторно використати код, виділяєте частину логіки в окремий метод — і раптом безпека перестає спрацьовувати. Жодної містики: ви просто перенесли виклик усередину класу і випадково вийшли із зони дії proxy.

6. Дизайн сервісів для method security

Після self-invocation виникає запитання: «І що тепер, не можна викликати методи всередині сервісу?» Можна. Просто потрібно пам’ятати, де проходить межа, яку реально бачить proxy.

Практично зручна стратегія для нашого Secure Content Platform API така: анотаціями захищаємо публічні вхідні методи, які викликаються ззовні, зазвичай із контролерів або з інших сервісів, а внутрішні 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) {
        // Фактична логіка публікації без анотацій.
        // Цей метод може викликатися тільки з уже перевірених вхідних методів.
    }
}

Тепер перевірка точно спрацює, тому що зовнішній код викликає publish(...) через proxy, proxy робить перевірку, і лише потім доходить до doPublish(...).

Якщо ж вам потрібно, щоб два різні вхідні методи мали різні правила доступу і водночас викликали спільну частину логіки, це теж вирішується: спільну частину залишають 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, який насправді може бути proxy
        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 не бере участі. У результаті можна отримати ситуацію «анотація є, а перевірки немає». Вирішується це не заклинаннями, а архітектурою: захищайте вхідні методи, виносьте спільну логіку в helper без анотацій або розділяйте виклики на різні Spring-beanʼи.

Помилка № 3: створювати сервіс вручну через new або виносити бізнес-логіку в не-bean і ставити на неї security-анотації.
Method security працює тільки на об’єктах, якими керує Spring. Якщо об’єкт створено вручну, Spring не створює для нього proxy, не підключає перехоплювачі і не знає, що ви взагалі розраховуєте на security. У навчальному проєкті це особливо небезпечно: здається, що все працює, а потім правила доступу раптово зникають.

Помилка № 4: ставити @PreAuthorize на private-методи і дивуватися, що нічого не відбувається.
Часто хочеться сховати нутрощі й анотувати невеликий приватний helper, щоб публічний метод залишався чистим. Але proxy-модель Spring AOP зазвичай не перехоплює private-методи. У результаті ви отримуєте гарний код і нульовий захист. Надійніше анотувати публічний вхідний метод, а внутрішні деталі тримати звичайними методами без анотацій.

Помилка № 5: робити final-клас або final-метод сервісу заради дисципліни і випадково ламати proxy-перехоплення.
Іноді розробники люблять final як символ «все під контролем». Але багато proxy-механік будуються на перевизначенні методів, особливо class-based proxy. Фінальні класи і методи можуть заважати Spring втрутитися у виклик. На рівні курсу простіше дотримуватися базової практики: сервіси зазвичай не роблять final, а захищають дисципліною архітектури і тестами, а не заборонами на наслідування.

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