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