1. Від 403 до authorities
Уявіть ситуацію: ви чесно входите в систему, отримуєте хороший JWT, кладете його в Authorization: Bearer ..., надсилаєте запит — і все одно отримуєте 403 Forbidden. Це дуже типова проблема під час переходу на вбудовану JWT-підтримку: аутентифікація пройшла, а авторизація — ні, тому що в поточного користувача або немає прав, або вони названі так, що Spring Security їх не розпізнає. Це як прийти в офіс із справжнім паспортом — ви є в системі, усе гаразд, — але без перепустки на турнікет: у зону ви все одно не потрапите.
У Spring Security корисно одразу розкладати помилку на два запитання. Якщо ви бачите 401, отже система каже: «Я не знаю, хто ви» або «Доведіть, хто ви». Якщо ви бачите 403, отже система каже: «Я знаю, хто ви, але вам не можна». У JWT-гілці 403 після валідного токена майже завжди означає одну з двох речей: або authorities не витягнуті з токена, або витягнуті, але не збігаються за назвами з тим, що перевіряють hasAuthority(...) і hasRole(...). І ось тут починається найцікавіше: claims токена потрібно правильно зіставити з мовою Spring Security.
Перетворення claims у GrantedAuthority
Вбудована JWT-підтримка хороша тим, що не ламає вашу базову ментальну модель Spring Security. Вона, навпаки, її підкреслює: є вхідний запит, усередині ланцюга безпеки відбувається аутентифікація, а результат потрапляє в SecurityContext. Головна відмінність від нашого шляху з власним фільтром у тому, що частину інфраструктурного коду тепер пише Spring Security, а не ми. Але ключові точки залишаються знайомими: у нас з’являється Authentication, і всередині нього є набір GrantedAuthority, за якими далі працюють правила доступу.
У вбудованому JWT-шляху є дуже зрозумілий вузол, у якому вирішується доля прав доступу: це перетворення Jwt → Authentication. Усередині цієї стадії Spring Security використовує JwtAuthenticationConverter, а всередині нього за замовчуванням — JwtGrantedAuthoritiesConverter. Тобто валідація підпису, exp і формат токена — це одна історія, яку робить JwtDecoder, а «які права у користувача?» — окрема історія, і її бере на себе конвертор. Це добре видно на схемі:
flowchart TD
A["Authorization: Bearer JWT"] --> B["JwtDecoder перевіряє підпис і exp"]
B --> C["Розкриті claims JWT"]
C --> D["JwtAuthenticationConverter будує Authentication"]
D --> E["JwtAuthenticationToken + authorities"]
E --> F["SecurityContext"]
F --> G["Правила авторизації hasRole/hasAuthority/@PreAuthorize"]
Ключова думка лекції: JwtDecoder відповідає за довіру до токена, але не зобов’язаний вгадувати, де у вас ролі, де permissions, як ви їх називаєте і які префікси вам подобаються. Це завдання зіставлення claims і authorities. Якщо воно неправильне, ви отримаєте коректний JwtAuthenticationToken, але з порожніми або «не тими» authorities — і далі закономірний 403.
2. Контракт JWT для прав
Але перш ніж крутити конвертори, потрібно зафіксувати сам контракт токена. JwtAuthenticationConverter не витягує права з повітря: він читає лише те, що ваш POST /api/auth/login і TokenService реально поклали в payload. Якщо токен несе лише sub або використовує іншу назву claim, вбудований шлях чесно аутентифікує запит, але не наповнить Authentication тим набором прав, якого очікують hasRole(...) і hasAuthority(...).
Коли ця межа зрозуміла, можна чесно відповісти на запитання: а що ми взагалі кладемо в JWT у нашому Secure Content Platform API? Ми ж не в порожнечі працюємо: у проєкті вже є модель доступу, вже є правило hasRole("ADMIN"), уже є @PreAuthorize("hasAuthority('draft:publish')"), і вже є домовленості щодо іменування прав — наприклад, profile:read і user:manage. Якщо токен не несе цю інформацію, Spring Security її не вигадує. З іншого боку, якщо токен несе все підряд, ви отримаєте надмірно роздутий токен, у якому важко розібратися.
Найпрактичніший і дружній до новачка підхід у навчальному проєкті — домовитися, що JWT містить один claim зі списком готових authorities, тобто рядків, які ми хочемо бачити всередині Authentication.getAuthorities(). Це означає, що ролі теж можна представити як authorities, просто за правилом Spring: роль ADMIN — це authority ROLE_ADMIN. Тоді в токені ми можемо зберігати, наприклад, ROLE_USER, ROLE_EDITOR і ROLE_ADMIN, щоб працювали hasRole(...), а також profile:read, draft:publish, user:manage тощо, щоб працювали hasAuthority(...) і @PreAuthorize.
Для наочності зручно тримати маленьку таблицю відповідностей:
| Що перевіряємо в коді | Що насправді шукає Spring Security | Як це має виглядати в GrantedAuthority |
|---|---|---|
| hasAuthority("draft:publish") | "draft:publish" | "draft:publish" |
| hasRole("ADMIN") | "ROLE_ADMIN" | "ROLE_ADMIN" |
Тобто роль — це не окрема сутність, а домовленість про іменування authority. І якщо ви це прийняли, усе стає простіше: один список рядків, без магії.
На цьому місці в новачків зазвичай з’являється спокуса покласти в токен права лише для власника — наприклад, «ось цьому користувачу можна редагувати чернетку з ID 123». Не треба так робити. Правила доступу за власником у нашому курсі — це окремий клас правил, вони прив’язані до конкретного об’єкта (draftId, userId) і мають перевірятися в бізнес-шарі або через method security з доступом до даних, а не перетворюватися на строкову кашу всередині токена. JWT добре підходить для «хто ви» і «які у вас глобальні права», але не для «цей чернетковий документ ваш чи чужий».
3. JwtGrantedAuthoritiesConverter: claim і префікс
Тепер переходимо до найприкладнішої частини: як Spring Security дістає authorities із Jwt. Клас JwtGrantedAuthoritiesConverter — це маленький, але надзвичайно важливий перекладач, який читає один claim і перетворює його на колекцію GrantedAuthority. Проблема в тому, що за замовчуванням він орієнтується на світ OAuth2 scopes і зазвичай шукає scope/scp, а потім додає префікс SCOPE_. У нашому навчальному проєкті ми не хочемо, щоб право draft:publish раптом стало SCOPE_draft:publish, інакше наші перевірки перестануть збігатися буквально за рядками.
Тому базове налаштування тут — вказати конвертору, з якого claim читати список прав і який префікс додавати або не додавати. Приклад конфігурації логічно тримати в пакеті com.example.securecontent.security.config:
import org.springframework.context.annotation.Bean;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
@Bean
JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter() {
// Важлива точка: саме тут ми говоримо Spring Security,
// де в JWT лежать права і чи потрібно додавати префікс.
JwtGrantedAuthoritiesConverter converter = new JwtGrantedAuthoritiesConverter();
converter.setAuthoritiesClaimName("authorities"); // читаємо наш claim (контракт токена)
converter.setAuthorityPrefix(""); // не додаємо "SCOPE_", залишаємо рядки як є
return converter;
}
Це налаштування каже Spring Security дуже просту річ: «У токені є claim authorities, він уже містить рівно ті рядки, які ми хочемо бачити як права, тому нічого додатково не додавай». У підсумку hasAuthority("draft:publish") продовжить працювати як і працювало, тому що ми не змінюємо мову прав у застосунку — ми лише пояснюємо Spring Security, де ці права лежать у токені.
Якщо ви раптом обрали інший claim, наприклад permissions, змінюється лише один рядок. Головне — не переплутати: назва claim у конверторі має збігатися з тим, що реально кладе TokenService під час видавання токена. Інакше ви отримаєте порожній список authorities і той самий валідний токен, але 403.
4. JwtAuthenticationConverter: збірка Authentication
З JwtGrantedAuthoritiesConverter ми навчилися отримувати сировину — набір GrantedAuthority. Але Spring Security насправді очікує не просто набір прав, а повноцінний Authentication, який ляже в SecurityContext. За це відповідає JwtAuthenticationConverter: він бере Jwt, витягує authorities через внутрішній конвертор і збирає JwtAuthenticationToken. Це як складання бургера: Jwt — булочки, authorities — котлета, а JwtAuthenticationToken — підсумок, який можна «їсти» всюди в застосунку.
Найпряміший шлях — створити bean JwtAuthenticationConverter, який використовує наш JwtGrantedAuthoritiesConverter:
import org.springframework.context.annotation.Bean;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
@Bean
JwtAuthenticationConverter jwtAuthenticationConverter(
JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter
) {
// Цей конвертор перетворює Jwt у Authentication, який потрапить у SecurityContext.
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
// Підключаємо наше зіставлення прав: від claim до GrantedAuthority.
converter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
return converter;
}
Зверніть увагу на ідею: ми не розмазуємо логіку зіставлення по всьому проєкту, а тримаємо її в одному місці. Це важлива дисципліна, інакше за кілька днів у вас вийде ситуація, коли правила на рівні URL очікують одне, @PreAuthorize — інше, а токен видає третє. У security такий безлад зазвичай закінчується або прогалиною, або нескінченним ланцюгом «чому 403?».
Іноді потрібна трохи складніша поведінка. Наприклад, ви вирішили зберігати ролі в окремому claim roles: ["ADMIN", "EDITOR"], а permissions — в окремому claim authorities: ["draft:publish"]. У такому разі JwtGrantedAuthoritiesConverter «з коробки» не вміє об’єднувати два різні джерела. Тоді можна написати маленький кастомний конвертор, який зробить об’єднання. У навчальному проєкті це допустимо, якщо тримати код коротким і читабельним:
import java.util.List;
import java.util.stream.Stream;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
Converter<Jwt, List<GrantedAuthority>> mergedAuthorities() {
// Ідея: беремо ролі та permissions із різних claim'ів і поєднуємо в один список authorities.
// Важливо: roles перетворюємо на "ROLE_..." (так очікує hasRole()).
return jwt -> Stream.concat(
// roles: ["ADMIN", "EDITOR"] -> ["ROLE_ADMIN", "ROLE_EDITOR"]
jwt.getClaimAsStringList("roles").stream().map(r -> "ROLE_" + r),
// authorities: ["draft:publish", ...] -> як є
// Якщо claim відсутній або порожній — залежно від вашого Jwt,
// тут може знадобитися захист від null (дивіться поведінку конкретного токена).
jwt.getClaimAsStringList("authorities").stream()
)
// Кожен рядок перетворюємо на GrantedAuthority, яку розуміє Spring Security.
.map(SimpleGrantedAuthority::new)
.toList();
}
Це вже трохи гостріший варіант для початківців, тому в курсі частіше зручно обрати простий контракт «усе в одному claim authorities», а об’єднання залишити як запасний план. Але важливо, що вбудований шлях це дозволяє: ви не зачинені в магії, ви просто використовуєте стандартну точку розширення.
5. Підключення та перевірка
Тепер фінальний крок склеювання: ми зробили конвертори, але Spring Security сам їх не вгадає. У вбудованому JWT-шляху точка підключення — jwtAuthenticationConverter всередині oauth2ResourceServer(...). Важливо, що ми зараз не змінюємо ні access matrix, ні ролі проєкту, ні @PreAuthorize: ми просто робимо так, щоб Spring Security наповнював SecurityContext правильними authorities.
Мінімальний фрагмент SecurityFilterChain може виглядати так:
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
@Bean
SecurityFilterChain apiSecurity(HttpSecurity http, JwtAuthenticationConverter converter) throws Exception {
http
// JWT зазвичай використовується без сесій: кожен запит самодостатній і несе токен.
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// Увімкніть Resource Server і підставте наш JwtAuthenticationConverter,
// щоб authorities із токена потрапили в Authentication так, як ми очікуємо.
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(converter)));
return http.build();
}
Після цього ваші правила на кшталт .requestMatchers("/api/admin/**").hasRole("ADMIN") і сервісні анотації на кшталт @PreAuthorize("hasAuthority('draft:publish')") мають запрацювати без переписування, якщо authorities в Authentication збігаються за рядками з тим, що перевіряється.
Щоб швидко побачити, що реально потрапило в контекст, іноді корисно тимчасово зробити маленький діагностичний endpoint у зоні /api/me. Наприклад, повернути список authorities поточного користувача:
import java.util.List;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class MeDebugController {
@GetMapping("/api/me/authorities")
List<String> authorities(JwtAuthenticationToken auth) {
// Діагностика: показуємо, які рядки реально лежать у Authentication.getAuthorities().
// Це допомагає швидко зрозуміти, чому hasRole/hasAuthority не збігаються з токеном.
return auth.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.toList();
}
}
Це дуже швидко перетворює «мені здається, що права не ті» на «ось конкретний список рядків, тепер усе зрозуміло». І так, у security іноді найкорисніша навичка — перестати здогадуватися і почати бачити.
6. Типові помилки під час зіставлення claims → authorities
Помилка №1: токен валідний, але authorities порожні через неправильну назву claim.
Найчастіший сценарій виглядає так: у токені лежить authorities, а в конверторі ви випадково написали authority або permissions. У результаті JwtGrantedAuthoritiesConverter чесно читає «нічого» і повертає порожній список. Далі Spring Security так само чесно видає 403, тому що прав немає. Це не баг Spring — це баг контракту.
Помилка №2: префікс з’їхав, і рядки перестали збігатися з hasAuthority(...).
Якщо конвертор додає SCOPE_, а ваші перевірки очікують draft:publish, ви отримаєте ідеально валідований токен та ідеально заборонений доступ. У security рядки мають збігатися буквально. Тому або ви змінюєте весь проєкт на перевірки SCOPE_... — що для навчального домену зазвичай зайве, — або вимикаєте префікс через setAuthorityPrefix("").
Помилка №3: ролі кладуть як ADMIN, а перевірки написані через hasRole("ADMIN"), але ROLE_ так і не з’явився.
hasRole("ADMIN") шукає authority ROLE_ADMIN. Якщо ви поклали в токен просто ADMIN і не додали ROLE_ на етапі зіставлення, правило не спрацює. Або кладіть у токен уже ROLE_ADMIN, або робіть конвертацію ролей у ROLE_... через власний конвертор. Це не дрібниця іменування, а фундаментальний контракт.
Помилка №4: намагаються засунути доступ за власником у authorities і перетворюють JWT на звалище.
Іноді хочеться зробити authority на кшталт draft:update:own:123. Технічно ви можете так зробити, але вб’єте читабельність, ускладните відкликання і почнете підміняти бізнес-перевірки рядковими трюками. Правила за власником мають залишатися правилами за власником: вони залежать від конкретного об’єкта й найчастіше потребують доменних даних, а не лише токена.
Помилка №5: починають переписувати @PreAuthorize і правила на рівні URL замість того, щоб спершу полагодити зіставлення.
Коли прилітає 403, рука тягнеться «ну гаразд, зроблю permitAll() або зміню анотацію». Це приблизно як лагодити зламаний замок тим, що ви знімаєте двері з петель. У нормальному проєкті правильний порядок такий: спершу дивимося, які authorities реально потрапили в Authentication, потім виправляємо конвертор або контракт, і лише потім, якщо потрібно, обговорюємо зміни правил доступу.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ