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, а не «якийсь утилітарний клас, який генерує токени». Це вже архітектура, де видача токена — окрема відповідальність.
Ви можете запитати: «Навіщо ще один сервіс, якщо можна контролер → authenticationManager → tokenService?» Відповідь проста: тому що контролер не має знати про внутрішні кроки, і тому що в реальному проєкті навколо входу майже завжди є додаткові дії (наприклад, аудит події входу, обмеження кількості спроб, нормалізація логіну). Оркестратор — це місце, де ці кроки живуть, не роздуваючи контролер.
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 символів і позначку, що токен видано, без витоку вмісту.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ