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 (три блока через точку):
- Header — алгоритм (
HS256) и типJWT. - Payload — JSON с claims (
sub,exp, …); не шифруется, только подписывается — не кладите в JWT пароли и персональные данные без необходимости. - Signature — HMAC от header+payload секретом; подмена payload ломает подпись → 401.
Проверка на сервере: декодировать payload, убедиться что exp в будущем, пересчитать подпись тем же секретом.
Basic и JWT (когда что)
| HTTP Basic | JWT (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 |
| Claims | subject, 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 |
Что попробовать
- Добавить claim
rolesи.requestMatchers("/api/admin/**").hasRole("ADMIN"). - Подключить Keycloak — заменить
JwtDecoderнаspring.security.oauth2.resourceserver.jwt.issuer-uri. - Глобальные ошибки API — единый JSON при 401/400.
Дальше
Ошибки REST и @Valid · Spring Security — Basic · JPA · Testcontainers
См. также
Другие статьи этого же раздела в боковом меню (как на странице «О разделе»). Основы Java - устройство JDK/JVM, модель компиляции и базовые принципы платформонезависимого выполнения. Java — это объектно-ориентированный язык программирования общего назначения, который работает на принципах «напиши один раз, запускай в любом месте». Набор советов, правил, принципов и обычаев в разработке на этом языке. История Java — от проекта Green и Oak до OpenJDK, LTS-релизов и современной платформы (модули, records, виртуальные потоки). Библиотеки, фреймворки, инструменты сборки, тестирования, развёртывания и мониторинга. Что такое пакет и пакетная структура, как собираются проекты на Java. Справочник-шпаргалка по конфигурациям в Java — типы, синтаксис, стандартная библиотека, типовые паттерны. Не заменяет пошаговое обучение. Учебный курс — раздел. Гайд по установке и настройке с написанием первой программы и её запуском. Практические примеры — консольные утилиты, композиция классов в мини-игре и первое Swing-приложение. Точки останова, пошаговое выполнение, панели Variables и Call Stack — практика отладки в IntelliJ IDEA. Кавычки, точки, запятые, скобки и прочие знаки препинания. Это полный справочник всех ключевых слов языка Java, включая основные, контекстные и зарезервированные слова.Основы языка Java
Что требуется знать перед началом изучения языка программирования Java
Рекомендации по разработке на Java
История языка Java
Экосистема Java-приложений
Структура и сборки Java-проектов
Справочник по конфигурациям в Java
Первая программа на Java
Простые приложения на Java
Отладка Java-кода в IDE
Синтаксис и пунктуация в Java
Ключевые слова в Java