JavaRush /Курси /Spring Security /TokenService для acc...

TokenService для access token

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

1. Проблема: токен у контролері

Коли ми вперше пишемо endpoint входу, рука сама тягнеться зібрати все в одному місці: прийняти JSON, перевірити логін і пароль, зібрати claims, підписати JWT і повернути відповідь. Це здається швидким і «прагматичним», особливо якщо ви ще не встигли нажити алергію на контролери на 300 рядків. Але саме такий підхід робить security-код крихким і складним у підтримці.

Давайте уявимо типовий «злиплий» контролер. Спеціально показую антиприклад — не для того, щоб ви його повторювали, а щоб відчули запах майбутніх проблем:

import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/auth")
class AuthController {

    @PostMapping("/login")
    Object login(@RequestBody Object request) {
        // Антиприклад: контролер не має повністю містити security-логіку.
        // 1) тут би "перевірити пароль" (зона відповідальності AuthenticationManager)
        // 2) тут би "зібрати claims" (зона відповідальності TokenService)
        // 3) тут би "підписати JWT"
        // 4) повернути JSON-відповідь клієнту
        return "token"; // Поганий сигнал: "магічний рядок" замість моделі результату
    }
}

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

У stateless API особливо важливо, щоб HTTP-шар був тонким. Контролер має бути дверима: зайшли — вийшли. А от рішення «який токен видати підтвердженому користувачеві» — це окрема бізнес-операція security-шару.

2. Ролі: AuthenticationManager і TokenService

У stateless-світі зручно думати так: AuthenticationManager — це «паспортний контроль», а TokenService — це «видача перепустки на територію». Паспортний контроль не має друкувати перепустки, а друкарня перепусток не має вирішувати, хто справжній громадянин, а хто прийшов із роздруківкою з Paint. Розділення здається очевидним у житті — і чомусь дуже часто забувається в коді.

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

Компонент Головне запитання Що отримує на вхід Що віддає на вихід
AuthenticationManager «Чи можна вважати цього користувача аутентифікованим?» облікові дані (логін/пароль у формі Authentication request) успішний Authentication або виняток
TokenService «Який access token видати вже підтвердженому користувачеві?» успішний Authentication (без пароля як бізнес-обʼєкта) модель токена (рядок + час життя)

А тепер — схема, щоб мозок бачив не лише слова:

flowchart TD
  %% Клієнт викликає endpoint входу
  C["Клієнт"] -->|"POST /api/auth/login"| AC["AuthController"]
  %% Контролер делегує оркестрацію сервісу
  AC --> AS["AuthService"]
  %% Spring Security перевіряє облікові дані і повертає Authentication або помилку
  AS --> AM["AuthenticationManager"]
  AM --> AS
  %% Після успішної аутентифікації видаємо access token окремим сервісом
  AS --> TS["TokenService"]
  TS --> AS
  AS --> AC
  AC -->|"JSON-відповідь"| C

Зверніть увагу на важливу ідею: TokenService не має приймати raw password взагалі. Жодних String password «на секундочку», жодних LoginRequest, жодних HttpServletRequest. Це не примха архітектора, а базова гігієна: чим менше місць у коді «бачать» пароль, тим менше шансів випадково його залогувати, зберегти або передати не туди.

І ще один момент: TokenService не вирішує, правильний пароль чи ні. Він виходить із того, що Authentication уже успішний, а отже, пароль уже перевірили стандартні механізми Spring Security (через DaoAuthenticationProvider, UserDetailsService, PasswordEncoder і врахування станів акаунта).

3. Модель результату: IssuedAccessToken

Коли токен уже видано, результат потрібно десь зберігати. Можна одразу повернути String, але це швидко призводить до «магічних рядків»: десь потрібен TTL, десь token type, десь хочеться додати issuedAt, а десь узагалі буде два токени. Тому зручніше зробити маленьку внутрішню модель.

У нашому проєкті Secure Content Platform API заведемо обʼєкт результату видачі токена. Щоб код був коротким і зрозумілим, можна використати record (у Java 25 це цілком буденна річ для «класів-даних»).

package com.example.securecontent.security.jwt;

/**
 * Внутрішній результат видачі access token.
 * Важливо: це не HTTP-DTO, а контракт між security-шаром і прикладним сервісом.
 */
public record IssuedAccessToken(
        String value,         // Вміст JWT у вигляді рядка
        long expiresInSeconds // Час життя токена (TTL) у секундах
) { }

Тут важливо зрозуміти, що IssuedAccessToken — це внутрішній контракт між шарами, а не «обовʼязковий формат HTTP-відповіді». Чому це принципово:

Контролер і web-шар загалом живуть у світі HTTP: @RequestBody, @ResponseStatus, JSON-контрактів і помилок. А TokenService має жити у світі security-механіки і бізнес-рішення «видати токен». Якщо ми змусимо TokenService повертати одразу AuthResponse (DTO для клієнта), ми привʼяжемо security-шар до web-шару, і потім будь-який косметичний рефакторинг API почне зачіпати нутрощі security.

Внутрішня модель — як упаковка на складі: може відрізнятися від вітрини в магазині. Склад має працювати ефективно й стабільно, а вітрина може змінюватися під потреби клієнта.

4. Інтерфейс TokenService

Тепер, коли у нас є модель результату, можемо описати контракт самого сервісу. Тут важливий один принцип: контракт має бути мінімальним, але передавати сенс. Ми не пишемо «універсальний комбайн генерації токенів на всі часи», ми пишемо зрозумілий сервіс для поточного проєкту й поточного рівня курсу.

Мінімальний інтерфейс має такий вигляд:

package com.example.securecontent.security.jwt;

import org.springframework.security.core.Authentication;

public interface TokenService {

    /**
     * Видає access token на основі вже успішної аутентифікації.
     * Важливо: сюди приходить Authentication після перевірки пароля.
     */
    IssuedAccessToken issueAccessToken(Authentication authentication);
}

Чому вхід саме Authentication, а не UserAccount, UserDetails або String username? Тому що Authentication — це вже канонічний результат роботи Spring Security. У ньому є імʼя користувача (getName()), є authorities, і він уже враховує все, що ми побудували раніше: стани акаунта, ролі, правила завантаження користувача з БД.

Чому це корисно на практиці, особливо новачку? Якщо ви спробуєте передати в TokenService щось «своє», ви швидко почнете робити ручний мапінг і домовлятися, що вважати істинним: десь roles, десь authorities, десь username, десь id, десь accountNonLocked… У результаті ви дублюєте security-модель у своєму коді й збільшуєте шанс розсинхронізації.

А Authentication — це те, що Spring Security і так використовує для прийняття рішень. Отже, спиратися на нього — найекономніший шлях.

Щоб сильніше відчути межі відповідальності, уявіть собі два запитання, які ви ставите коду:

  • Якщо запитання звучить як «чи правильний пароль?», «користувач заблокований чи ні?» — це зона AuthenticationManager.
  • Якщо запитання звучить як «що покласти в токен? який TTL? який формат?» — це зона TokenService.

Усе. Не змішуємо.

5. AuthService як оркестратор

У нашому застосунку буде endpoint POST /api/auth/login. Але навіть якщо ми ще не обговорюємо його JSON-контракт, можна заздалегідь поставити правильну архітектурну опору: сервіс, який повʼязує кроки процесу входу. Зазвичай я називаю це «оркестратором»: він не грає на всіх інструментах одночасно, але підказує, кому і коли вступати.

Мінімальний каркас такого сервісу:

package com.example.securecontent.auth;

import com.example.securecontent.security.jwt.TokenService;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.stereotype.Service;

@Service
public class AuthService {
    // AuthenticationManager відповідає за перевірку облікових даних і видачу Authentication
    private final AuthenticationManager authenticationManager;

    // TokenService відповідає за видачу access token після успішної аутентифікації
    private final TokenService tokenService;

    public AuthService(AuthenticationManager authenticationManager, TokenService tokenService) {
        // DI робить залежності явними й придатними для тестування
        this.authenticationManager = authenticationManager;
        this.tokenService = tokenService;
    }
}

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

По-перше, AuthService отримує AuthenticationManager, а не якийсь UserRepository і не PasswordEncoder. Це тримає нас у межах правильної моделі: облікові дані перевіряє Spring Security.

По-друге, AuthService отримує TokenService, а не «якийсь утилітарний клас, який генерує токени». Це вже архітектура, де видача токена — окрема відповідальність.

Ви можете запитати: «Навіщо ще один сервіс, якщо можна контролер → authenticationManagertokenService?» Відповідь проста: тому що контролер не має знати про внутрішні кроки, і тому що в реальному проєкті навколо входу майже завжди є додаткові дії (наприклад, аудит події входу, обмеження кількості спроб, нормалізація логіну). Оркестратор — це місце, де ці кроки живуть, не роздуваючи контролер.

6. Пакети для TokenService

Коли проєкт маленький, здається, що пакети неважливі. Коли він живе хоча б місяць, пакети раптом стають навігацією, без якої ви починаєте шукати token service через Ctrl+Shift+F і молитви. Тому тут краще одразу покласти код туди, де його очікують.

Ми вже фіксували структуру проєкту як package-by-feature з окремою security-зоною. Отже, на цей етап природні місця такі:

  • com.example.securecontent.security.jwt — для TokenService, IssuedAccessToken і майбутніх JWT-утиліт рівня security.
  • com.example.securecontent.auth — для AuthService і web-кінцевих точок auth-зони (контролерів і DTO), тому що «auth» — прикладна зона продукту.

Чому не класти TokenService в auth? Тому що видача токена — це частина security-інфраструктури, і вона буде потрібна не лише «в контролері входу», а й як окремий компонент, який має залишатися чистим і мінімально залежним від web-шару.

І ще одна важлива думка: TokenService має бути Spring bean, а не public final class JwtUtils { ... } зі статичними методами. Щойно ви дійдете до налаштування секрету, TTL та інших параметрів, ви одразу захочете DI і конфігурацію. Крім того, bean простіше тестувати і підміняти.

Невелика підказка з практики: якщо ви бачите, що security-логіка розкидана по controller, service, util, config без чіткої зони — це майже завжди означає, що через пару тижнів ви самі собі влаштуєте квест «чому токен видається так, а перевіряється інакше».

7. Типові помилки при виділенні TokenService

Помилка № 1: генерація токена прямо в контролері.
Зазвичай це починається як «швидко перевірю ідею», а закінчується тим, що контролер стає центром всесвіту: у ньому зʼявляються claims, TTL, підпис, обробка помилок, логування і ще пара випадкових TODO. Такий код складно тестувати, складно змінювати і легко випадково зламати. Правильна звичка — тримати контролер тонким, а видачу токена — у TokenService.

Помилка № 2: передавати в TokenService raw password або LoginRequest.
Якщо TokenService приймає пароль, він стає співучасником аутентифікації, хоча не має бути таким. Ба більше, ви збільшуєте поверхню ризику витоку: пароль може випадково потрапити в лог, у виняток, у налагоджувальний вивід. Набагато безпечніше й архітектурно чесніше — передавати в TokenService лише успішний Authentication, де пароль уже не є «бізнес-даними», які ви продовжуєте тягати по коду.

Помилка № 3: робити TokenService статичним utility-класом.
Поки ви генеруєте «рядок токена з повітря», static util здається зручним. Але щойно зʼявляться налаштування (секрет, TTL, issuer), ви почнете або хардкодити їх у коді, або передавати параметри через десяток методів, або тягнути в util @Value і ламати собі голову. Spring bean тут банально простіший і правильніший: конфігурація і залежності живуть через DI, а сервіс лишається тестованим.

Помилка № 4: змішувати внутрішню модель токена і HTTP-відповідь.
Якщо TokenService повертає DTO відповіді (AuthResponse), він починає знати про API-контракт і web-шар. А потім ви змінюєте формат відповіді (наприклад, хочете повернути поле tokenType або змінити імʼя accessToken), і вам доводиться чіпати security-шар. Внутрішній обʼєкт на кшталт IssuedAccessToken розвʼязує проблему: TokenService лишається всередині, а контролер або сервіс рівня API самі запакують це у потрібний JSON-формат.

Помилка № 5: логувати токен «для налагодження» цілком.
Це звучить невинно: «я ж у локальному середовищі». Але звички мігрують у production швидше, ніж здається, а логи мають властивість їхати в централізовані системи. Навіть у навчальному проєкті краще виробити норму: не друкувати токен повністю. Якщо дуже потрібно, логуйте перші 10–15 символів і позначку, що токен видано, без витоку вмісту.

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