JavaRush /Курсы /Spring Security /JWT — secret, TTL, login flow

JWT — secret, TTL, login flow

Spring Security
23 уровень , 4 лекция
Открыта

1. Введение

Если вы впервые делаете stateless login flow, очень хочется «просто чтобы заработало» и вписать секрет подписи прямо в класс JwtTokenService строкой "my-secret". Работать будет… ровно до первого момента, когда вы поймёте, что секрет уже уехал в репозиторий, в историю Git и, возможно, в чей-то скриншот. А ещё окажется, что TTL тоже не константа: сегодня вы хотите 15 минут, завтра — 5 минут, а в тестах — 2 секунды, чтобы не ждать вечность.

В Spring Boot нормальная инженерная модель такая: код описывает поведение, а окружение (local/dev/test/prod) подсовывает значения через конфигурацию. JWT secret и TTL — классические security-настройки. Они не должны жить в контроллере, не должны быть «магическими числами» по коду и тем более не должны быть «одними и теми же навсегда» для всех окружений.

Самая практическая мысль: один и тот же jar должен запускаться в разных условиях, и единственное, что меняется — конфиг. Тогда вы не «пересобираете безопасность», вы просто её настраиваете.

2. JWT в application.yml

Когда новичок впервые слышит «вынеси секрет в конфиг», он часто понимает это буквально: «ок, напишу секрет в application.yml и закоммичу». И тут мы делаем дружескую остановку: application.yml — это не мусорка для секретов, это место для ключей конфигурации. Секрет как значение чаще всего должен приходить из переменной окружения, а YAML должен оставаться либо без значения, либо с локальным дефолтом для разработки (и то аккуратно).

В нашем проекте Secure Content Platform API мы фиксируем два ключа:

Ключ Смысл Пример
app.security.jwt.secret секрет подписи JWT приходит из env var
app.security.jwt.access-token-ttl время жизни access token например 900000 (мс)

Минимальный пример application.yml

Сначала покажем самый прямолинейный вариант, который понятен даже если вы ненавидите YAML (такое бывает, я не осуждаю — я тоже сначала ненавидел).

# src/main/resources/application.yml
app:
  security:
    jwt:
      # Значение лучше передавать через env vars, а тут оставить только "подключение"
      secret: ${APP_SECURITY_JWT_SECRET}
      # TTL в миллисекундах (15 минут)
      access-token-ttl: 900000

Здесь важный момент: ${APP_SECURITY_JWT_SECRET} означает «возьми значение из переменной окружения». Если переменной нет, приложение при старте упадёт — и это нормально. Секрет без секрета работать не должен.

Если вы хотите, чтобы локально оно запускалось «без плясок», можно задать дефолтное значение через двоеточие. Но делайте это осознанно и не используйте «боевой» секрет.

app:
  security:
    jwt:
      secret: ${APP_SECURITY_JWT_SECRET:dev-only-secret-change-me}
      access-token-ttl: 900000

Если вы видите в репозитории секрет "dev-only-secret-change-me" — это ещё терпимо для учебного проекта, потому что это не настоящий секрет. Но если вы видите там реальную 64-символьную строку, которую кто-то «сгенерил и забыл» — это уже тревожный звоночек.

Единицы TTL: миллисекунды и секунды

TTL — это место, где начинаются очень глупые и очень дорогие ошибки. Самая частая — перепутать единицы измерения. Вы думаете, что TTL в секундах и ставите 900, а код считает миллисекунды — и токен живёт 0.9 секунды. Или наоборот: вы думаете, что TTL в миллисекундах и ставите 900000, а код считает секунды — и токен живёт ~10 дней. В этот момент безопасность начинает “улыбаться и махать” издалека.

Чтобы не гадать, мы фиксируем правило в проекте: app.security.jwt.access-token-ttl хранится в миллисекундах. Это немного “не красиво” в YAML, зато математически просто: expiresAt = issuedAt + ttlMillis.

Если вам психологически больно видеть 900000, вы можете завести комментарий рядом (да, комментарии в конфиге — это нормально):

app:
  security:
    jwt:
      access-token-ttl: 900000 # 15 минут

Главное — единообразие. Когда все в команде понимают «TTL в мс», у вас исчезает целый класс ошибок.

3. DI: secret и TTL в TokenService

Теперь у нас есть ключи в конфигурации, и логичный следующий шаг — научиться доставать их в Spring-bean’ы. Многие делают это «на полях», буквально:

@Value("${app.security.jwt.secret}")
private String secret;

Работает, но это не самый приятный стиль. Для учебного проекта допустимо, но как инженерная привычка лучше сразу приучить себя к конструкторной инъекции. Она делает зависимости явными, помогает тестированию и не оставляет шансов получить null, если кто-то случайно создал объект не через Spring.

JwtTokenService: читаем параметры из конфигурации

Ниже — аккуратный вариант через конструктор и @Value. Мы складываем параметры в final поля, чтобы они не «уплыли» в рантайме и чтобы класс был проще для чтения.

package com.example.securecontent.security.jwt;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

@Service
public class JwtTokenService implements TokenService {

    // Секрет подписи JWT. Его не храним в коде, а получаем из конфигурации.
    private final String secret;

    // TTL именно в миллисекундах — это важно фиксировать в названии.
    private final long accessTokenTtlMillis;

    // Claim set собираем отдельно, чтобы JwtTokenService не разрастался в "комбайн на всё".
    private final JwtClaimsFactory claimsFactory;

    public JwtTokenService(
            // Берём secret из application.yml / env var по ключу.
            @Value("${app.security.jwt.secret}") String secret,
            // Берём TTL из конфигурации как единственный источник правды.
            @Value("${app.security.jwt.access-token-ttl}") long accessTokenTtlMillis,
            JwtClaimsFactory claimsFactory) {
        this.secret = secret;
        this.accessTokenTtlMillis = accessTokenTtlMillis;
        this.claimsFactory = claimsFactory;
    }
}

Обратите внимание на мелочь, которая потом экономит часы: названия параметров и полей прямо говорят, что TTL у нас в миллисекундах. Это не занудство. Это “страховка от будущего себя”.

Мини-проверка конфигурации при старте

Когда секрет отсутствует или слишком короткий, лучше получить понятную ошибку на старте приложения, чем странные 500-ки где-то в момент логина. Для этого можно добавить простую валидацию, например в @PostConstruct.

package com.example.securecontent.security.jwt;

import jakarta.annotation.PostConstruct;

// Этот метод можно добавить прямо в JwtTokenService, чтобы плохой конфиг ловился при старте.
@PostConstruct
void validateConfig() {
    // Явно валидируем secret: лучше упасть при старте, чем ловить 500 во время логина.
    if (secret == null || secret.isBlank()) {
        throw new IllegalStateException("JWT secret is not configured");
    }
}

Да, это выглядит немного «как охранник на входе». Но охранник на входе — это как раз то, чего мы тут добиваемся.

Используем TTL при выдаче IssuedAccessToken

Пока это всё ещё инфраструктурные куски. Самое важное место — сам issueAccessToken(...), где сходятся подтверждённый пользователь, выбранный claim set, secret и configured TTL.

import java.time.Instant;
import java.util.Map;

import org.springframework.security.core.Authentication;

@Override
public IssuedAccessToken issueAccessToken(Authentication authentication) {
    Instant now = Instant.now();
    Instant exp = now.plusMillis(accessTokenTtlMillis);

    // Claims уже собраны в одном месте: sub, userId, authorities, iat и exp.
    Map<String, Object> claims = claimsFactory.createClaims(authentication, now, exp);

    // Конкретная JWT-библиотека может быть разной; смысл шага от этого не меняется.
    String jwt = sign(claims, secret);

    long expiresInSeconds = accessTokenTtlMillis / 1000;
    return new IssuedAccessToken(jwt, expiresInSeconds);
}

private String sign(Map<String, Object> claims, String secret) {
    // Здесь выбирается конкретная JWT-библиотека и алгоритм подписи.
    // Для текущего куска важно другое: на вход уходит готовый claim set и secret,
    // на выходе появляется подписанная строка токена.
    return "...signed-jwt...";
}

Вот здесь впервые и сходятся все куски дня: AuthenticationManager уже дал authenticated principal, JwtClaimsFactory собрал claim set, secret и TTL приехали из конфигурации, а наружу ушёл внутренний IssuedAccessToken. Это важное разведение: exp уходит внутрь JWT для сервера, а expiresInSeconds — наружу, чтобы клиент понимал срок жизни ответа.

4. Полный stateless login flow

Теперь этот flow можно прочитать короче: новым на этом этапе становятся именно внутренности JwtTokenService, а остальная цепочка уже должна выглядеть знакомо. Правильная цель здесь — чтобы login flow читался как рецепт: “взяли DTO → аутентифицировали → выписали токен → вернули ответ”.

Чтобы закрепить картину, удобно сначала посмотреть на схему. Не потому что мы любим диаграммы, а потому что мозгу иногда проще сначала увидеть маршрут, а потом уже читать код.

flowchart TD
    Client[API клиент] -->|POST /api/auth/login| C[AuthController]
    C --> S[AuthService]
    S --> AM[AuthenticationManager]
    AM -->|authenticated Authentication| S
    S --> TS[TokenService]
    TS -->|IssuedAccessToken| S
    S -->|AuthResponse| C
    C --> Client

AuthService.login(...): один сценарий

Ниже — тот самый «линейный» метод, который хочется читать сверху вниз. Он делает три вещи: превращает LoginRequest в Authentication запрос, отдаёт его в AuthenticationManager, и только после успеха просит TokenService выписать токен.

package com.example.securecontent.auth;

import com.example.securecontent.security.jwt.IssuedAccessToken;
import com.example.securecontent.security.jwt.TokenService;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;

@Service
public class AuthService {

    // Инфраструктура Spring Security: проверяет логин/пароль и возвращает Authentication.
    private final AuthenticationManager authenticationManager;

    // Наша часть: выписываем JWT после успешной аутентификации.
    private final TokenService tokenService;

    public AuthService(AuthenticationManager authenticationManager, TokenService tokenService) {
        this.authenticationManager = authenticationManager;
        this.tokenService = tokenService;
    }

    public AuthResponse login(LoginRequest request) {
        Authentication authResult = authenticationManager.authenticate(
                UsernamePasswordAuthenticationToken.unauthenticated(
                        request.getLogin(),
                        request.getPassword()
                )
        );

        IssuedAccessToken token = tokenService.issueAccessToken(authResult);
        return new AuthResponse(token.value(), "Bearer", token.expiresInSeconds());
    }
}

Здесь новым на этом этапе является только одно: вызов tokenService.issueAccessToken(...) теперь действительно знает, откуда берутся claim set, secret и TTL. Всё остальное — уже знакомый каркас login flow.

AuthController: контроллер не знает про secret и TTL

На web-границе ничего экзотического не происходит: контроллер по-прежнему принимает LoginRequest, делегирует в AuthService и возвращает AuthResponse. Ему не нужны ни secret, ни TTL, ни детали подписи — и это хороший сигнал, что слои не перепутались.

Пример запроса/ответа

Ниже — пример ручной проверки через curl. Мы не обсуждаем, где клиент хранит токен, и не обсуждаем protected endpoints. Нам важно только увидеть, что login endpoint возвращает ожидаемый JSON.

# POST на endpoint логина, передаём логин/пароль в JSON
curl -i -X POST http://localhost:8080/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"login":"alice","password":"password"}'

Пример ответа (сокращённый, токен обрезан):

{
  "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9....",
  "tokenType": "Bearer",
  "expiresIn": 900
}

Если вы видите такой ответ и понимаете, откуда берутся эти три поля — у вас уже есть рабочий stateless login flow. На этом месте token уже перестаёт быть абстракцией: он реально рождается в login flow, знает свой TTL и несёт понятный claim set. И тут сам собой появляется следующий инженерный вопрос: что делать с этим Authorization: Bearer ... на обычных запросах, как проверить токен и снова собрать Authentication для SecurityContext.

5. Гигиена: secret и токен

На этом этапе очень легко «обнулить» всю пользу от вынесенной конфигурации, если начать логировать secret, возвращать его в ошибки или хранить в коде “на всякий случай”. Поэтому нужно буквально пару простых правил, которые спасают репутацию сильнее, чем любая шифровка. Secret — это secret. Token — почти secret. Если сомневаетесь — ведите себя так, будто вас читают лог-ревьюеры из будущего (они, кстати, реально существуют: это вы через месяц).

Первое практическое правило — не логировать token целиком. Для отладки достаточно пользователя и expiresIn. Если очень нужно «увидеть token», выведите только первые 1015 символов и то в локальном профиле.

package com.example.securecontent.auth;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class AuthLog {
    private static final Logger log = LoggerFactory.getLogger(AuthLog.class);

    public static void issued(String username, long expiresInSec) {
        // Не логируем сам токен: в логах он живёт долго и может утечь.
        log.info("Issued access token for user={}, expiresInSec={}", username, expiresInSec);
    }
}

Второе правило — secret не должен быть “просто строкой в Git”. Даже если “это только учебный проект”. Учебный проект — это как раз место, где привычки закрепляются. Пусть привычка будет хорошей: secret приходит из переменной окружения.

Если вы запускаете приложение локально, можно (на вашем компьютере) сделать так:

# Секрет задаём переменной окружения на машине разработчика
export APP_SECURITY_JWT_SECRET="local-dev-secret-please-change"
./gradlew bootRun

И да, secret должен быть длиннее, чем “123”. Иначе вы подпишете токены чем-то, что подбирается быстрее, чем вы успеете допить чай.

6. Типичные ошибки при работе с JWT

Ошибка №1: хардкодить secret в коде “чтобы быстрее”.
Это обычно начинается как невинное "secret" в JwtTokenService, а заканчивается тем, что этот secret живёт в репозитории годами. Даже если проект учебный, вы тренируете себе привычку. Правильное место secret — внешняя конфигурация (env var или секрет-хранилище), а в коде остаётся только ключ ${...}.

Ошибка №2: втащить @Value с secret в контроллер.
Иногда кажется логичным: “контроллер же отвечает за login, пусть он знает secret”. Это ломает границы слоёв. Контроллер должен быть тупым и безопасным: он не должен иметь доступ к secret вообще. Чем меньше компонентов знают secret, тем меньше шансов, что его случайно залогируют, вернут в ошибке или утащат в дебаг.

Ошибка №3: перепутать единицы измерения TTL.
Это тот случай, когда приложение «работает», но безопасность или UX становятся странными. Token'ы внезапно истекают сразу или живут слишком долго. В учебном проекте мы фиксируем TTL в миллисекундах, но expiresIn в ответе удобнее отдавать в секундах. Если вы делаете конвертацию — делайте её ровно в одном месте и называйте переменные так, чтобы единицы читались глазами.

Ошибка №4: продублировать TTL в нескольких местах.
Например, вы записали TTL в application.yml, а потом “для удобства” ещё раз вписали 15 * 60 * 1000 в TokenService. Через неделю вы поменяете TTL в конфиге, а token'ы продолжат жить по старому времени, и вы будете искать “почему exp не совпадает”. TTL должен жить в конфигурации и попадать в код через DI как один источник правды.

Ошибка №5: логировать token целиком (или ещё хуже — Authorization header).
В логах token живёт долго, часто попадает в системы агрегации логов, и доступ к логам обычно шире, чем доступ к базе. Поэтому token в логах — это почти “публичная ссылка на ваш аккаунт”. Для диагностики достаточно логировать username, статус и время жизни. Если прям очень нужно — логируйте только кусочек token и только в локальном профиле.

1
Задача
Spring Security, 23 уровень, 4 лекция
Недоступна
Secret и TTL из application.yml
Secret и TTL из application.yml
1
Задача
Spring Security, 23 уровень, 4 лекция
Недоступна
Secret из env var и учебный JWT-подобный token
Secret из env var и учебный JWT-подобный token
1
Опрос
JWT Авторизация, 23 уровень, 4 лекция
Недоступен
JWT Авторизация
Логин и выдача токенов
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ