Spring Cloud Gateway + Keycloak: полноценный пример

Всем привет! Сегодня мы посмотрим, как сделать полноценную интеграцию 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 и рядом нюансов, характерных для продакшн среды.

a1b29b3972f94d6f7c31af342b268f4d.png

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

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

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

c7cdda273dbfa723a30abaa2c580364c.jpg

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

6171cb7723d7be6762381ae522753e29.jpg

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

90d9d785622c616c35277cd97ea75048.jpg

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

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

50d8afe7227957df1db6461f33161817.jpgae0dadcebadd989cfcbab0760a57d241.jpg

В общем то, на этом все. Никаких других настроек больше не нужно. Можно сделать свой 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:

ee7758df034437b28ad1ac98c18e4ef3.jpg92e722d79f8827f467168cacb020d66c.jpg

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

b2cf0ba83d5dc2f4f9ef84e16aeaaebd.jpg

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

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

015175812224f246961777eda31a948b.jpg

И id токен:

ccfaa31ea9dbe161a7f014baf33b829a.jpg

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

7aa51a2dad5ec9920e4a463d0b06658a.jpg

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

2851bac2c2e4adac61cf487424f9cd8f.jpg

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

e4990a82fd526fd0a079525c154e9aaf.jpg

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

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

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

© Habrahabr.ru