JavaRush /Курси /Spring Security /Доступ лише для власника: «свій / чужий»

Доступ лише для власника: «свій / чужий»

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

1. authenticated() — це вхід, а не доступ

Якщо ви вже звикли до URL-правил на кшталт «/api/drafts/** доступно всім, хто аутентифікований», то легко потрапити в пастку: здається, що якщо користувач увійшов у систему і має роль USER, отже, він «у приватній зоні» і може працювати з чернетками. Але приватна зона — це не «спільна кухня», а набір особистих кімнат. А чернетка найчастіше — особиста.

Уявімо типову кінцеву точку:

GET /api/drafts/42 — отримати чернетку за id.

На рівні правил запиту ми зазвичай ставимо щось на кшталт: «якщо ви аутентифіковані — можете заходити». І це правильно: анонімному користувачеві тут робити нічого. Але далі виникає головне запитання: якому саме аутентифікованому користувачеві можна показати цю конкретну чернетку?

Проблема в тому, що роль USER відповідає на запитання «який у вас загальний рівень доступу» (coarse-grained). Ownership відповідає на запитання «цей конкретний об’єкт належить вам?» (object-level). Ці запитання не конкурують; вони просто працюють на різних рівнях.

Щоб це відчути, зручно тримати в голові просту таблицю:

Механізм Відповідає на запитання Приклад Що НЕ вирішує
hasRole("USER") «Ти взагалі користувач?» вхід у приватну зону чия саме чернетка
hasAuthority("draft:read:own") «Тобі можна читати свої чернетки?» право на тип дії чи є цей draftId «своїм»
Owner-check «Ця чернетка твоя?» порівняння authorId і currentUserId не замінює ролі чи authority

І тут є важлива, майже філософська теза: роль — це про «хто ви в системі», а ownership — про «які об’єкти в системі ваші». Це різні координати.

2. Модель ownership: actor, resource, action

Коли ви говорите про «свій / чужий», корисно перестати мислити категоріями «кінцева точка відкрита / закрита» і почати думати як механізм безпеки (або як охоронець у бізнес-центрі, якому дали інструкції та каву). Будь-яке рішення щодо доступу — це перевірка зв’язку: хто діє → що робить → з чим працює. І ownership майже завжди живе в третій частині — «з чим».

У термінології, зручній для backend-розробника, є три сутності:

Actor (суб’єкт) — поточний користувач, якого ми отримали з SecurityContext (наприклад, principal.getUserId()).

Resource (об’єкт) — доменна сутність: ContentItem, UserProfile, StoredFile.

Action (дія) — операція: read/update/delete/submit/publish.

Owner-only правило формулюється дуже буденно: «дію дозволено, якщо actor є власником ресурсу». Технічно це означає: «у ресурсі є поле owner (authorId, userId, ownerUserId), і ми порівнюємо його з довіреним ідентифікатором поточного користувача».

Ось схема, яка допомагає не заплутатися, де саме має жити перевірка:

flowchart TD
    A[HTTP-запит] --> B[SecurityFilterChain]
    B --> C[Контролер]
    C --> D[Сервіс: бізнес-дія]
    D --> E[Завантаження ресурсу з БД]
    E --> F{"ownerId == currentUserId?"}
    F -->|так| G[Виконати дію]
    F -->|ні| H["AccessDeniedException -> 403"]

Зверніть увагу на важливий момент: ownership не перевіряється «в повітрі». Її не можна чесно перевірити, не маючи хоча б двох речей: довіреного ідентифікатора поточного користувача і поля власника в ресурсі. Тому ownership — це майже завжди сервісний рівень (або шар поруч із бізнес-дією), а не просто гарний matcher у SecurityFilterChain.

3. Owner-check у service layer

Іноді здається, що якщо ми вже увімкнули @PreAuthorize, то можна «написати одну анотацію — і безпека готова». На жаль, або на щастя для вашої майбутньої зарплати, реальний світ так не працює. Перевірка ownership майже завжди перетворюється на дуже простий код: завантажили об’єкт, порівняли ownerId, не збіглося — заборонили.

Почнемо з маленького фрагмента моделі. У нашої чернетки є автор:

package com.example.securecontent.content;

public class ContentItem {
    private Long id;
    private Long authorId; // id власника (автора) — саме його порівнюємо з currentUserId

    public Long getId() { return id; }
    public Long getAuthorId() { return authorId; }
}

Так, це не весь entity-клас, але нам зараз важлива саме ідея: у ресурсу є власник.

Тепер — контролер. Ми не довіряємо клієнту authorId і не просимо його передати «я точно користувач 123, чесно-чесно». Ми беремо поточного користувача з security-контексту і передаємо в сервіс довірений ідентифікатор:

package com.example.securecontent.content;

import com.example.securecontent.security.AppUserPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@RestController
class DraftController {

    private final DraftService draftService;

    DraftController(DraftService draftService) {
        this.draftService = draftService;
    }

    @GetMapping("/api/drafts/{id}")
    ContentItem getDraft(@PathVariable Long id,
                         @org.springframework.security.core.annotation.AuthenticationPrincipal
                         AppUserPrincipal principal) {
        // principal — довірене джерело userId: він прийшов із SecurityContext, а не від клієнта
        return draftService.getOwnDraft(id, principal.getUserId()); // довірений userId
    }
}

Сервіс — це місце, де ми ухвалюємо рішення «свій / чужий», тому що саме тут знаходиться бізнес-дія. Спочатку зробімо наївно-прозорий варіант — дві стрічки, які роблять безпеку реальною:

package com.example.securecontent.content;

import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;

@Service
class DraftService {

    private final ContentRepository contentRepository;

    DraftService(ContentRepository contentRepository) {
        this.contentRepository = contentRepository;
    }

    ContentItem getOwnDraft(Long draftId, Long currentUserId) {
        // Спершу завантажуємо ресурс, адже ownership без ownerId ресурсу не перевіряється
        ContentItem draft = contentRepository.findById(draftId).orElseThrow();

        // Важливо: порівнюємо саме ідентифікатори, а не «роль користувача»
        if (!draft.getAuthorId().equals(currentUserId)) {
            // Це свідомо 403 (forbidden) для аутентифікованого користувача
            throw new AccessDeniedException("Чужа чернетка");
        }
        return draft;
    }
}

Якщо draftId не існує, далі має спрацювати звичайний шлях not-found у проєкті. AccessDeniedException тут потрібна саме для випадку, коли об’єкт знайдено, але він чужий.

Тут є два дуже практичні нюанси, які часто ламають новачків.

По-перше, Long треба порівнювати через equals(), а не через ==. == порівнює посилання, тому баг буде «то працює, то ні». Такий баг — ідеальний кандидат на нічний кошмар чергового.

По-друге, AccessDeniedException — це не просто виняток «для краси». Коли в нас уже є AccessDeniedHandler для JSON API, цей виняток перетворюється на коректний 403 з нашим JSON-контрактом помилок, а не на «ой, знову HTML-сторінка».

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

package com.example.securecontent.content;

import java.util.Optional;

interface ContentRepository {
    Optional<ContentItem> findById(Long id);

    // Варіант «з owner-bound фільтрацією»: поверне результат лише тоді, коли збігся власник
    Optional<ContentItem> findByIdAndAuthorId(Long id, Long authorId);
}

Але тут важливо не втратити канонічну політику відповідей. У базовому варіанті цього проєкту ми не змішуємо not found і foreign: якщо об’єкта немає — це 404, якщо об’єкт існує, але чужий — це 403. Тому основним для owner-check залишається явний варіант вище: завантажили об’єкт, порівняли owner, у разі невідповідності кинули AccessDeniedException. А findByIdAndAuthorId(...) зручно тримати як додатковий захисний патерн, а не як заміну цієї політики.

4. API для self: зона /api/me/**

Коли ви вперше проєктуєте API, рука часто тягнеться до «класичного REST»: /api/users/{id}/profile, /api/users/{id}/drafts. І це нормально, це звично. Але для self-сценаріїв (операції «від мого імені») такий дизайн створює зайву поверхню атаки: клієнт починає надсилати вам id, і вам доводиться щоразу доводити, що цей id справді його.

У навчальному проєкті ми спеціально тримаємо окрему зону /api/me/**, щоб self-сценарії були «натуральними»: сервер і так знає, хто прийшов. І тоді ownership перевіряється не як «чи збігається path userId з current userId», а як «ми взагалі не приймаємо userId ззовні».

Порівняйте два підходи. Сумнівний для self-операцій варіант (не обов’язково «поганий завжди», але ризикованіший):

package com.example.securecontent.profile;

import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@RestController
class ProfileController {

    @PatchMapping("/api/users/{userId}/profile")
    void updateProfile(@PathVariable Long userId,
                       UpdateProfileRequest request) {
        // Антипатерн для self: userId, контрольований клієнтом
        // Якщо забути перевірити userId == currentUserId,
        // ви “подаруєте” редагування чужого профілю.
    }
}

Варіант, зручний для self-сценаріїв, який набагато простіше правильно захистити:

package com.example.securecontent.profile;

import com.example.securecontent.security.AppUserPrincipal;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
class MeProfileController {

    private final ProfileService profileService;

    MeProfileController(ProfileService profileService) {
        this.profileService = profileService;
    }

    @PatchMapping("/api/me/profile")
    void updateMyProfile(@AuthenticationPrincipal AppUserPrincipal principal,
                         @RequestBody UpdateProfileRequest request) {
        // У цьому API клієнт НЕ може передати чужий userId: він завжди береться з SecurityContext
        profileService.updateOwnProfile(principal.getUserId(), request); // власник за задумом
    }
}

Тут ownership вбудований у саму форму API: у клієнта просто немає місця, куди він може «підсунути чужий id». Це не скасовує перевірку доступу (endpoint все одно має бути authenticated()), але сильно знижує шанс логічної вразливості «я випадково повірив клієнту».

5. Ownership + method security без простирадл

Після 19-го дня у нас з’явився потужний інструмент — method security. І тут виникає спокуса: «а давайте ownership виразимо цілком у @PreAuthorize». Іноді так справді роблять, але початківцям важливо запам’ятати більш приземлену, стійку думку: method security і owner-check — це шари, які зручно поєднувати, а не намагатися будь-якою ціною запхати в один рядок SpEL.

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

Приклад для читання чернетки:

package com.example.securecontent.content;

import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;

@Service
class DraftService {

    private final ContentRepository repo;

    DraftService(ContentRepository repo) {
        this.repo = repo;
    }

    @PreAuthorize("hasAuthority('draft:read:own')") // право на тип дії — читати свої чернетки
    ContentItem getOwnDraft(Long draftId, Long currentUserId) {
        // Перевірку owner-check не можна виконати, поки не завантажили ресурс (потрібен authorId)
        ContentItem draft = repo.findById(draftId).orElseThrow();

        if (!draft.getAuthorId().equals(currentUserId)) {
            // authority != ownership: право є, але об’єкт може бути чужим
            throw new AccessDeniedException("Чужа чернетка");
        }
        return draft;
    }
}

На людській мові це читається так: «читати свої чернетки можуть лише ті, у кого є authority draft:read:own, і навіть якщо вона є — конкретну чернетку треба перевірити за автором».

Якщо ви хочете зробити метод ще більш «говорячим», можна винести перевірку ownership в окремий маленький компонент. Це корисно, коли перевірка повторюється для update/delete/submit, а ви не хочете копіювати if (!equals) у пʼять методів. Ми не будуємо ACL і policy engine, ми просто робимо маленький helper.

package com.example.securecontent.content;

import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Component;

@Component
class DraftOwnership {

    void assertOwner(ContentItem draft, Long currentUserId) {
        // Єдиний стандарт перевірки: одне місце — менший шанс зробити «схожу, але іншу» перевірку
        if (!draft.getAuthorId().equals(currentUserId)) {
            throw new AccessDeniedException("Чужа чернетка");
        }
    }
}

І в сервісі виходить акуратніше:

package com.example.securecontent.content;

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;

@Service
class DraftService {

    private final ContentRepository repo;
    private final DraftOwnership ownership;

    DraftService(ContentRepository repo, DraftOwnership ownership) {
        this.repo = repo;
        this.ownership = ownership;
    }

    @PreAuthorize("hasAuthority('draft:update:own')") // право на оновлення своїх чернеток
    void updateOwnDraft(Long draftId, Long currentUserId, UpdateDraftRequest req) {
        ContentItem draft = repo.findById(draftId).orElseThrow();
        ownership.assertOwner(draft, currentUserId); // owner-check — частина бізнес-дії
        // draft.update(req) — умовно, бізнес-логіка
    }
}

Зверніть увагу на важливу «методичну чесність»: authority називається draft:update:own, але вона не робить чернетку «own» автоматично. Вона лише говорить: «користувач має право оновлювати свої чернетки». А от чи вони справді свої — зʼясовується через ownership-check.

6. Editor/Admin і ownership: різні гілки

На цьому місці часто виникає плутанина рівня «змішати всі перевірки в одному казані»: розробник намагається в одному методі врахувати і власника, і редактора, і адміна, і «про всяк випадок ще й котика». У підсумку виходить умова на три екрани, і ніхто не впевнений, що вона правильна. У реальному проєкті це прямий шлях до випадково відкритих даних.

Тримаємо модель курсу: owner-only — окремий тип доступу, а editor/admin — це привілейовані гілки. Чернетка може бути «не моя», але редактор може мати право її читати в черзі модерації, а адмін — читати для розслідувань. Це не робить чернетку «моєю», це робить дію «дозволеною за привілеєм».

Практично це виражається так: в owner-методах ми перевіряємо owner, а для привілейованих операцій робимо окремі методи й окремі права. Наприклад, читання чернетки власником:

@PreAuthorize("hasAuthority('draft:read:own')")
ContentItem getOwnDraft(Long draftId, Long currentUserId) { ... }

І читання чернетки для модерації (інший use case, інше право, інша кінцева точка):

package com.example.securecontent.content;

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;

@Service
class EditorDraftService {

    private final ContentRepository repo;

    EditorDraftService(ContentRepository repo) {
        this.repo = repo;
    }

    @PreAuthorize("hasAuthority('draft:review')") // привілей модерації, не "ownership"
    ContentItem getDraftForReview(Long draftId) {
        return repo.findById(draftId).orElseThrow(); // owner-check не потрібен: це не owner-метод
    }
}

Психологічно важливо прийняти: якщо ви додали editor/admin bypass усередині owner-методу, ви вже змішали два світи. Іноді це допустимо в маленькому проєкті, але як навчальний базовий варіант краще тримати гілки окремо, тому що так легше читати й тестувати.

Ownership і коди 401/403

Коли ви впроваджуєте правило «свій / чужий», запитання про 401 і 403 починають виникати постійно. І це добре: значить, у вашій голові з’являється правильний зв’язок між механікою та контрактом API. Логіка тут проста, але її корисно проговорити вголос, щоб не плутати.

Якщо запит робить анонімний користувач (не аутентифікований), але намагається потрапити в приватну зону, то проблема у відсутності аутентифікації. Це 401. Ваш AuthenticationEntryPoint (який ми налаштовували для JSON API) віддасть правильну відповідь.

Якщо запит робить аутентифікований користувач (він «хтось»), але цей «хтось» намагається виконати дію над чужим об’єктом, то проблема у відсутності права доступу до ресурсу. Це 403. Найприродніший спосіб «сигналізувати» про це всередині сервісу — AccessDeniedException.

І це ще одна перевага розміщення owner-check у service layer: саме там ви розумієте контекст і саме там можете вирішити, що це forbidden, а не «просто не знайшли». На рівні контролера без знання бізнес-смислів часто починаються криві рішення на кшталт «повернемо 404, щоб ніхто не здогадався». Це окрема стратегія (іноді допустима), але в межах курсу ми тримаємо поведінку простою і спостережуваною: чуже — це 403.

7. Типові помилки при owner-only доступі

Коли люди вперше впроваджують ownership, вони зазвичай наступають не на «складні криптографічні граблі», а на найпобутовіші — і саме тому вони такі небезпечні: мозок каже «та тут же все очевидно», а через місяць ви знаходите вразливість рівня «користувач змінює чужий профіль». Нижче — набір помилок, які варто навчитися впізнавати за запахом.

Помилка №1: брати userId для self-операцій із запиту (path/query/body).
Це класика жанру: endpoint приймає userId, а ви «чесно» оновлюєте профіль за цим id. Так система починає вірити словам клієнта про те, ким він є. У self-сценаріях довірений ідентифікатор має приходити з SecurityContext, а не з JSON.

Помилка №2: обмежитися authenticated() і вважати, що ownership «якось сам».
authenticated() справді закриває endpoint від анонімних користувачів, але аж ніяк не відрізняє «моє» від «чужого». Тому кінцева точка /api/drafts/{id}, відкрита для всіх аутентифікованих без додаткової перевірки, майже гарантовано перетвориться на «читати будь-яку чернетку за id».

Помилка №3: плутати право типу draft:read:own з фактом володіння конкретною чернеткою.
Authority — це рядок, який говорить «користувачеві дозволено тип дії», але він не знає, чий саме об’єкт зараз запитано. Якщо ви не порівняли authorId із currentUserId, то не зробили ownership-check, навіть якщо назвали authority дуже переконливо.

Помилка №4: зробити owner-check лише в контролері, а сервіс залишити «чистим».
Поки у вас один контролер і одна кінцева точка, це «ніби» працює. Але щойно з’являється другий вхід у сервіс (інша кінцева точка, batch-операція, інтеграція), сервіс стає діркою: він виконує бізнес-дію без перевірки, бо ви винесли security в HTTP-шар. У результаті один новий контролер може випадково відкрити доступ до чужих об’єктів.

Помилка №5: порівнювати Long через == і отримувати «привидні» баги доступу.
Це особливо бридка помилка, тому що вона не завжди проявляється на локальній машині. Сьогодні == спрацював, завтра — ні, а післязавтра ваш тімлід почав підозрювати, що у вас «інколи неправильний користувач». Для object-id завжди використовуйте equals().

1
Задача
Spring Security, 20 рівень, 1 лекція
Недоступна
Читання лише своєї нотатки
Читання лише своєї нотатки
1
Задача
Spring Security, 20 рівень, 1 лекція
Недоступна
Однакова authority, але різні власники
Однакова authority, але різні власники
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ