Ваш сервис получает HTTP-запрос с заголовком Authorization: Bearer <длинная строка>. Эту строку (access-токен) выдал Keycloak после того, как пользователь вошёл в систему. Вопрос, на который сервис обязан ответить до выполнения любой логики: этот токен настоящий, его правда выпустил наш Keycloak, и срок ещё не вышел? Разберём, как Spring Boot отвечает на этот вопрос почти без вашего кода.
Проблема: проверять токен самому — дорого и опасно
Наивный путь — на каждый запрос дёргать Keycloak и спрашивать «этот токен ещё годен?». Так можно (это называется introspection), но у подхода две беды: лишний сетевой вызов на каждый запрос и жёсткая зависимость от того, что Keycloak сейчас доступен. Под нагрузкой это узкое место.
Хорошая новость: современный access-токен Keycloak — это JWT (JSON Web Token). Внутри него лежат данные о пользователе и подпись, сделанная закрытым ключом Keycloak. Подпись можно проверить локально, имея только открытый (публичный) ключ. Сервису не нужно никого спрашивать на каждый запрос — он один раз скачивает публичные ключи и дальше проверяет подписи сам.
Аналогия: токен — как бумажный пропуск с водяным знаком. Охранник на входе не звонит в типографию по каждому посетителю — он один раз выучил, как выглядит водяной знак, и сверяет на месте.
Роль сервиса в этой схеме называется OAuth2 Resource Server — «сервер ресурсов». Он не логинит пользователей и не выдаёт токены (это работа Keycloak), он только принимает уже выданный токен и проверяет его.
Где Keycloak хранит публичные ключи: JWKS
Keycloak публикует свои открытые ключи по специальному адресу — это JWKS (JSON Web Key Set), набор ключей в формате JSON. Для realm с именем myrealm адрес такой:
https://keycloak.example.com/realms/myrealm/protocol/openid-connect/certs
Тут важны два термина Keycloak:
- realm — изолированное пространство со своими пользователями, ролями и ключами. У каждого realm — свой набор JWKS.
- client — приложение, зарегистрированное в realm. Frontend получает токен «от имени» какого-то client; ваш backend проверяет токены, выданные в рамках того же realm.
Spring скачает JWKS, закеширует ключи и будет сверять подпись каждого входящего токена с этими ключами. Сеть дёргается редко (когда Keycloak меняет ключи), а не на каждый запрос.
Шаг 1: зависимость
Всё, что нужно для роли Resource Server, лежит в одном стартере. Для Gradle:
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
Для Maven:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
Этот стартер тянет за собой Spring Security и библиотеки для разбора и проверки JWT. Отдельный JWT-парсер подключать не нужно.
Шаг 2: куда ходить за ключами
Достаточно одной строки в application.yml. Есть два варианта — выберите один.
Вариант А — issuer-uri (рекомендуется). Вы указываете адрес самого realm, а не ключей:
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://keycloak.example.com/realms/myrealm
Что произойдёт при старте: Spring сходит по адресу <issuer-uri>/.well-known/openid-configuration — это «визитка» realm, где Keycloak сам описывает все свои адреса. Оттуда Spring узнает адрес JWKS, скачает ключи и соберёт проверяльщик токенов (JwtDecoder). Бонус: Spring запомнит, какой issuer (поле iss в токене) считается «своим», и будет автоматически отклонять токены, выпущенные чужим realm.
Вариант Б — jwk-set-uri. Вы напрямую даёте адрес ключей:
spring:
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: https://keycloak.example.com/realms/myrealm/protocol/openid-connect/certs
Когда это нужно: вариант А обращается к Keycloak уже при старте приложения (за «визиткой»). Если Keycloak в этот момент недоступен, сервис не поднимется. Вариант Б такого обращения на старте не делает — ключи подтянутся при первом запросе. Платой за это будет потерянная автоматическая проверка iss (её при желании добавляют отдельно). На практике issuer-uri удобнее почти всегда.
Шаг 3: что проверять, а что пускать без токена — SecurityFilterChain
Зависимость и адрес ключей научили сервис проверять токен. Теперь надо сказать, какие запросы вообще требуют токена. Это описывается бином SecurityFilterChain:
@Configuration
@EnableWebSecurity
class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health", "/public/**").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(withDefaults()));
return http;
}
}
Читается сверху вниз, как список правил:
permitAll()— health-check и/public/**доступны без токена;anyRequest().authenticated()— всё остальное требует валидный токен;oauth2ResourceServer(... .jwt(...))— главная строка: «токены — это JWT, проверяй их так, как настроено вapplication.yml».
Если запрос пришёл без токена или с просроченным/поддельным — Spring сам вернёт 401 Unauthorized, ваш код даже не вызовется. Импорт withDefaults — org.springframework.security.config.Customizer.withDefaults.
Что Spring проверяет автоматически при каждом запросе: подпись (по JWKS), срок действия (поле exp), время «не раньше» (nbf) и — при issuer-uri — что iss принадлежит вашему realm. Всё это локально, без обращения к Keycloak.
Шаг 4: как достать пользователя и claims из токена
Токен прошёл проверку — теперь логике нужны данные из него: кто это, какие у него права. Содержимое токена называется claims (утверждения) — это просто пары «ключ-значение»: sub (идентификатор пользователя), preferred_username, email и так далее.
Самый простой способ — попросить Spring передать разобранный токен в метод контроллера:
@GetMapping("/me")
public String me(@AuthenticationPrincipal Jwt jwt) {
String userId = jwt.getSubject(); // claim "sub"
String username = jwt.getClaimAsString("preferred_username");
return "Привет, " + username + " (" + userId + ")";
}
Тот же токен доступен и через SecurityContext, если до него нужно добраться вне контроллера. Объект аутентификации здесь — JwtAuthenticationToken:
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
Jwt jwt = ((JwtAuthenticationToken) auth).getToken();
String userId = jwt.getSubject();
sub — самый надёжный идентификатор пользователя: он не меняется, в отличие от имени или почты.
Подводный камень: роли Keycloak Spring по умолчанию не видит
Keycloak кладёт роли пользователя в claim realm_access.roles (роли уровня realm) и в resource_access (роли по конкретным client). А Spring Security по умолчанию ищет права в claim scope. Из-за этого свежий проект часто ведёт себя так: токен валиден, пользователь пускается, но любая проверка роли (hasRole("admin")) проваливается, потому что Spring не нашёл, где Keycloak записал роли.
Решается небольшим переходником — JwtAuthenticationConverter, который объясняет Spring, откуда брать роли:
@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
var realmAccess = jwt.getClaimAsMap("realm_access");
if (realmAccess == null) {
return List.of();
}
@SuppressWarnings("unchecked")
var roles = (List<String>) realmAccess.get("roles");
if (roles == null) {
return List.of();
}
return roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.map(GrantedAuthority.class::cast)
.toList();
});
return converter;
}
Префикс ROLE_ — соглашение Spring: метод hasRole("admin") под капотом ищет право ROLE_admin. Когда SecurityFilterChain объявлен явно (как в шаге 3), конвертер нужно так же явно передать в .jwt(...) — сам он не подхватится:
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())))
После этого роли из Keycloak работают в @PreAuthorize и .hasRole(...).
Коротко
- В этой схеме сервис — OAuth2 Resource Server: он не выдаёт токены, а только проверяет входящие.
- Access-токен Keycloak — это JWT с подписью; проверка идёт локально по публичным ключам, без вызова Keycloak на каждый запрос.
- Публичные ключи Keycloak отдаёт по адресу JWKS своего realm; Spring их кеширует.
- Подключается одной зависимостью —
spring-boot-starter-oauth2-resource-server. - В
application.ymlхватает одной строки:issuer-uri(рекомендуется — даёт discovery и проверкуiss) либоjwk-set-uri(без обращения к Keycloak на старте). SecurityFilterChainзадаёт, что требует токена, а что открыто; невалидный токен →401ещё до вашего кода.- Данные пользователя берут из
Jwt(@AuthenticationPrincipal JwtилиJwtAuthenticationToken); надёжный идентификатор — claimsub. - Роли Keycloak лежат в
realm_access.roles— чтобы Spring их увидел, нуженJwtAuthenticationConverterс префиксомROLE_.
Что почитать дальше
- Realm, client и роли в Keycloak — как устроены пространства, приложения и роли.
- OAuth2 и OIDC: потоки получения токена — как frontend получает тот самый Bearer-токен (включая PKCE).
- Структура JWT и проверка подписи — что внутри токена и как устроена JWKS-проверка.