JavaRush /Курси /Spring Security /JWT: перевірка й скл...

JWT: перевірка й складання Authentication

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

1. JWT після вилучення — усе ще «просто рядок»

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

JWT (у нашому випадку — JWS-підписаний токен) спеціально влаштований так, що його payload легко читається. Це нормально: він не про секретність, а про цілісність. Якщо ви просто «розпарсили payload», то отримали дані, які міг написати хто завгодно. Справжня магія JWT починається саме в той момент, коли ви перевірили підпис і переконалися, що токен справді випущений вашим сервером і не був змінений.

Уявімо поганий сценарій. Зловмисник бере справжній токен, змінює в payload список прав на ["user:manage", "draft:publish"] — і надсилає його. Якщо ваш фільтр «наївно» вірить claims без перевірки підпису, то ваш застосунок перетворюється на «будь ласка, візьміть адмінку, вона лежала на столі».

Тому дисципліна така: спочатку ми робимо validation — тобто перевіряємо довіру, і тільки потім extraction — витягуємо дані. Витягнути без довіри можна, але використовувати не можна.

Мінінагадування, щоб не плутатися: просте декодування Base64 — це не безпека. Це як відкрити валізу й подивитися, що всередині. Без перевірки підпису ви не знаєте, чи не поклали туди цеглу замість ноутбука.

2. Мінімальна валідація JWT для Secure Content Platform API

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

Спочатку ми переконуємося, що токен узагалі схожий на JWT: у нього три частини header.payload.signature. Потім перевіряємо підпис — для нас це означає: «Підписаний нашим секретом або ключем, отже випущений нами і не змінений». Після цього перевіряємо exp — термін дії. І лише потім читаємо корисні claims: кого ми аутентифікуємо (username/sub, userId) і які права йому надати (authorities).

Зручно тримати цю логіку в голові як «контрольний список»:

Перевірка Навіщо вона потрібна Що буде в разі пропуску
Структура a.b.c Щоб не намагатися парсити сміття malformed token, помилки парсингу
Підпис Щоб не прийняти підроблений payload Ескалація прав «за папірцем»
exp (expiration) Щоб токени не жили вічно Скомпрометований токен працює безстроково
Обов’язкові claims (username, userId) Щоб зібрати осмислений principal «Аутентифікований» користувач без особистості
Authorities/roles Щоб працювали правила доступу Скрізь буде 403 або, навпаки, «усюди можна»

І дуже важливе уточнення для нашої архітектури курсу: на цьому кроці ми не перевіряємо пароль. Пароль уже перевірили на етапі POST /api/auth/login, коли видавався токен. Зараз ми лише відновлюємо «хто це» і «які в нього права» для поточного запиту.

3. TokenService: validate + extract без дублювання

Якщо ви спробуєте зробити у фільтрі 15 рядків split, Base64, JsonNode і «ну ніби працює», то через три дні самі собі почнете ставити філософське запитання: «Хто написав цей код і чому він мене ненавидить?». Тому ми тримаємо дисципліну: у нас є TokenService, і саме він відповідає за дві речі — перевірити токен і витягнути з нього те, що потрібно.

Важливо не скотитися в стиль «сервіс на 30 методів extractXxx», кожен із яких по-своєму парсить токен. По-перше, це дублювання роботи. По-друге, це ризик: один метод «перевірив exp», інший — не перевірив, третій — забув про підпис. І ви отримуєте найстрашнішу категорію багів: іноді безпечно, іноді ні.

Краще зробити так: TokenService один раз парсить токен, перевіряє довіру і повертає нормалізований результат. Наприклад, окремий record ValidatedToken.

package com.example.securecontent.security.jwt;

import com.example.securecontent.security.auth.CurrentUserPrincipal;
import java.util.List;

// Результат, якому вже можна довіряти: токен перевірено (підпис / exp / claims),
// і ми витягуємо лише те, що потрібно для Spring Security.
public record ValidatedToken(CurrentUserPrincipal principal, List<String> authorities) {
}

Тепер перевірка й витягування «склеєні» в одну операцію. Приблизний контракт:

package com.example.securecontent.security.jwt;

public interface TokenService {

    // Один вхід — один вихід: на цьому кроці ми і перевіряємо токен, і витягуємо дані.
    // Важливо: метод не повинен повертати "сирий" payload без перевірки підпису.
    ValidatedToken parseAndValidate(String token);
}

А ось спрощений фрагмент внутрішньої логіки TokenService. Тут важлива не бібліотека, а ідея: спочатку підпис і exp, потім claims.

На цьому кроці зручно відразу розрізняти категорію проблеми, а не лише текст повідомлення: фільтр зможе відреагувати на неї передбачувано, без вгадування за рядком помилки. Тут биту структуру, невалідний підпис та інші випадки, коли токену не можна довіряти, зручно складати в один кошик TokenProblem.MALFORMED; окремо нам потрібен лише EXPIRED.

import java.time.Clock;
import java.time.Instant;

private void validateExpiration(Instant exp, Clock clock) {
    // Беремо поточний час із Clock, щоб це було зручно тестувати.
    Instant now = clock.instant();

    // Якщо exp раніше за now — токен прострочено, і ми не повинні довіряти його claims.
    if (exp.isBefore(now)) {
        throw new TokenValidationException(TokenProblem.EXPIRED, "Термін дії токена сплив");
    }
}

Якщо ви використовуєте бібліотеку, яка перевіряє підпис, це виглядатиме приблизно так (знову ж таки: не як догма, а як орієнтир):

import com.nimbusds.jose.crypto.MACVerifier;
import com.nimbusds.jwt.SignedJWT;

// Парсимо JWS (підписаний JWT) зі рядка.
SignedJWT jwt = SignedJWT.parse(token);

// Криптографічно перевіряємо підпис, а не просто "дивимося header".
boolean signatureOk = jwt.verify(new MACVerifier(secret));

if (!signatureOk) {
    // Якщо підпис не зійшовся, токен підроблений або змінений дорогою.
    throw new TokenValidationException(TokenProblem.MALFORMED, "Невірний підпис");
}

Ще раз: на рівні курсу достатньо розуміти, що «перевірити підпис» — це не «подивитися header», а саме виконати криптографічну перевірку через бібліотеку.

4. Principal із claims: CurrentUserPrincipal

У JWT є спокуса запхати все: і профіль, і аватар, і улюблений колір кнопок. Але наш проєкт — не соціальна мережа на стероїдах, а навчальний backend, який вчиться робити безпеку акуратно. Тому ми тримаємо principal компактним. Нам у запиті зазвичай потрібні дві речі: ідентифікатор користувача — для owner-based правил і доступу до БД, а також логін/username — для логів і деяких сценаріїв.

Ідеальний формат для такого principal у Java — звичайний record. Він маленький, незмінний і не намагається жити власним життям.

package com.example.securecontent.security.auth;

// Мінімальне "security-представлення" користувача для поточного запиту.
// Важливо: це НЕ JPA-сутність і не "повний профіль", а лише те, що потрібно в security.
public record CurrentUserPrincipal(Long userId, String username) {
}

Чому це важливо? Тому що principal — це «security-представлення» користувача, а не «наш JPA-UserAccount у всій красі». Якщо ви почнете тягати в principal сутності, прив’язані до БД, то швидко отримаєте проблеми: ледачі поля, випадкові серіалізації, залежність шару безпеки від persistence-шару. А потім хтось захоче залогувати principal — і ви раптом побачите пів профілю в логах. Не робіть так.

І ще — не забувайте: JWT payload за визначенням не є секретним. Його можна прочитати. Тому жодних email, телефонів і «дівочого прізвища матері» в principal із токена.

Мініприклад того, як ми збираємо principal після успішної перевірки:

import com.example.securecontent.security.auth.CurrentUserPrincipal;

// Витягуємо дані лише після успішної перевірки підпису та exp.
Long userId = tokenClaims.userId();
String username = tokenClaims.username();

// Збираємо компактний principal, який житиме в Authentication/SecurityContext.
CurrentUserPrincipal principal = new CurrentUserPrincipal(userId, username);

Де tokenClaims — це ваш внутрішній нормалізований результат розбору токена. Ми можемо зберігати його як record або як map — не принципово. Принципово, що до цього моменту токен уже перевірений.

5. Рядкові права → GrantedAuthority

У нашому проєкті права виражені рядками на кшталт draft:publish, user:manage, profile:write. У коді це виглядає красиво й читабельно. Але Spring Security всередині не зберігає «просто рядки». Він зберігає колекцію об’єктів GrantedAuthority. Це інтерфейс з одним методом getAuthority(), і Spring використовує його як абстракцію над правами.

Найпростіший адаптер із рядка в GrantedAuthoritySimpleGrantedAuthority. Ми беремо рядок і загортаємо його.

import java.util.List;
import org.springframework.security.core.authority.SimpleGrantedAuthority;

// Перетворюємо рядки з токена в тип, який розуміє Spring Security.
List<SimpleGrantedAuthority> grantedAuthorities = authoritiesFromToken.stream()
        .map(SimpleGrantedAuthority::new) // "draft:publish" -> new SimpleGrantedAuthority("draft:publish")
        .toList();

Тут критично важливо, щоб рядки authority у токені збігалися з тим, що очікують ваші правила. Якщо в SecurityFilterChain ви пишете .hasAuthority("user:manage"), а в токені через помилку лежить "USER_MANAGE" або "manage:user", то у вас буде вічне «чому в мене 403, я ж адмін».

Окрема тонкість — ролі та ROLE_. Нагадую: hasRole("ADMIN") фактично перевіряє authority "ROLE_ADMIN". Якщо ви хочете й надалі використовувати hasRole, то в токен потрібно класти саме "ROLE_ADMIN", а не просто "ADMIN". У межах нашого курсу зручніше дотримуватися одного стилю: або ви кладете в токен уже нормалізовані значення (ROLE_ADMIN, draft:publish), або в одному місці робите конверсію. Головне — щоб це було послідовно, а не «вчора було так, сьогодні — інакше».

6. Authentication із JWT: складання і сенс

Тепер у нас є дві речі: principal — хто користувач, і grantedAuthorities — які в нього права. Залишилося зібрати об’єкт Authentication. Важливо зрозуміти саму ідею: Authentication — це стандартний контейнер Spring Security для результату аутентифікації. Він не зобов’язаний означати «перевірили пароль просто зараз». Він означає: «Для поточного запиту у нас є аутентифікований користувач».

Найзручніший клас для нашого сценарію — UsernamePasswordAuthenticationToken. Його назва може збити з пантелику («чому username/password, якщо у нас JWT?»), але на практиці це просто готова реалізація Authentication, яку зручно використовувати. Пароль (credentials) у цей момент нам не потрібен, тому він буде null. І так, це нормально.

import com.example.securecontent.security.auth.CurrentUserPrincipal;
import java.util.List;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;

CurrentUserPrincipal principal = new CurrentUserPrincipal(userId, username);

// Права з токена приводимо до формату, який очікує Spring Security.
List<SimpleGrantedAuthority> granted = authorities.stream()
        .map(SimpleGrantedAuthority::new)
        .toList();

// credentials = null, тому що пароль / секрет ми тут не перевіряємо (це JWT-сценарій).
// Важливо: конструктор (principal, credentials, authorities) створює authenticated-об’єкт.
Authentication authentication = new UsernamePasswordAuthenticationToken(principal, null, granted);

Зверніть увагу на важливу деталь: конструктор із трьома аргументами (principal, credentials, authorities) створює об’єкт, який вважається authenticated. Це нам і потрібно: не «спроба аутентифікації», а «відновлена аутентифікація».

І ось тепер ми отримали стандартний об’єкт, який «розуміє» весь інший Spring Security. Його зможуть використовувати:

— правила на рівні запиту (.anyRequest().authenticated(), .hasAuthority(...));
— method security (@PreAuthorize("hasAuthority('draft:publish')"));
— точки доступу до поточного користувача через SecurityContext.

Схема потоку: token → Authentication

Зараз ми зробили важливий проміжний результат: ми вміємо взяти рядок токена й отримати з нього Authentication. Це як зібрати паспорт і перепустку на прохідній: «ось хто я» і «ось куди мені можна». Але ми ще не «передали охороні цю інформацію» — тобто не поклали її в SecurityContext.

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

flowchart TD
    A["HTTP request Authorization: Bearer token"] --> B["resolveBearerToken() отримали рядок token"]
    B --> C["tokenService.parseAndValidate(token) підпис + exp + claims"]
    C --> D["ValidatedToken principal + authorities"]
    D --> E["map -> SimpleGrantedAuthority"]
    E --> F["new UsernamePasswordAuthenticationToken(...) отримали Authentication"]

Якщо ви зможете відтворити цей потік словами, то ви вже, по суті, розумієте stateless-аутентифікацію. Залишилося вбудувати цей результат у request lifecycle, щоб його побачили URL-rules, method security і @AuthenticationPrincipal.

7. Межі відповідальності на етапі перевірки

На цьому етапі дуже легко почати «покращувати» рішення до стану «нічого не працює». Тому корисно явно проговорити межі. Перевірка JWT і складання Authentication — це інфраструктурний крок, який має бути швидким, передбачуваним і не містити бізнес-логіки.

По-перше, ми не повинні робити тут ownership-перевірки. «Це його чернетка чи чужа?» — це питання авторизації на рівні бізнес-операції та доменних даних. Токен не зобов’язаний знати про конкретний draftId, і фільтр не повинен перетворюватися на мінісервіс контенту.

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

По-третє, ми не повинні «роздувати» principal до розмірів доменної моделі. У principal є проста роль: бути ідентифікатором і мінімальним представленням користувача. Якщо ви покладете туди сутність, колекції ролей, профіль і аватар, ви погіршите безпеку — через зайві дані в пам’яті та логах — і ускладните супровід.

І нарешті, ми не повинні змішувати «перевірку токена» і «відповідь клієнту». Якщо токен поганий, наше завдання на етапі перевірки — коректно сигналізувати про це винятком або результатом. Формування 401 JSON-відповіді та єдиний контракт помилок мають залишатися окремою логікою: інакше у фільтрі змішаються JSON, логи, бізнес-правила і трохи магії.

8. Типові помилки під час перевірки JWT

Помилка №1: використовувати claims до перевірки підпису.
Це найнебезпечніша логічна пастка: «Я ж бачу username у payload — отже, це він». На практиці payload можна змінити, а перевірка підпису якраз і потрібна, щоб payload став довіреним. Правильна послідовність завжди одна: спочатку довіра (підпис/exp), потім витягування (username/userId/authorities).

Помилка №2: перевіряти лише структуру токена й забувати про exp.
Новачок іноді акуратно перевіряє header.payload.signature, навіть підпис, і думає, що все добре. Але якщо не перевіряти термін дії, токен стає безстроковою перепусткою. У навчальному проєкті це теж погана звичка: ви закріплюєте в себе ментальну модель «токен вічний», а потім у реальному проєкті здивуєтеся, чому безпека така собі.

Помилка №3: будувати Authentication «по частинах» у різних місцях.
Якщо у фільтрі ви зробили extractUsername, а authorities — «десь потім», а userId — «в іншому методі», ви майже гарантовано отримаєте розсинхрон. Найкращий стиль — один метод parseAndValidate(), який повертає нормалізований ValidatedToken, і вже з нього збирається Authentication в одному місці.

Помилка №4: класти в principal JPA-сутність або цілий UserAccount.
Це дуже спокусливо: «Ну раз ми все одно працюємо з користувачами, давайте покладемо UserAccount». Потім починаються сюрпризи: ледачі колекції, зайві поля, випадкові серіалізації, витоки даних у логах. Principal має бути компактним і незалежним від persistence.

Помилка №5: плутанина hasRole і hasAuthority через ROLE_-префікс.
Якщо ви використовуєте hasRole("ADMIN"), Spring очікує "ROLE_ADMIN". Якщо токен несе "ADMIN", то доступ буде заборонено, і ви отримаєте загадковий 403, хоча «ніби адмін». Щоб не займатися шаманством, виберіть один стиль іменування та дотримуйтеся його: або ролі у вигляді "ROLE_ADMIN", або чисті authorities і .hasAuthority(...).

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ