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

В 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 и не только.


ссылка на оригинал статьи https://habr.com/ru/articles/872856/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *