Всем привет! Сегодня мы посмотрим, как сделать полноценную интеграцию api шлюза spring cloud gateway и keycloak, так как мне показалось, что тема недостаточно раскрыта. С небольшими оговорками этот пример можно использовать в реальных продакшн условиях.

Шлюз как BFF

Для веб-приложений рекомендуемым шаблоном авторизации и аутентификации является BFF – то есть вся логика Oauth 2.0/OIDC выполняется на бэкенде. При этом само веб-приложение (фронт) не выступает в процессе авторизации в качестве клиента. В такой архитектуре клиентом будет являться некий промежуточный бэкенд, он же BFF, при чем приватным клиентом. Веб приложение взаимодействует с BFF через http-сессии, это утверждение справедливо и для авторизации/аутентификации. Иногда можно встретить термин cookie-based authentication. Основная идея заключается в том, что получаемые в процессе авторизации токены access и refresh (если мы еще и аутентифицируемся, то id токен) не должны храниться где-то на стороне веб-приложения, лучше, если они будут храниться в веб-сессии на стороне BFF. При этом на стороне веб-приложения будет храниться cookie, который однозначно идентифицирует веб сессию. Пока эта сессия активна мы будем получать авторизованный доступ к нашему веб-ресурсу. Кроме того, BFF при таком подходе выступает в роли приватного oauth клиента, а такие клиента гораздо более безопасны, чем публичные.

В качестве такого BFF может выступать api шлюз, например spring cloud gateway. В блоге spring подробно описано как настроить шлюз, мы сделаем тоже самое, но с keycloak и рядом нюансов, характерных для продакшн среды.

В spring cloud gateway реализован фильтр TokenRelay. По факту он полностью поддерживает cookie-based authentication – каждый раз при попытке доступа к защищенному ресурсу фильтр будет проверять наличие объекта OAuth2AuthorizedClient в текущей http-сессии, если объект найдет, то будет выполнен проброс запроса дальше к защищенному ресурсу с access токеном, полученным из OAuth2AuthorizedClient, либо выполнен его рефреш, если его срок действия истек. Объект OAuth2AuthorizedClient будет получен в процессе авторизации и создания сессии. Все, что нужно сделать – это настроить шлюз, как oauth2.0 клиент и обеспечить работу http-сессий в кластерной среде. Этого будет достаточно.

Настраиваем Keycloak

Тут все достаточно просто. В моем локальном инстансе keycloak уже есть реалм test с дефолтными настройками, нам этого вполне достаточно. Создадим в нем клиента.

Желательно давать осмысленные названия и не стесняться писать подробные описания для чего нужен клиент.

Обязательно ставим галочку Client authentication, иначе наш клиент будет публичным, а нам нужен приватный.

Для большей безопасности рекомендуется включить PKCE, даже несмотря на то, что наш клиент не публичный и не является native app, то есть мобильным или десктопным приложением.

Кроме того, я создал тестового пользователя user и установил ему такой же пароль.

В общем то, на этом все. Никаких других настроек больше не нужно. Можно сделать свой client scope, но это для демонстрации нам не нужно. Также у пользователя должны быть какие-нибудь роли, что тоже не сильно влияет на наш пример.

Шлюз он же BFF

Так как шлюз выступает в роли oauth клиента, нам нужен стартер spring-boot-starter-oauth2-client. Естественно, сам шлюз spring-cloud-starter-gateway и поддержка сессий spring-session-data-redis и spring-session-core. Для реализации htpp-сессий мы будем использовать redis. Локально у меня одна нода, но в продакшн условиях нужен полноценный кластер. Для коннекта к редису нужен стартер spring-boot-starter-data-redis.

Переходим к конфигурации:

@Configuration
@EnableWebFluxSecurity
@EnableRedisWebSession
public class SecurityConfig {

    @Bean
    SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity httpSecurity,
                                                  ServerOAuth2AuthorizationRequestResolver resolver,
                                                  ServerOAuth2AuthorizedClientRepository auth2AuthorizedClientRepository,
                                                  ServerLogoutSuccessHandler logoutSuccessHandler,
                                                  ServerLogoutHandler logoutHandler) {
        return httpSecurity
                .authorizeExchange(
                        authorizeExchange ->
                                authorizeExchange.pathMatchers(
                                                "/actuator/**",
                                                "/access-token/**",
                                                "/id-token")
                                        .permitAll()
                                        .anyExchange()
                                        .authenticated()
                ).oauth2Login(oauth2Login ->
                        oauth2Login.authorizationRequestResolver(resolver)
                                .authorizedClientRepository(auth2AuthorizedClientRepository)
                )
                .logout(logout ->
                        logout.logoutSuccessHandler(logoutSuccessHandler)
                                .logoutHandler(logoutHandler)
                )
                .csrf(Customizer.withDefaults())
                .build();
    }

    @Bean
    ServerOAuth2AuthorizationRequestResolver requestResolver(ReactiveClientRegistrationRepository clientRegistrationRepository) {
        var resolver = new DefaultServerOAuth2AuthorizationRequestResolver(clientRegistrationRepository);
        resolver.setAuthorizationRequestCustomizer(OAuth2AuthorizationRequestCustomizers.withPkce());
        return resolver;
    }

    @Bean
    ServerOAuth2AuthorizedClientRepository authorizedClientRepository() {
        return new WebSessionServerOAuth2AuthorizedClientRepository();
    }

    @Bean
    ServerLogoutSuccessHandler logoutSuccessHandler(ReactiveClientRegistrationRepository clientRegistrationRepository) {
        OidcClientInitiatedServerLogoutSuccessHandler oidcLogoutSuccessHandler =
                new OidcClientInitiatedServerLogoutSuccessHandler(clientRegistrationRepository);
        oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}/test");
        return oidcLogoutSuccessHandler;
    }

    @Bean
    ServerLogoutHandler logoutHandler() {
        return new DelegatingServerLogoutHandler(
                new SecurityContextServerLogoutHandler(),
                new WebSessionServerLogoutHandler(),
                new HeaderWriterServerLogoutHandler(
                        new ClearSiteDataServerHttpHeadersWriter(ClearSiteDataServerHttpHeadersWriter.Directive.COOKIES)
                )
        );
    }
}

Конфигурация относительно небольшая. Так как шлюз реактивный, нам понадобится webflux реализация spring security. Как и положено в permitAll указываем все, что не должно быть защищено. Для защиты от межсайтовой подделки запросов указываем настройку csrf. Основное внимание нужно уделить oauth2Login. Это, то, о чем я говорил выше – шлюз будет oauth клиентом и процесс авторизации выполняется на нем. Чтобы работал PKCE необходимо задать ServerOAuth2AuthorizationRequestResolver с опцией OAuth2AuthorizationRequestCustomizers.withPkce(). В процессе авторизации будет создать объект  OAuth2AuthorizedClient, это экземпляр авторизации, в котором хранятся токены (access и refresh). Для хранения объектов OAuth2AuthorizedClient используется компонент  ServerOAuth2AuthorizedClientRepository. Нам не нужно, чтобы наши авторизованные клиенты хранились в памяти, нам нужно чтобы они хранились в веб-сессии, поэтому создаем экземпляр WebSessionServerOAuth2AuthorizedClientRepository и указываем его в настройке oauth2Login.

Отдельно стоит обратить внимание на разлогин. В spring security для этого есть эндпоит /logaut. Сконфигурировать его можно по-разному, мы реализуем вариант с двумя компонентами – ServerLogoutHandler и ServerLogoutSuccessHandler. Для ServerLogoutSuccessHandler будем использовать OidcClientInitiatedServerLogoutSuccessHandler – это разлогин на стороне клиента с использование эндпоинта oidc, его можно посмотреть в конфигурации oidc. Не забываем указать т.н. postLogoutRedirectUri – страница, куда нас перенаправит шлюз после разлогина. Для ServerLogoutHandler есть компонент DelegatingServerLogoutHandler – это компоновщик, состоящий из нескольких ServerLogoutHandler. Мы будем использовать три реализации:

  • SecurityContextServerLogoutHandler – удаляем SecurityContext после разлогина за ненадобностью;

  • WebSessionServerLogoutHandler – очищаем сессию;

  • HeaderWriterServerLogoutHandler в связке с ClearSiteDataServerHttpHeadersWriter – чистим ненужные больше cookie;

Эти два компонента api spring security указываем в logout.

Последнее что нам осталось сделать это добавить настройки oauth клиента в application.yaml:

  security:
    oauth2:
      client:
        provider:
          keycloak:
            issuer-uri: http://localhost:8080/realms/test
        registration:
          keycloak:
            provider: keycloak
            client-id: oauth-client
            client-secret: changeIt
            authorization-grant-type: authorization_code
            scope:
              - openid
              - email
              - profile
              - roles

И добавим тестовый маршрут:

  cloud:
    gateway:
      globalcors:
        cors-configurations:
          '[/**]':
            allowedOrigins: "*"
            allowedMethods:
              - GET
              - OPTIONS
            allowedHeaders: "*"
            exposedHeaders: "*"
      routes:
        - id: test-app
          uri: http://localhost:8085/
          predicates:
            - Path=/test/**
            - Method= GET
          filters:
            - TokenRelay=

Я добавил настройки CORS, для фронта это важно. В списке filters не забываем указать TokenRelay. Помимо фронта в списке маршрутов можно прописать все api, к которым он обращается, это будет работать.

Очень часто веб-приложению бывают нужны токены, как access, так и id. Для их получения у нас предусмотрен контроллер AuthInfoController с двумя запросами:

   @GetMapping("/access-token")
    public OAuth2AccessToken getAccessToken(@RegisteredOAuth2AuthorizedClient OAuth2AuthorizedClient client) {
        return client.getAccessToken();
    }

    @GetMapping("/id-token")
    public OidcIdToken getIdToken(@AuthenticationPrincipal OidcUser oidcUser) {
        return oidcUser.getIdToken();
    }

Первый вернет access токен, второй id по идентификатору сессии (т.е. на основе cookie).

Защищенный ресурс

У меня есть очень простой сервис, который настроен как oauth2 resource server, т.е. ресурс, которому мы хотим получить защищенный доступ.

	@Bean
	SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
		return httpSecurity.csrf(AbstractHttpConfigurer::disable)
				.httpBasic(AbstractHttpConfigurer::disable)
				.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
				.oauth2ResourceServer(oauth2ResourceServer -> oauth2ResourceServer.jwt(Customizer.withDefaults()))
				.build();
	}

И application.yaml:

spring:
  application:
    name: test-app
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:8080/realms/test

Есть простой контроллер:

@RestController
public class TestController {

    @GetMapping("/test")
    public String get() {
        return "Hello World!";
    }
}

Смотрим как это работает

Сервис запущен на порту 8085, keycloak 8080, а шлюз 8082. Пробуем выполнить запрос к защищенному ресурсу через шлюз http://localhost:8082/test:

Доступ получен. При этом в keycloak была создана активная сессия.

Сессию можно довольно гибко настраивать как для отдельного клиента, так и для всего реалма. В простейшем случае время жизни сессии можно интерпретировать, как время жизни рефреш-токена и лучше настроить веб-сессию и сессию keycloak одинаково, чтобы ни одна из сессий не повисла в воздухе.

Попробуем получить access токен:

И id токен:

Теперь попробуем разлогиниться: выполняет запрос http://localhost:8082/logout к нашему шлюзу.

Лучше, конечно, страницу кстомизировать под свои нужды, но мы для примера оставим дефолтную. Нажимает кнопку “Log Out” и получаем:

Проверим список активных сессий в keycloak:

Сессий нет. Мы успешно разлогинились.

В реактивной реализации oauth2Login есть парочка неудобных вещей – как минимум некоторые компоненты, если она заданы как бины, не подтягиваются в filterChain. Чуть позже я добавлю эти это в spring security, возможно в следующей минорной версии оно уже появится. Все примеры есть в моих репозиториях на github:

В целом, все вышеописанное будет работать для любого сервера авторизации, не только keycloak. Пишите в комментариях если столкнулись с проблемами при конфигурировании шлюза или keycloak, постараюсь ответить всем. Кроме того не забывайте подписываться на мой телеграм-канал, там много интересного контента на тему InfoSec и не только.

Комментарии (10)


  1. inzagher
    10.01.2025 17:57

    Спасибо за статью. После прочтения возникает пару вопросов.

    1. Если у нас на гетевей ломится фронт, к примеру реактовский, то как он определит, что сессии нет и надо показать страницу логина? В данном решении, как понимаю, в ответ фронт получит html со страницей аутентификации и как то должен её переварить? Или он всё таки должен в ответ получить 401ю без контента и тогда перенаправлять на страницу логина(свою реактовскую). Я встречал подобные решения типа сберовского IAM Proxy, который запрос из браузера на основании id сессии отправлял либо на внутреннюю страницу аутентификации(если сесиии нет), либо, в соответствии с настроенной маршрутизацией, на сервисы с фронтом (SPA) или гетевей. При этом все запросы обогащались токенами.

    1. Есть ли какое нибудь кэширование или каждый запрос на гетевей по http вызывает обращение в редис за сессией?


    1. franticticktick Автор
      10.01.2025 17:57

      Если у нас на гетевей ломится фронт, к примеру реактовский, то как он определит, что сессии нет и надо показать страницу логина?

      Это поймет сам шлюз и фронт получит редирект на страницу авторизации сервера авторизации. Даже если сессия до этого была и были просто почищены куки. При этом старая сессия будет висеть пока не отвалится сама.

      1. Есть ли какое нибудь кэширование или каждый запрос на гетевей по http вызывает обращение в редис за сессией?

      В общем-то, сам редис частично выполняет функцию кэша, в памяти авторизационные данные не кэшируются. Это могло бы быть актуально для небольшого числа сессий, но представьте если у вас, например, 2 млн живых пользователей, и вам нужно держать в памяти 2 млн активных сессий, т.е. объектов OAuth2AuthorizedClient. Лучше так не делать.


      1. inzagher
        10.01.2025 17:57

        То есть получается, что браузер тянет SPA тоже через гетевей? Если же это не так, и первый запрос гетевею идет из браузера с уже загруженным SPA, то редирект придется прописывать вручную в коде фронта, т.к. запрос к апи, который вернул 302, скорее всего не приведет к уходу на страницу аутентификации(не уверен на 100%, надо проверять).

        По кешированию - имел дело с решением, где мы на гетевее парсим токен и проверяем его подпись открытым ключом, который как раз закеширован. В том решении сессий нет, токен выдали и доверяем ему, пока не протухнет. В описанном примере понятно, что без редиса не обойтись, просто было интересно есть ли еще дополнительные кэши - видимо нет.


  1. inzagher
    10.01.2025 17:57

    А где кейклок держит информацию о сессии? Гетевей хранит в редисе, кейклок скорее всего в своей бд, выходит дублирование?


    1. mrfloony
      10.01.2025 17:57

      Редис будет хранить базовую (для работы только шлюза) информацию, а в кейклоке для пользователя будет полная информация, в том числе для иных систем


      1. franticticktick Автор
        10.01.2025 17:57

        Для простоты можно принять, что редис хранит два токена - аксес и рефреш.


  1. ksenechka_m_belos
    10.01.2025 17:57

    Правильно ли я понимаю, что фронт логиниться один раз, при этом создаётся сессия на какой-то срок, и фронт следующие запросы отправляет без токена?


    1. franticticktick Автор
      10.01.2025 17:57

      Все верно. В следующий раз для доступа к авторизованной сессии фронт будет использовать куку. Правильнее сказать браузер будет использовать куку.


      1. ksenechka_m_belos
        10.01.2025 17:57

        Очень полезная статья, спасибо!

        Правда по безопасности такой вариант не всегда прокатит((( могут придраться


        1. franticticktick Автор
          10.01.2025 17:57

          Не волнуйтесь, по безопасности такой вариант на данный момент - лучшее, что можно сделать:

          • На шлюзе есть csrf токены от межсайтового скриптинга.

          • Аксес и рефреш токены не хранятся на стороне клиента.

          • Запрос не доходит до бэка если есть проблемы с авторизацией, а остается на шлюзе.

          В целом, такая архитектура это наиболее безопасный и оптимальный вариант.