Всем привет! Сегодня мы посмотрим, как сделать полноценную интеграцию 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.yam
l:
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/
Добавить комментарий