Перейти к основному содержимому

JWT и OAuth2 Resource Server в Spring Boot

Разработчику Архитектору

JWT и OAuth2 Resource Server в Spring Boot

После HTTP Basic клиенту неудобно слать логин и пароль в каждом запросе. Схема JWT: один раз POST /auth/login → сервер отдаёт токен → дальше только Authorization: Bearer eyJhbG....

Stateless значит: сервер не хранит сессию в памяти; вся информация о пользователе и сроке жизни — внутри подписанного токена. Микросервисы масштабируются проще: любой инстанс проверяет подпись тем же секретом или публичным ключом.

Здесь — учебный монолит: свой эндпоинт выдачи токена + OAuth2 Resource Server для проверки. В проде токены обычно выдаёт Keycloak, Auth0 или корпоративный IdP — см. Spring Framework § Security.

Перед стартом: Spring Boot — первая программа, Spring Security — старт.

Словарь

ТерминОбъяснение
JWT (JSON Web Token)Строка из трёх частей в Base64: заголовок, полезная нагрузка (claims), подпись
ClaimПоле внутри JWT: sub (кто), exp (когда истекает), scope, roles
Bearer«Предъявитель токена» — тип авторизации в заголовке Authorization: Bearer ...
Resource ServerВаш API, который принимает и проверяет токены (в отличие от сервера, который их выдаёт)
JwtEncoder / JwtDecoderПодписать токен при логине / проверить подпись и срок при запросе
HS256Симметричный алгоритм: один секрет и для подписи, и для проверки (учебный вариант)
RS256Асимметричный: приватный ключ подписывает, публичный проверяет (типично в проде + JWKS)

Как проходить статью
Сначала запустите приложение и получите токен через POST /auth/login, затем вызовите /api/me с заголовком Authorization: Bearer .... Только после этого разбирайте JwtEncoder и JwtDecoder.


Структура JWT (что внутри строки)

Токен выглядит как xxxxx.yyyyy.zzzzz (три блока через точку):

  1. Header — алгоритм (HS256) и тип JWT.
  2. Payload — JSON с claims (sub, exp, …); не шифруется, только подписывается — не кладите в JWT пароли и персональные данные без необходимости.
  3. Signature — HMAC от header+payload секретом; подмена payload ломает подпись → 401.

Проверка на сервере: декодировать payload, убедиться что exp в будущем, пересчитать подпись тем же секретом.

Basic и JWT (когда что)

HTTP BasicJWT (Bearer)
Логин/пароль в каждом запросе (Base64 в заголовке)Токен после /auth/login
Проще для curl и внутренних скриптовУдобно мобильным приложениям и SPA
Сессия на сервере опциональнаОбычно без серверной сессии (stateless)
Пароль «светится» чащеПароль один раз на /login; дальше только токен с ограниченным сроком

Что получится

# 1) Получить токен
curl -s -X POST http://localhost:8080/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"demo","password":"secret"}'

# 2) Вызвать API с токеном (подставьте access_token из ответа)
curl -s http://localhost:8080/api/me \
-H "Authorization: Bearer eyJhbG..."

Зависимости

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>

Секрет для подписи (только dev)

В application.yml не храните прод-секреты в Git. Для локальной отладки:

app:
jwt:
# минимум 32 байта для HS256
secret: "change-me-change-me-change-me-32b!"

JwtDecoder и JwtEncoder (симметричный HS256)

package com.example.demo.security;

import com.nimbusds.jose.jwk.source.ImmutableSecret;
import com.nimbusds.jose.jwk.OctetSequenceKey;
import com.nimbusds.jose.proc.SecurityContext;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.jwt.*;

import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;

@Configuration
public class JwtConfig {

@Bean
SecretKey secretKey(@Value("${app.jwt.secret}") String secret) {
byte[] bytes = secret.getBytes(StandardCharsets.UTF_8);
return new SecretKeySpec(bytes, "HmacSHA256");
}

@Bean
JwtDecoder jwtDecoder(SecretKey secretKey) {
return NimbusJwtDecoder.withSecretKey(secretKey).build();
}

@Bean
JwtEncoder jwtEncoder(SecretKey secretKey) {
var jwk = new OctetSequenceKey.Builder(secretKey.getEncoded()).build();
var jwkSource = new ImmutableSecret<SecurityContext>(jwk);
return new NimbusJwtEncoder(jwkSource);
}
}

Разбор beans:

BeanРоль
SecretKeyБайты секрета из app.jwt.secret для HmacSHA256
JwtDecoderПри запросе /api/me: проверить подпись и exp
JwtEncoderПри POST /auth/login: собрать claims и подписать

Один и тот же секрет для encoder и decoder — иначе токен, выданный при логине, не пройдёт проверку. В проде — RS256 и URI набора ключей (JWKS) у провайдера вроде Keycloak.

Почему секрет ≥ 32 байта: HS256 требует достаточной длины ключа; короткая строка даёт WeakKeyException.


SecurityFilterChain

@Configuration
@EnableWebSecurity
public class SecurityConfig {

@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/login", "/api/public").permitAll()
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> {}));
return http.build();
}
}

oauth2ResourceServer().jwt() подключает фильтр, который читает заголовок Bearer и валидирует JWT через JwtDecoder.


Выдача токена (учебный login)

package com.example.demo.web;

import org.springframework.http.ResponseEntity;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.web.bind.annotation.*;

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

record LoginRequest(String username, String password) {}
record TokenResponse(String accessToken, String tokenType, long expiresIn) {}

@RestController
@RequestMapping("/auth")
public class AuthController {

private final JwtEncoder jwtEncoder;

public AuthController(JwtEncoder jwtEncoder) {
this.jwtEncoder = jwtEncoder;
}

@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest req) {
if (!"demo".equals(req.username()) || !"secret".equals(req.password())) {
return ResponseEntity.status(401).body(Map.of("error", "invalid credentials"));
}

Instant now = Instant.now();
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("demo-app")
.subject(req.username())
.issuedAt(now)
.expiresAt(now.plusSeconds(3600))
.claim("scope", "read")
.build();

var jwt = jwtEncoder.encode(JwtEncoderParameters.from(claims));
return ResponseEntity.ok(new TokenResponse(jwt.getTokenValue(), "Bearer", 3600));
}
}

Разбор login:

ШагКодСмысл
Проверка пароля!"demo".equals(...)Учебная заглушка; в проде — UserDetailsService + encoder
Claimssubject, issuedAt, expiresAtКто вошёл и когда токен протухает (3600 с)
jwtEncoder.encode(...)Строка JWT в accessToken
tokenType: BearerПодсказка клиенту, как слать заголовок

Ответ клиенту: {"accessToken":"eyJ...","tokenType":"Bearer","expiresIn":3600}.

предупреждение

Жёстко прошитые demo/secret — только для обучения. В проде — BCrypt, БД, rate limit, refresh tokens.


Защищённый эндпоинт

@RestController
public class MeController {

@GetMapping("/api/me")
public Map<String, Object> me(@AuthenticationPrincipal Jwt jwt) {
return Map.of(
"subject", jwt.getSubject(),
"expiresAt", jwt.getExpiresAt().toString()
);
}
}

Jwt — уже разобранный и проверенный токен; поля доступны как claims.


Цепочка запроса

POST /auth/login → JwtEncoder → access_token
GET /api/me + Bearer → JwtAuthenticationFilter → JwtDecoder → контроллер

Если подпись неверна или срок истёк — 401 до входа в метод контроллера.


Частые ошибки

СимптомПричина
401 на валидном токенеДругой app.jwt.secret при выдаче и проверке
WeakKeyExceptionСекрет HS256 короче 256 бит — удлините строку
403 вместо 401Токен валиден, но не хватает scope/роли
Токен в query stringИспользуйте только заголовок Authorization

Что попробовать

  1. Добавить claim roles и .requestMatchers("/api/admin/**").hasRole("ADMIN").
  2. Подключить Keycloak — заменить JwtDecoder на spring.security.oauth2.resourceserver.jwt.issuer-uri.
  3. Глобальные ошибки API — единый JSON при 401/400.

Дальше

Ошибки REST и @Valid · Spring Security — Basic · JPA · Testcontainers


См. также

Другие статьи этого же раздела в боковом меню (как на странице «О разделе»).