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», выведите только первые 10–15 символов и то в локальном профиле.
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 и только в локальном профиле.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ