JavaRush /Курси /Spring Security /Правила доступу за власником:

Правила доступу за власником: /api/me і drafts

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

1. authenticated() — лише перший бар’єр

Ролі, authorities і @PreAuthorize уже відповіли на запитання, якому типу користувача взагалі можна виконувати дію. Але для /api/me і чернеток цього замало. Тут система має не просто впустити користувача в закриту зону, а пов’язати конкретний запит із конкретним власником ресурсу. Тому корисно одразу зібрати базову схему для чотирьох сценаріїв: GET /api/me, PATCH /api/me/profile, GET /api/drafts і GET /api/drafts/{id}.

Якщо чесно, authenticated() — це лише замок на під’їзді. Він корисний, але не відповідає на запитання: «а ви в яку квартиру?». Для доступу за власником це саме перший бар’єр: він не впускає аноніма, але не вирішує, ця чернетка ваша чи чужа.

Важливо й інше: ця схема переживе перехід до stateless-моделі. Зміниться лише те, як Authentication потрапляє до SecurityContext, але current user, перевірка власника та розрізнення між 401 і 403 залишаться тими самими.

2. Скелет маршруту: SecurityFilterChain → controller → service → DB

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

flowchart TD
    A["HTTP-запит"] --> B["SecurityFilterChain
правила на рівні запиту"] B -->|"401 для аноніма"| X["AuthenticationEntryPoint"] B --> C["Контролер
@AuthenticationPrincipal"] C --> D["Сервіс
бізнес-операція"] D --> E["Repository/DB
завантажити цільовий об’єкт"] E --> F{"перевірка власника:
authorId == currentUserId?"} F -->|"ні"| Y["AccessDeniedException -> 403
AccessDeniedHandler + JSON-помилка"] F -->|"так"| G["Бізнес-логіка + відповідь"]

Ідея проста: SecurityFilterChain відсікає аноніма й розмежовує зони доступу, контролер отримує довіреного поточного користувача, сервіс ухвалює бізнес-рішення, а БД повертає ownerId конкретного об’єкта. Контролер тут не стає мініслужбою безпеки. Він лише передає в сервіс «хто прийшов» у вигляді довіреного ідентифікатора.

На рівні authorizeHttpRequests тут достатньо звичайного DSL: authenticated(), hasAnyRole(), hasRole(), denyAll(). Цими правилами ми розмежовуємо зони доступу. Expression-based access через access(...) і SpEL — окремий інструмент; для цього базового сценарію перевірку власника ми не ховаємо туди, а залишаємо в сервісі, поруч із бізнес-дією.

Приклад конфігурації «на рівень 20» — без зайвої екзотики, просто читабельна карта зон:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
class SecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(auth -> auth
            // Self і drafts: пропускаємо тільки автентифікованих (це "перший бар’єр")
            .requestMatchers("/api/me/**", "/api/drafts/**").authenticated()

            // Редакторська зона: тільки ролі EDITOR або ADMIN
            .requestMatchers("/api/editor/**").hasAnyRole("EDITOR", "ADMIN")

            // Адмінська зона: тільки ADMIN
            .requestMatchers("/api/admin/**").hasRole("ADMIN")

            // Усе, що не описано явно, закриваємо
            .anyRequest().denyAll()
        );
        return http.build();
    }
}

Ці правила розмежовують зони доступу, але не перетворюють будь-яку чернетку всередині /api/drafts/** на «свою» автоматично. Рішення лише для власника все одно живе поруч із конкретною бізнес-дією.

3. Self-ендпоінти: /api/me/** без userId

Self-ендпоінти — це маленька інженерна зручність. Вони дозволяють зробити API одночасно безпечнішим і простішим. У таких ендпоінтах ми прямо говоримо: «це дія від імені поточного користувача». Отже, ми не приймаємо userId у path або body, бо це одразу створює спокусу: «а що, якщо підставити інший id?». Клієнту не потрібно повідомляти серверу, хто він. Сервер уже це знає.

Почнемо з простого /api/me. Ми беремо principal із контексту безпеки й віддаємо клієнту мінімальну інформацію: id і username. Важливо: ми не звертаємося до БД лише з інерції, якщо в principal уже є userId. Якщо у вашій реалізації principal зберігає тільки username — теж нормально, але тоді ви можете додатково довантажити користувача за 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 {

    // Self-endpoint: працюємо тільки з поточним користувачем, без userId від клієнта
    @GetMapping("/api/me")
    MeResponse me(@AuthenticationPrincipal AppUserPrincipal principal) {
        // Дістаємо довірений ідентифікатор із principal, а не із запиту
        return new MeResponse(principal.getUserId(), principal.getUsername());
    }
}

DTO варто зробити максимально скромним. Чим менше ви віддаєте назовні, тим менше потім доведеться пояснювати, звідки взялося зайве поле.

// Мінімальна відповідь: тільки те, що справді потрібно клієнту
public record MeResponse(Long id, String username) {
}

Тепер /api/me/profile. Тут логіка схожа, але особливо важливо, щоб оновлення профілю не приймало userId. Якщо клієнт намагається оновити профіль користувача 777, сервер має відповісти: «дуже цікаво, а я хочу у відпустку».

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;
    }

    // Self-endpoint: оновлюємо профіль лише поточного користувача
    @PatchMapping("/api/me/profile")
    void update(@AuthenticationPrincipal AppUserPrincipal principal,
                @RequestBody UpdateProfileRequest request) {
        // currentUserId беремо тільки з security-контексту (principal)
        profileService.updateOwnProfile(principal.getUserId(), request);
    }
}

І запит на оновлення профілю нехай буде просто «що змінити», без ідентифікаторів:

public record UpdateProfileRequest(String displayName, String bio) {
}

Тут власник фактично вбудований у форму API: сервіс не з’ясовує, чи збігається path userId з currentUserId, бо path userId узагалі немає. Він просто отримує довірений currentUserId і виконує звичайну бізнес-операцію.

4. Чернетки: /api/drafts і /api/drafts/{id}

У /api/me/** клієнту просто немає куди підкласти чужий id. У чернеток усе навпаки: id ресурсу приходить ззовні. Тому тут current user із principal має зустрітися з ownerId ресурсу з БД.

У контролері клієнт і надалі передає тільки draftId. currentUserId беремо з principal:

import org.springframework.security.core.annotation.AuthenticationPrincipal;
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 DraftQueryService draftQueryService;

    DraftController(DraftQueryService draftQueryService) {
        this.draftQueryService = draftQueryService;
    }

    // Важливо: клієнт передає тільки id чернетки, а currentUserId беремо з principal
    @GetMapping("/api/drafts/{id}")
    ContentItem get(@PathVariable Long id,
                    @AuthenticationPrincipal AppUserPrincipal principal) {
        return draftQueryService.getOwnDraft(id, principal.getUserId());
    }
}

Для списку логіка ще простіша: ми не завантажуємо «усі чернетки системи», щоб потім фільтрувати їх у пам’яті. Ми одразу робимо owner-bound запит у БД.

import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;

interface ContentItemRepository extends JpaRepository<ContentItem, Long> {

    // Важливо: фільтрація за authorId відбувається на рівні БД, а не "після завантаження всіх даних"
    List<ContentItem> findAllByAuthorId(Long authorId);
}
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
import java.util.List;

@Service
class DraftService {

    private final ContentItemRepository repository;

    DraftService(ContentItemRepository repository) {
        this.repository = repository;
    }

    // Безпека на рівні методів: навіть список "своїх" чернеток потребує окремого права
    @PreAuthorize("hasAuthority('draft:read:own')")
    List<ContentItem> listOwnDrafts(Long currentUserId) {
        // currentUserId має надходити з довіреного джерела (principal), а не з параметрів запиту клієнта
        return repository.findAllByAuthorId(currentUserId);
    }
}

А ось GET /api/drafts/{id} уже потребує явної перевірки власника: current user приходить із security-шару, а ownerId — із самого об’єкта.

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

@Service
class DraftQueryService {

    private final ContentItemRepository repository;

    DraftQueryService(ContentItemRepository repository) {
        this.repository = repository;
    }

    // Права на читання «своїх» чернеток
    @PreAuthorize("hasAuthority('draft:read:own')")
    ContentItem getOwnDraft(Long draftId, Long currentUserId) {
        // 1) Знаходимо об’єкт за id (це ще не авторизація)
        ContentItem draft = repository.findById(draftId).orElseThrow();

        // 2) Авторизація за власником: якщо чужий — віддаємо 403 через AccessDeniedException
        if (!draft.getAuthorId().equals(currentUserId)) {
            throw new AccessDeniedException("foreign_draft");
        }
        return draft;
    }
}

Якщо draftId не існує, спрацьовує звичайний шлях обробки відсутнього ресурсу в проєкті — це 404. Якщо об’єкт знайдено, але власник не збігся, це вже 403.

Якщо редактору потрібен доступ до чужих чернеток для review, це окремий endpoint і окрема authority на кшталт draft:review. Метод лише для власника не має раптово перетворюватися на комбайн owner || editor || admin.

5. Що виходить на виході: 401, 403, 404 і слід у логах

Тепер маршрут можна прочитати до кінця. Якщо запит прийшов від anonymous, його зупиняє SecurityFilterChain, а AuthenticationEntryPoint повертає 401. Якщо draftId узагалі не існує, спрацьовує звичайний шлях обробки відсутнього ресурсу в проєкті — це 404. Якщо об’єкт існує, але перевірка власника в сервісі не пройшла, AccessDeniedException перетворюється на 403 через AccessDeniedHandler.

Це й є канонічна базова схема сценаріїв лише для власника: не змішувати «не знайдено» і «чуже».

Поруч із перевіркою власника корисно залишити короткий слід: actor, action, target. Загальний 403 можна додатково фіксувати на boundary-рівні, але точне порушення прав найкраще видно прямо поруч із рішенням про доступ.

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.access.AccessDeniedException;

class DraftOwnership {

    private static final Logger log = LoggerFactory.getLogger(DraftOwnership.class);

    static void requireOwner(Long draftAuthorId, Long currentUserId, Long draftId) {
        // Центральна точка ухвалення рішення: "свій/чужий"
        if (!draftAuthorId.equals(currentUserId)) {
            // Логуємо тільки actor/action/target — без токенів і заголовків
            log.warn("access_denied action=read_draft actorUserId={} draftId={}",
                    currentUserId, draftId);

            // Далі це перетворюється на 403 через AccessDeniedHandler
            throw new AccessDeniedException("foreign_draft");
        }
    }
}

Цього достатньо, щоб потім знайти спроби читання чужих чернеток, не друкуючи токени, cookies і заголовки.

6. Типові помилки

Помилка №1: self-endpoint приймає userId від клієнта «для зручності».
Це зазвичай починається з фрази: «ну раптом фронтенду так простіше». Потім хтось підставляє інший userId, і ви раптово винайшли API для перегляду чужих профілів. Self-ендпоінти хороші тим, що взагалі не потребують довіряти вхідним ідентифікаторам: current user береться із security-контексту, і крапка.

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

Помилка №3: перевірку власника роблять у контролері, а сервіс залишається «сліпим».
Спочатку здається логічним: «у контролері ж є @PathVariable id, от там і перевіримо». Але щойно бізнес-операція починає викликатися не лише з одного контролера або ви додаєте другий endpoint, перевірки розповзаються. Саме сервісний шар — це місце, де правило «свій/чужий» живе стійко й однаково.

Помилка №4: AccessDeniedException замінюють на «повернемо 404, щоб не палити, що об’єкт існує».
Це інколи буває усвідомленою стратегією, але для нашої базової схеми ми розрізняємо «не знайдено» і «знайдено, але не можна». Якщо чернетка є, але вона чужа — це 403. Якщо чернетки немає — це 404. Змішування цих смислів погіршує підтримку й діагностику, а ще ламає зрозумілу карту 401/403.

Помилка №5: у лог пишуть зайве — аж до токенів, cookie й заголовків.
Відмова за власником — чудова нагода для warn-лога з actor/action/target. Але це не привід логувати «все, що прийшло в запиті». Особливо погано логувати значення, які потім можна використати повторно (session id, CSRF token, Authorization header). Лог має допомагати вам, а не зловмиснику.

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