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). Лог має допомагати вам, а не зловмиснику.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ