Вход в API по токену JWT
Сейчас клиент API отправляет имя и пароль в каждом запросе. Мобильному приложению пришлось бы хранить пароль пользователя, а это плохо. Обычно делают иначе: пароль отправляют один раз, а взамен получают токен — строку, которую дальше прикладывают к запросам в заголовке Authorization: Bearer .... Самый распространённый формат таких токенов — JWT (JSON Web Token).
Что внутри JWT
Токен состоит из трёх частей через точку: заголовок, данные и подпись. Данные — обычный JSON в кодировке Base64: кто пользователь, какие у него роли, до какого времени токен действует. Подпись сервер вычисляет секретным ключом. Подделать токен без ключа нельзя, а проверить его можно без базы и без сессии — достаточно ключа.
В Spring Security проверкой токенов занимается модуль OAuth2 Resource Server:
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-oauth2-resource-server</artifactId>
</dependency>Ключ и бины
Секретный ключ для алгоритма HS256 должен быть не короче 32 байт. Положим его в настройки, а заодно скажем, где в токене роли:
# src/main/resources/application.yaml
spring:
security:
oauth2:
resourceserver:
jwt:
authorities-claim-name: roles
authority-prefix: ""
blog:
jwt:
secret: changeme-changeme-changeme-changemeНа сервере ключ задают переменной окружения BLOG_JWT_SECRET. В SecurityConfig добавим ключ, кодировщик для выдачи токенов, декодер для проверки и AuthenticationManager для проверки пароля:
// src/main/java/com/example/blog/SecurityConfig.java
@Bean
public SecretKey jwtKey(@Value("${blog.jwt.secret}") String secret) {
return new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
}
@Bean
public JwtEncoder jwtEncoder(SecretKey jwtKey) {
return NimbusJwtEncoder.withSecretKey(jwtKey).build();
}
@Bean
public JwtDecoder jwtDecoder(SecretKey jwtKey) {
return NimbusJwtDecoder.withSecretKey(jwtKey).build();
}
@Bean
public AuthenticationManager authenticationManager(UserDetailsService users, PasswordEncoder encoder) {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider(users);
provider.setPasswordEncoder(encoder);
return new ProviderManager(provider);
}AuthenticationManager проверяет имя и пароль через наш BlogUserDetailsService — так же, как это делала форма входа.
Выдача токена
// src/main/java/com/example/blog/TokenService.java
@Service
public class TokenService {
private static final Duration LIFETIME = Duration.ofHours(1);
private final JwtEncoder encoder;
private final Clock clock;
public TokenService(JwtEncoder encoder, Clock clock) {
this.encoder = encoder;
this.clock = clock;
}
public TokenResponse issue(Authentication user) {
Instant now = Instant.now(clock);
List<String> roles = user.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.filter(name -> name.startsWith("ROLE_"))
.toList();
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("blog")
.subject(user.getName())
.issuedAt(now)
.expiresAt(now.plus(LIFETIME))
.claim("roles", roles)
.build();
JwsHeader header = JwsHeader.with(MacAlgorithm.HS256).build();
String token = encoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue();
return new TokenResponse(token, LIFETIME.toSeconds());
}
}subject — имя пользователя, roles — его роли вроде ROLE_USER, expiresAt — срок жизни: через час токен перестанет приниматься. Записи для запроса и ответа простые:
// src/main/java/com/example/blog/LoginRequest.java
public record LoginRequest(@NotBlank String username, @NotBlank String password) {
}// src/main/java/com/example/blog/TokenResponse.java
public record TokenResponse(String token, long expiresIn) {
}// src/main/java/com/example/blog/AuthController.java
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthenticationManager authenticationManager;
private final TokenService tokenService;
public AuthController(AuthenticationManager authenticationManager, TokenService tokenService) {
this.authenticationManager = authenticationManager;
this.tokenService = tokenService;
}
@PostMapping("/login")
public TokenResponse login(@Valid @RequestBody LoginRequest request) {
Authentication user = authenticationManager.authenticate(
UsernamePasswordAuthenticationToken.unauthenticated(request.username(), request.password()));
return tokenService.issue(user);
}
}При неверном пароле authenticate бросает AuthenticationException. Превратим его в ответ 401 в ApiExceptionHandler:
// src/main/java/com/example/blog/ApiExceptionHandler.java
@ExceptionHandler(AuthenticationException.class)
public ProblemDetail handleLogin(AuthenticationException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, "Неверное имя или пароль");
}Цепочка API принимает токены
В apiSecurity строку httpBasic(...) заменяем на проверку токена и открываем адрес входа:
// src/main/java/com/example/blog/SecurityConfig.java
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/users", "/api/auth/login").permitAll()
.requestMatchers(HttpMethod.DELETE, "/api/posts/**").hasRole("ADMIN")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()))Spring Security достаёт токен из заголовка, проверяет подпись и срок, а роли берёт из roles — это мы указали в настройках. Пользователь теперь не UserDetails, поэтому в контроллерах вместо @AuthenticationPrincipal UserDetails user пишем Principal principal и principal.getName(). Это работает и с токеном, и с формой входа.
Проверка
curl -X POST http://localhost:8080/api/auth/login -H "Content-Type: application/json" \
-d '{"username":"anna","password":"changeme"}'{"token":"eyJraWQiOiJOWkFjMWJIMzRCWGFu...","expiresIn":3600}Скопируйте токен в переменную оболочки TOKEN=eyJraWQi... и создайте статью. Файл post.json с текстом {"title":"Привет, JWT","body":"Статья по токену"} сохранён в UTF-8, как советовал урок о теле запроса:
curl -X POST http://localhost:8080/api/posts -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -d @post.json{"id":1,"title":"Привет, JWT","body":"Статья по токену","author":"anna","createdAt":"2026-10-04T10:49:22.976947400Z"}Средняя часть токена раскодируется командой echo $TOKEN | cut -d. -f2 | base64 -d и покажет {"iss":"blog","sub":"anna","exp":1791114549,"iat":1791110949,"roles":["ROLE_USER"]}. Остальные случаи:
| Запрос | Ответ |
|---|---|
Без заголовка Authorization | 401, WWW-Authenticate: Bearer |
| Неверный пароль при входе | 401, "detail":"Неверное имя или пароль" |
| Испорченный токен | 401, error="invalid_token", Signed JWT rejected: Invalid signature |
DELETE с токеном Анны (роль USER) | 403, error="insufficient_scope" |
DELETE с токеном администратора | 204 |
Пароль через -u anna:changeme API больше не принимает — только токен.
Частая ошибка
Короткий секрет, например changeme. Приложение не запустится:
Factory method 'jwtEncoder' threw exception with message: com.nimbusds.jose.KeyLengthException: The secret length must be at least 256 bits256 бит — это 32 байта. Для настоящего сервера сгенерируйте случайный ключ, например командой openssl rand -base64 32, и передайте его через BLOG_JWT_SECRET, а не храните в Git.