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

К вашему сервису приходит HTTP-запрос с заголовком Authorization: Bearer <длинная строка>. Эту строку — access_token — выдал Keycloak после того, как пользователь вошёл в систему. И прежде чем сервис выполнит хоть одну строчку бизнес-логики, он обязан ответить на простой вопрос: этот токен настоящий, его правда выпустил наш Keycloak, и срок ещё не вышел? Если не проверить — кто угодно подставит любую строку и получит доступ к чужим данным.

Хорошая новость: Spring Boot умеет проверять такие токены почти без вашего кода. Плохая — пока не понимаешь, через какие руки проходит запрос внутри Spring, всё выглядит магией: написал три строки конфигурации, и вдруг одни запросы пускаются, а другие отлетают с ошибкой 401. Разберём эту «магию» по косточкам — кто и в каком порядке проверяет токен.

Зачем вообще проверять токен у себя, а не спрашивать Keycloak

Самый прямолинейный путь — на каждый входящий запрос звонить в Keycloak и спрашивать: «вот токен, он ещё годен?». Так действительно можно (этот приём называется introspection), но у него две беды. Первая — лишний сетевой вызов на каждый запрос: сервис становится медленнее, а Keycloak — узким местом под нагрузкой. Вторая — жёсткая зависимость: если Keycloak на минуту прилёг, ваш сервис перестаёт пускать вообще всех, даже с валидными токенами.

Поэтому современный access_token Keycloak делают в формате JWT (JSON Web Token). Это самодостаточный токен: внутри него уже лежат данные о пользователе и криптографическая подпись, поставленная закрытым (секретным) ключом Keycloak. Подпись можно проверить локально, имея на руках только парный открытый (публичный) ключ. Сервису не нужно никого спрашивать на каждый запрос — он один раз скачивает публичные ключи Keycloak и дальше сверяет подписи сам.

Аналогия: токен — как бумажный пропуск с водяным знаком. Охранник на входе не звонит в типографию по каждому посетителю — он один раз выучил, как выглядит правильный водяной знак, и сверяет на месте за секунду. Ровно так же Spring один раз запоминает «как выглядит подпись нашего Keycloak» и дальше проверяет токены самостоятельно.

Роль сервиса в этой схеме называется OAuth2 Resource Server — «сервер ресурсов». Запомните это разделение ролей: Keycloak логинит пользователей и выдаёт токены, а ваш сервис их только принимает и проверяет. Сам он токены не печатает и пользователей не аутентифицирует.

Чтобы и формат токена не путать с тем, как его проверяют: «JWT vs opaque» — это про формат access_token, а не про какой-то отдельный токен. JWT (by-value) несёт данные внутри себя, проверяется локально по публичному ключу. Opaque-токен (by-reference) — просто случайная строка-идентификатор, по которой данные приходится спрашивать у Keycloak через introspection. Keycloak по умолчанию выдаёт JWT, и эта статья — про него.

Где Keycloak хранит публичные ключи: JWKS

Чтобы проверить подпись локально, нужен публичный ключ Keycloak. Keycloak отдаёт свои публичные ключи по специальному адресу в виде набора ключей в формате JSON — это и называется JWKS (JSON Web Key Set). Для realm с именем myrealm адрес выглядит так:

https://keycloak.example.com/realms/myrealm/protocol/openid-connect/certs

Здесь всплывают два ключевых термина Keycloak, которые важно не путать:

  • realm — изолированное пространство со своими пользователями, ролями и ключами. У каждого realm — свой набор ключей в JWKS. Токен из одного realm не подойдёт к другому.
  • client — приложение, зарегистрированное внутри realm. Frontend получает токен «от имени» какого-то client; ваш backend проверяет токены, выданные в рамках того же realm.

Spring один раз скачает JWKS, закеширует ключи в памяти и дальше будет сверять подпись каждого входящего токена с этими ключами. К Keycloak за ключами он ходит редко — только когда Keycloak ротирует (меняет) ключи и встречается неизвестная подпись, — а не на каждый запрос.

Шаг 1: одна зависимость

Всё, что нужно для роли Resource Server, упаковано в один стартер Spring Boot. Для 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: куда ходить за ключами

Дальше надо сказать Spring, у какого Keycloak и какого realm брать публичные ключи. Достаточно одной строки в 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 в этот момент недоступен, ваш сервис не поднимется. Вариант Б на старте к Keycloak не ходит — ключи подтянутся при первом запросе с токеном. Платой за это будет потерянная автоматическая проверка iss (при желании её добавляют отдельно). На практике issuer-uri удобнее почти всегда — discovery и проверка издателя «из коробки» стоят дороже, чем независимость старта.

Шаг 3: что требует токена, а что открыто — SecurityFilterChain

Зависимость и адрес ключей научили сервис проверять токен. Но мы ещё не сказали, какие запросы вообще требуют токена. Health-check мониторинга должен отвечать без всякого токена, а вот защищённые данные — только по валидному. Это описывается бином 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».

Импорт withDefaultsorg.springframework.security.config.Customizer.withDefaults.

Здесь начинается то, ради чего статья. Слово Filter в названии бина — не случайное. Spring Security — это не один проверяльщик, а цепочка маленьких фильтров, через которые по очереди проходит каждый запрос, прежде чем добраться до вашего контроллера. Один из них умеет вытаскивать токен из заголовка Authorization, другой — проверять подпись, третий — раскладывать роли. Разберём этот конвейер пошагово.

Что происходит внутри: путь запроса по фильтрам

Когда строка .oauth2ResourceServer(... .jwt(...)) отработала при старте, Spring добавил в цепочку фильтр BearerTokenAuthenticationFilter. Это первое звено, которое касается токена. Его задача узкая: посмотреть в заголовок Authorization, и если там есть Bearer <токен> — вытащить эту строку. Если заголовка нет — фильтр просто пропускает запрос дальше, не падая (тогда позже сработает правило authenticated() и вернёт 401 для защищённых путей).

Дальше вытащенную строку нужно проверить. Этим занимается JwtDecoder — тот самый объект, который Spring собрал на шаге 2 из публичных ключей JWKS. Decoder делает несколько вещей за один проход, и все — локально, без обращения к Keycloak:

  • сверяет подпись токена с публичным ключом из JWKS — это гарантия, что токен выпустил именно наш Keycloak и его никто не подменил;
  • проверяет срок действия — поле exp (истёк ли токен) и nbf (не «из будущего» ли он);
  • при варианте issuer-uri проверяет издателя — что поле iss принадлежит вашему realm, а не чужому.

Если декодер всё одобрил, у нас есть разобранный токен — объект Jwt с его содержимым (claims). Но Spring пока не знает, какие у пользователя права. Этим занимается JwtAuthenticationConverter — переходник, который смотрит на claims токена и превращает роли из них в понятные Spring «полномочия» (authorities). По умолчанию он ищет права не там, где их кладёт Keycloak, — об этом отдельный раздел ниже.

В конце фильтр складывает результат — объект JwtAuthenticationToken (внутри него лежит и сам Jwt, и список полномочий) — в SecurityContext. С этого момента пользователь считается аутентифицированным, и запрос идёт дальше, в ваш контроллер, где данные о пользователе уже доступны.

Вот этот путь целиком. Что на схеме: запрос проходит через фильтр, который достаёт Bearer-токен, затем токен проверяет JwtDecoder по ключам из Keycloak, права раскладывает converter, и только потом управление получает контроллер.

diagram

Ключевое, что стоит вынести: к моменту, когда ваш код в контроллере начинает работать, токен уже проверен — подпись сошлась, срок не истёк, издатель свой. Вам не нужно ничего проверять руками.

Что происходит при невалидном токене: ответ 401

Теперь обратная ветка — самая частая причина недоумения новичка: «почему мой запрос возвращает 401?». Невалидным токен может оказаться на любом из шагов. Например:

  • заголовка Authorization вообще нет (забыли приложить токен);
  • токен просрочен — поле exp уже в прошлом;
  • подпись не сходится — токен подделан или выпущен чужим Keycloak;
  • издатель чужой — iss не совпал с вашим realm.

Во всех этих случаях JwtDecoder бракует токен, фильтр не кладёт аутентификацию в SecurityContext, и сработавший обработчик (AuthenticationEntryPoint) возвращает клиенту 401 Unauthorized. Важнейшая деталь: это происходит до вашего контроллера — ваш код вообще не вызывается. Бизнес-логика защищена ещё на входе.

Не путайте два кода ответа. 401 Unauthorized — «я не знаю, кто ты»: токена нет или он невалиден. 403 Forbidden — «я знаю, кто ты, но прав не хватает»: токен валиден, пользователь аутентифицирован, но у него нет нужной роли для этого действия. 401 — про проверку токена (эта статья), 403 — про проверку прав.

Что на схеме: тот же путь, но JwtDecoder забраковал токен, и клиент получает 401, не доходя до контроллера.

diagram

Если в логах вы видите 401 на запросе, который должен был пройти, — почти всегда дело в одном из четырёх пунктов выше. Загляните в сам токен (его содержимое не зашифровано, его легко прочитать) и сверьте exp и iss.

Как достать пользователя и его данные из токена

Токен прошёл проверку — теперь логике нужны данные из него: кто это, какая у него почта. Содержимое токена называется 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();

Маленький, но важный совет: для идентификации пользователя берите claim sub, а не имя или почту. sub — это неизменный технический идентификатор, он не поменяется, даже если человек сменит логин или email. Привязывать данные к preferred_username — частая ошибка, которая аукается при первом же переименовании пользователя.

Подводный камень: роли Keycloak Spring по умолчанию не видит

Это самая частая причина «у меня всё валидно, но hasRole не работает». Дело в том, что Keycloak и Spring по-разному договорились, где в токене лежат роли.

Keycloak кладёт роли пользователя в claim realm_access.roles (роли уровня realm) и в resource_access (роли по конкретным client). А Spring Security по умолчанию ищет права совсем в другом месте — в claim scope. В итоге свежий проект ведёт себя странно: токен валиден, пользователь пускается через authenticated(), но любая проверка роли (hasRole("admin")) проваливается — Spring просто не нашёл, где Keycloak записал роли, и считает, что у пользователя их нет.

Здесь и выходит на сцену JwtAuthenticationConverter из схемы выше. По умолчанию он смотрит не туда; наша задача — объяснить ему, откуда у Keycloak брать роли:

@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"), под капотом Spring ищет полномочие с именем ROLE_admin. Поэтому, складывая роли из Keycloak, мы дописываем им этот префикс — иначе hasRole снова ничего не найдёт.

И последний шаг, о котором легко забыть. Раз SecurityFilterChain объявлен явно (как в шаге 3), конвертер тоже нужно передать в .jwt(...) явно — сам по себе бин не подхватится:

.oauth2ResourceServer(oauth2 -> oauth2
    .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())))

После этого роли из Keycloak начинают работать в @PreAuthorize и в .hasRole(...).

Опасная настройка: как нечаянно превратить 401 в 403 и сломать refresh

По умолчанию Spring Security уже отвечает правильно: на невалидный или просроченный токен — 401 Unauthorized, на нехватку прав — 403 Forbidden. Ничего настраивать не нужно. Но есть распространённая ловушка: разработчик хочет «причесать» ответы об ошибках и переопределяет обработчики в блоке exceptionHandling — и нечаянно ломает поведение, которое работало само.

Вот как выглядит вредная настройка — отдавать 403 на всё подряд:

// ПЛОХО — теперь и невалидный токен отвечает 403
http.exceptionHandling(eh -> eh
    .authenticationEntryPoint((req, resp, e) -> resp.setStatus(403))
    .accessDeniedHandler((req, resp, e) -> resp.setStatus(403)));

Здесь authenticationEntryPoint — это как раз обработчик случая «токен не прошёл проверку» (нет заголовка, истёк exp, не сошлась подпись). Подменив его на ответ 403, вы сказали Spring: «на просроченный токен отвечай так, будто прав не хватает».

Почему это больно именно для просроченного токена. Access_token живёт недолго — минуты. Когда он истекает, грамотный клиент должен по своему refresh_token молча получить новый access_token и повторить запрос — пользователь даже не замечает. Но клиент принимает это решение по коду ответа: 401 он читает как «токен протух, надо обновиться», а 403 — как «токен в порядке, но это действие тебе запрещено, обновляться бесполезно». Подменив 401 на 403, вы лишаете клиент сигнала к обновлению. На истёкшем токене он получает 403, делает вывод «доступ запрещён», refresh не запускает — и пользователь оказывается в тупике: разлогинить себя клиент не считает нужным, а пускать его сервис не пускает.

Правильно — либо вообще ничего не трогать (дефолт Spring уже корректен), либо, если правка обработчиков всё-таки нужна, сохранить семантику кодов:

http.exceptionHandling(eh -> eh
    .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
    .accessDeniedHandler((req, resp, e) -> resp.setStatus(HttpStatus.FORBIDDEN.value())));

HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED) — это готовый обработчик Spring, который отвечает 401 на проблемы с аутентификацией. Так невалидный токен снова даёт 401 (клиент уйдёт обновляться), а нехватка прав — 403 (клиент покажет «доступ запрещён» и не будет зря дёргать refresh). Главное правило простое: не переопределяйте authenticationEntryPoint на 403. Если не уверены — не настраивайте exceptionHandling вовсе, дефолт делает ровно то, что нужно.

Коротко

  • В этой схеме сервис — OAuth2 Resource Server: он не выдаёт токены, а только проверяет входящие.
  • Access_token Keycloak — это JWT с подписью; проверка идёт локально по публичным ключам, без вызова Keycloak на каждый запрос.
  • Публичные ключи Keycloak отдаёт по адресу JWKS своего realm; Spring их кеширует.
  • Подключается одной зависимостью — spring-boot-starter-oauth2-resource-server.
  • В application.yml хватает одной строки: issuer-uri (рекомендуется — даёт discovery и проверку iss) либо jwk-set-uri (без обращения к Keycloak на старте).
  • Внутри запрос проходит конвейер: BearerTokenAuthenticationFilter достаёт токен из заголовка → JwtDecoder проверяет подпись по JWKS, exp, nbf, issJwtAuthenticationConverter раскладывает роли → контроллер.
  • SecurityFilterChain задаёт, что требует токена, а что открыто; невалидный токен → 401 Unauthorized ещё до вашего кода. 401 — «не знаю кто ты», 403 — «знаю, но прав нет».
  • Данные пользователя берут из Jwt (@AuthenticationPrincipal Jwt или JwtAuthenticationToken); надёжный идентификатор — claim sub.
  • Роли Keycloak лежат в realm_access.roles — чтобы Spring их увидел, нужен JwtAuthenticationConverter с префиксом ROLE_.

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

  • Realm, client, роли и пользователи в Keycloak — как устроены пространства, приложения и роли, откуда берётся realm_access.roles.
  • Authorization Code Flow и PKCE — как frontend получает тот самый Bearer-токен.
  • Токены Keycloak: проверка, refresh, отзыв и ошибки — что внутри JWT, как устроена проверка по JWKS при смене ключей.
  • Роли и доступ: RBAC и ABAC с Keycloak — что делать после проверки токена: проверка прав, @PreAuthorize, владение ресурсом.