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 | «Этого пользователя можно считать аутентифицированным?» | credentials (логин/пароль в форме Authentication request) | authenticated Authentication или исключение |
| TokenService | «Какой access token выдать уже подтверждённому пользователю?» | успешный Authentication (без пароля как бизнес-объекта) | модель токена (строка + время жизни) |
А теперь — схема, чтобы мозг видел не только слова:
flowchart TD %% Клиент вызывает endpoint логина C["Client"] -->|"POST /api/auth/login"| AC["AuthController"] %% Контроллер делегирует orchestration сервису AC --> AS["AuthService"] %% Spring Security проверяет credentials и возвращает Authentication или ошибку AS --> AM["AuthenticationManager"] AM --> AS %% После успешной аутентификации выдаём access token отдельным сервисом AS --> TS["TokenService"] TS --> AS AS --> AC AC -->|"JSON response"| C
Обратите внимание на важную идею: TokenService не должен принимать raw password. Вообще. Никаких String password «на секундочку», никаких LoginRequest, никаких HttpServletRequest. Это не каприз архитектора, это базовая гигиена: чем меньше мест в коде «видят» пароль, тем меньше шансов случайно его залогировать, сохранить или передать не туда.
И ещё момент: TokenService не решает, правильный ли пароль. Он исходит из того, что Authentication уже успешный, а значит, password уже проверили стандартные механизмы 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-контракт, можно заранее сделать правильную архитектурную точку: сервис, который связывает шаги login flow. Обычно я называю это «оркестратором»: он не играет на всех инструментах сразу, но говорит, кому когда вступать.
Минимальный каркас такого сервиса:
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 отвечает за проверку credentials и выдачу 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. Это держит нас в рамках правильной модели: credentials проверяет Spring Security.
Во-вторых, AuthService получает TokenService, а не «какой-то утилитный класс, который генерит токены». Это уже архитектура, где выдача токена — отдельная ответственность.
Вы можете спросить: «Зачем ещё один сервис, если можно контроллер → authenticationManager → tokenService?» Ответ простой: потому что контроллер не должен знать о внутренних шагах, и потому что в реальном проекте вокруг логина почти всегда есть дополнительные действия (например, аудит события входа, ограничения по попыткам, нормализация логина). Оркестратор — это место, где эти шаги живут, не раздувая контроллер.
6. Пакеты для TokenService
Когда проект маленький, кажется, что «пакеты не важны». Когда проект живёт хотя бы месяц — внезапно пакеты становятся навигацией, без которой вы начинаете «искать токен сервис через Ctrl+Shift+F и молитвы». Поэтому здесь лучше сразу положить код туда, где его ожидают.
Мы уже фиксировали структуру проекта как package-by-feature с отдельной security-зоной. Значит, для этого дня естественные места такие:
- com.example.securecontent.security.jwt — для TokenService, IssuedAccessToken и будущих JWT-утилит уровня security.
- com.example.securecontent.auth — для AuthService и web endpoint’ов 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: логировать токен «для отладки» целиком.
Это звучит невинно: «я же в локалке». Но привычки мигрируют в продакшен быстрее, чем кажется, а логи имеют свойство уезжать в централизованные системы. Даже в учебном проекте лучше выработать норму: не печатать токен полностью. Если очень нужно, логируйте первые 10–15 символов и пометку, что токен был выдан, без утечки содержимого.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ