1. Одне завдання — дві реалізації
Якщо ви коли-небудь відкривали чужий проєкт і бачили там одночасно JwtAuthFilter, oauth2ResourceServer().jwt(), три конвертери, два AuthenticationEntryPoint і ще одну «маленьку утиліту для токенів», то ви знаєте це відчуття: «Я нічого не чіпав, воно само зламалося». Ця лекція — про те, як не потрапити в таку ситуацію. Ми порівнюємо два шляхи не заради холівару, а заради простої інженерної навички: відокремлювати модель безпеки від способу її реалізації.
Тепер, коли вже зрозуміло, як Bearer-рядок проходить через фільтр, decoder, converter і потрапляє до SecurityContext, можна цілком порівняти обидва шляхи. Щоб порівняння не розповзалося, візьмемо той самий Secure Content Platform API, але в профілі jwt-rs, де вбудований Bearer-пайплайн активний лише один. Гілка custom filter потрібна тут лише як точка порівняння, а не як сусідній механізм у тому самому запущеному застосунку.
Найважливіше: custom JWT filter і вбудований resource-server path розв’язують однакове бізнес-завдання. Обидва мають відновити поточного користувача з Bearer JWT на кожному запиті й покласти результат у SecurityContext. Різниця не в тому, що ми хочемо отримати, а в тому, хто пише та підтримує інфраструктурний клей — ви чи Spring Security.
Порівнювати будемо чесно: не змінюючи модель доступу, а змінюючи лише двигун, який під капотом відновлює автентифікацію.
2. Спільні елементи в обох підходах
Коли люди мігрують на інший JWT-підхід, вони часто роблять дивну річ: одночасно змінюють claims у токені, переписують hasAuthority(...), чіпають @PreAuthorize, змінюють кінцеві точки, а потім не розуміють, що саме зламалося. Давайте зробимо навпаки: зафіксуємо речі, які мають бути стабільні. Інакше порівняння перетвориться на лотерею.
По-перше, в обох підходах залишається той самий контракт токена. Якщо в токені ми домовилися зберігати claim authorities зі строками на кшталт draft:publish і user:manage, то і custom filter, і вбудований шлях повинні читати це однаково. По-друге, незмінною залишається матриця доступу на рівні запитів і методів. Якщо /api/admin/** вимагає ROLE_ADMIN, а publish(...) вимагає draft:publish, то ці правила не повинні «переїжджати» лише тому, що ми змінили реалізацію перевірки JWT. По-третє, owner-only правила не перетворюються на строки в токені: вони й далі живуть у бізнес-авторизації, а не в «магічному claimʼі».
Зручно побачити це у вигляді таблиці:
| Область | Custom JWT filter | Built-in oauth2ResourceServer().jwt() | Має збігатися? |
|---|---|---|---|
| Безстанний режим | SessionCreationPolicy.STATELESS | SessionCreationPolicy.STATELESS | Так |
| Джерело автентифікації | Authorization: Bearer ... | Authorization: Bearer ... | Так |
| Видача токена | POST /api/auth/login + TokenService | те саме | Так |
| Правила доступу за URL | authorizeHttpRequests(...) | authorizeHttpRequests(...) | Так |
| Method security (@PreAuthorize) | працює за authorities | працює за authorities | Так |
| Спосіб валідації токена | ваш код | JwtDecoder | Ні (це й порівнюємо) |
| Спосіб перетворення claims → authorities | ваш код | JwtAuthenticationConverter | Ні (але результат має збігатися) |
Якщо ви тримаєте цей «якір стабільності», то можете спокійно мігрувати інфраструктуру: змінюєте лише те, як із Bearer-рядка отримується Authentication, але не чіпаєте сенс доступу.
3. Як токен стає поточним користувачем
У будь-якого JWT-підходу є момент істини: коли в SecurityContext з’являється Authentication. До цього моменту ваш код має ставитися до запиту як до anonymous. Після цього — як до запиту автентифікованого користувача з конкретними roles/authorities.
Порівняймо пайплайни як дві схеми. Я спеціально намалюю їх в однаковому масштабі, щоб стало видно: різниться не сенс, а блоки відповідальності.
Власний JWT-фільтр: «усе робимо самі»
flowchart TD
A["HTTP-запит"] --> B["JwtAuthFilter: читаємо Authorization"]
B --> C["TokenService: розбір + перевірка"]
C --> D["Збираємо Authentication вручну"]
D --> E["SecurityContextHolder.setContext"]
E --> F["Правила авторизації + контролер + сервіс"]
Ідея проста: ви самі написали той шматок, який в інших механізмах Spring Security зазвичай «розмазаний» між фільтрами й провайдерами. Це максимально прозоро, але й максимально на вашій совісті.
Вбудований шлях: «віддаємо інфраструктуру Spring Security»
flowchart TD
A["HTTP-запит"] --> B["BearerTokenAuthenticationFilter"]
B --> C["AuthenticationManager"]
C --> D["JwtAuthenticationProvider"]
D --> E["JwtDecoder: декодування + перевірка"]
E --> F["JwtAuthenticationConverter: claims → authorities"]
F --> G["JwtAuthenticationToken"]
G --> H["SecurityContext"]
H --> I["Правила авторизації + контролер + сервіс"]
Ключовий висновок: вбудований шлях не «магічний». Він просто використовує стандартні деталі Spring Security: фільтр → менеджер → провайдер → декодер → конвертер → контекст. І якщо ви вже засвоїли цю ментальну модель у модулі 1 (filters/providers/context), то built-in path просто вкладається у звичну архітектуру.
4. Різниця у SecurityFilterChain
У цьому розділі хочеться зробити дуже просту річ: показати два мініскелети конфігурації, які відрізняються рівно в одному місці. Це добрий прийом для новачків: ви буквально бачите, що «все те саме, крім одного блоку».
Гілка з власним фільтром (спрощено)
Припустімо, у нас є фільтр JwtAuthFilter із попередньої лекції. Тоді конфігурація може виглядати так:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
@Configuration
class SecurityCustomJwtConfig {
@Bean
SecurityFilterChain apiSecurity(HttpSecurity http, JwtAuthFilter jwtAuthFilter) throws Exception {
// Важливо: API stateless, щоб SecurityContext не зберігався в сесії
http.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
// Важливо: вставляємо наш фільтр у ланцюжок ДО стандартної автентифікації за логіном/паролем
http.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
// Важливо: правила доступу залишаються такими самими, ми порівнюємо лише JWT-механіку
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**", "/api/auth/login").permitAll()
.anyRequest().authenticated()
);
return http.build();
}
}
Зверніть увагу на характер цієї конфігурації: ланцюжок має знати, де вставлено ваш фільтр, а ви повинні вирішити, що він робить у разі помилки токена і як взаємодіє з обробкою винятків.
Гілка вбудованого resource-server рішення (спрощено)
А тепер той самий сенс, але з built-in path:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
@Configuration
class SecurityResourceServerJwtConfig {
@Bean
SecurityFilterChain apiSecurity(HttpSecurity http, JwtDecoder jwtDecoder) throws Exception {
// Важливо: API stateless, щоб SecurityContext не зберігався в сесії
http.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
// Важливо: вмикаємо стандартний Bearer JWT пайплайн Spring Security
http.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.decoder(jwtDecoder)) // Тут підключається JwtDecoder (валідація/декодування)
);
// Важливо: правила доступу залишаються такими самими, ми порівнюємо лише JWT-механіку
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**", "/api/auth/login").permitAll()
.anyRequest().authenticated()
);
return http.build();
}
}
Другий варіант виглядає майже нудним. І це, чесно кажучи, комплімент: нудна безпека зазвичай краща, ніж «креативна».
5. Один Bearer-механізм для API
Майже всі великі проблеми починаються не з того, що ви обрали «не той підхід», а з того, що ви ввімкнули два підходи одночасно. Тоді один фільтр може поставити Authentication, інший — перезаписати його або кинути виняток, а ви відлагоджуватимете «привидів»: то 401, то 403, то раптово AnonymousAuthenticationToken.
Якщо обидва варіанти лежать в одному репозиторії, найпростіше не змішувати їх — розвести за профілями. Тут профілі не створюють «третю security-модель», а просто ізолюють дві альтернативні конфігурації одного й того самого API: jwt-custom і jwt-rs. Під час виконання активний рівно один профіль для Bearer-автентифікації, інакше обидва механізми почнуть сперечатися за один і той самий заголовок Authorization.
Приклад: @Profile("jwt-custom")
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
@Configuration
@Profile("jwt-custom") // Важливо: цей конфіг активний лише за профілем jwt-custom
class SecurityCustomJwtConfig {
// SecurityFilterChain з addFilterBefore(jwtAuthFilter, ...)
}
Приклад: @Profile("jwt-rs")
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
@Configuration
@Profile("jwt-rs") // Важливо: цей конфіг активний лише за профілем jwt-rs
class SecurityResourceServerJwtConfig {
// SecurityFilterChain з oauth2ResourceServer().jwt()
}
Тоді локально ви можете запускати застосунок з одним профілем за раз і тримати це в репозиторії як окрему контрольну точку. І головне — під час виконання у вас ніколи не буде «двох конкуруючих правд» про те, хто є поточним користувачем.
6. Різні Authentication і principal
Поки ви дивитеся на JWT як на рядок, здається, що різниці немає. Але насправді в застосунку ви працюєте не з токеном, а з Authentication. І тут підходи можуть дати вам різні типи principal, а отже — різну зручність або незручність у контролерах і сервісах.
Built-in path: зручно отримувати Jwt і JwtAuthenticationToken
У resource-server гілці ви можете прямо запросити Jwt як principal:
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class MeController {
@GetMapping("/api/me/sub")
String subject(@AuthenticationPrincipal Jwt jwt) {
// Jwt уже декодований і провалідований Spring Security (не треба парсити header вручну)
return jwt.getSubject();
}
}
Це дуже наочно: ви не читаєте заголовок, не парсите рядок — Spring Security віддає вам уже декодований і провалідований обʼєкт.
Custom filter: ви самі вирішуєте, чим буде principal
У custom-гілці ви зазвичай створюєте Authentication вручну. Це дає свободу: можна зробити principal вашим AppUserPrincipal, де будуть userId, username, authorities. Але свобода означає, що ви зобовʼязані бути послідовними.
Мініприклад «свого» principal:
import java.util.Set;
public record AppUserPrincipal(Long userId, String username, Set<String> authorities) {
// Важливо: тут краще зберігати вже нормалізовані authorities, як їх очікує авторизація
}
І далі, умовно, фільтр кладе саме його в Authentication. У контролері ви можете отримати його так:
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class MeController {
@GetMapping("/api/me/username")
String username(@AuthenticationPrincipal AppUserPrincipal principal) {
// Тут ми не залежимо від структури JWT: контролеру приходить уже "готовий" principal
return principal.username();
}
}
З погляду зручності це іноді навіть приємніше, ніж Jwt, тому що ви не тягаєте «сирий токен» кодом. Але тоді важливо, щоб custom filter завжди створював principal однаково, інакше ви отримаєте хаос типів.
7. Критерії вибору підходу
Порівняння підходів найкраще робити не за гаслами, а за сукупною вартістю володіння. І ось тут починається доросла інженерія: ви думаєте не лише «як швидко запустити», а й «як легко супроводжувати», «як важко зламати», «як легко пояснити колезі через місяць».
Custom JWT filter часто виграє як навчальний інструмент і як спосіб відчути безстанний життєвий цикл на практиці. Ви бачите кожен рядок і розумієте, де саме ставиться SecurityContext. Але далі вмикається реальність: вам потрібно обробити тонни дрібних edge cases. Наприклад, не логувати токен цілком. Не перетворювати будь-яку проблему на 500. Не забути викликати chain.doFilter(...) у потрібному місці. Не переплутати 401 і 403. Не зробити з фільтра «бог-обʼєкт», який знає про бізнес більше, ніж сервіси.
Built-in path зазвичай виграє як ідіоматичне рішення Spring Security. Він менший за обсягом коду, а отже — має менше місць, де ви можете випадково зламати безпеку. Плюс він природно використовує вже знайомі абстракції: фільтри та провайдери. Це не означає «вбудоване завжди краще», це означає: «вбудоване краще покриває стандартний кейс Bearer JWT».
Якщо сформулювати вибір максимально чесно, то custom шлях — це як зібрати велосипед самому з труб і зварювання: ви зрозумієте, як він влаштований, але відповідати за гальма будете також ви. Built-in шлях — це велосипед від нормального виробника: ви й далі маєте вміти їздити, тобто розуміти security-модель, але не зобовʼязані кувати ланцюг у кузні.
8. Типові помилки під час міграції
Помилка № 1: тримати custom JWT filter і oauth2ResourceServer().jwt() одночасно.
Це найчастіший самостріл. Ви отримуєте подвійну обробку заголовка Authorization: один механізм може виставити Authentication, другий — спробувати зробити те саме й упасти або перезаписати контекст. У результаті поведінка стає недетермінованою: сьогодні 401, завтра 403, післязавтра — «чому я anonymous, якщо токен є». Якщо обидва варіанти лежать в одному проєкті, розводьте їх за різними профілями й запускайте лише один.
Помилка № 2: вважати, що змінилася security-модель, а не реалізація.
Іноді люди переходять на resource-server path і відразу починають переписувати URL-правила, захист на рівні методів і матрицю доступу. Це ламає порівняння: ви не розумієте, що саме вплинуло. Правильний підхід — тримати access model стабільним, а змінювати лише інфраструктурний Bearer-пайплайн.
Помилка № 3: валідний токен, але вічний 403 — і паніка «Spring Security зламано».
Найчастіше тут винен claim-to-authority mapping. Наприклад, токен містить authorities, але converter читає інший claim. Або префікс SCOPE_/ROLE_ додається чи не додається не так, як очікують ваші правила. Підсумок: автентифікація проходить, але авторизація не збігається з очікуваними рядками.
Помилка № 4: намагатися запхати owner-based правила в claims.
Дуже спокусливо покласти в токен «я власник усіх чернеток світу» і жити щасливо, але це ламає сенс ownership. Owner-only — це перевірка «цей конкретний обʼєкт мій?», а не «у мене є право бути власником». Такі правила зазвичай вимагають даних з БД або хоча б перевірки атрибутів обʼєкта, а не лише читання токена.
Помилка № 5: «вбудоване — це чорна скринька, я більше нічого не розумію».
Resource-server path справді ховає частину деталей, але він не має перетворюватися на магію. Тримайте ментальну модель: filter → provider → decoder → converter → SecurityContext. Якщо ви тримаєте її в голові, built-in шлях стає не чорною скринькою, а просто стандартною реалізацією.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ