← назад к разделу

Ваш сервис получает 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, ваш код даже не вызовется. Импорт withDefaultsorg.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); надёжный идентификатор — claim sub.
  • Роли Keycloak лежат в realm_access.roles — чтобы Spring их увидел, нужен JwtAuthenticationConverter с префиксом ROLE_.

Что почитать дальше