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 «Этого пользователя можно считать аутентифицированным?» 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, а не «какой-то утилитный класс, который генерит токены». Это уже архитектура, где выдача токена — отдельная ответственность.

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

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 символов и пометку, что токен был выдан, без утечки содержимого.

1
Задача
Spring Security, 23 уровень, 0 лекция
Недоступна
Минимальный TokenService для готового Authentication
Минимальный TokenService для готового Authentication
1
Задача
Spring Security, 23 уровень, 0 лекция
Недоступна
Тонкий контроллер над TokenService
Тонкий контроллер над TokenService
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ