Spring Cloud Gateway как шлюз для мобильных приложений
В статье будет рассмотрен способ организации инфраструктуры API шлюза для мобильных приложений. Как и в предыдущий раз мы будем использовать spring cloud gateway и keycloak.
Oauth 2.0 для мобильных клиентов
Первое и самое главное отличие — мобильные Oauth 2.0 клиенты не могут быть публичными. В теории, конечно, можно сделать мобильное приложение приватным клиентом, но тогда встанет вопрос безопасного хранения клиентского секрета на стороне приложения, что сразу же открывает ряд уязвимостей и просто делает такое решение сложным и неудобным. Для приватных клиентов лучше всего использовать паттерн BFF. В том же стандарте (пока еще драфт) описан еще один шаблон — Token-Mediating Backend, который мы и будем использовать в своей архитектуре. При этом наша реализация будет немного отличаться от той, что в стандарте. Там все-таки речь идет про браузерные приложения. Мы адаптируем этот архитектурный шаблон под мобильные приложения.
Для начала нужно обратить внимание на ряд моментов. Во-первых, в идеальном варианте на каждое мобильное приложение нужен свой oauth-клиент. Этого можно добиться с помощью OpenID Connect Dynamic Client Registration. В такой архитектуре безопасность приложения существенно выше, чем у одного клиента на множество приложений. Например, при потере девайса, мы легко сможем заблокировать клиента в keycloak, что надежно защитит наш девайс от попыток несанкционированного доступа. Второй момент — это привязка токена к самому клиенту. Для этого можно использовать технику Demonstrating Proof of Possession (DPoP). Я не буду подробно останавливаться на том, что это такое, при необходимости могу написать отдельную статью с подробным разъяснением.
Допустим, что мы смогли добиться всех вышеперечисленных рекомендаций — у нас есть множество ouath-клиентов по одному на девайс пользователя, приложение в процессе установки в ОС пользователя проходит процесс регистрации клиента. Все наши клиенты — публичные, то есть процесс авторизации выполняется на стороне клиента, не на шлюзе, как это было в нашем прошлом примере. Как это реализовать — это уже выходит за рамки статьи, рекомендуемый способ — это AppAuth.
Кроме того, нас понадобится отдельный oauth scope. Тем самым мы сможем гарантировать, что с мобильных девайсов пользователь сможет получить доступ к определенным защищенным ресурсам. Назовем его mobile_app
.
Шлюз в нашей архитектуре будет выполнять роль oauth resource server, выполняющего стандартные для oauth resource server проверки — валидация jwt токена и проверка нужной роли, т.е. в нашем случае скоупа mobile_api
.
Клиент проходит процесс авторизации и получает access токен. Для получения доступа к защищенному ресурсу клиент выполняет запрос через шлюз.
Ошибки 401 и 403 можно получить как от шлюза, так и от бэкенда, т.к. и тот и другой являются ресурс-серверами. Проверять роли пользователя на стороне шлюза бессмысленно, так как у каждого бэкенда эти роли могут быть свои и лучше делегировать эту задачу самому бэкенду. Все, что должен проверить шлюз — это скоуп токена, в нашем случае это mobile_app
.
Настройка Keycloak
Создадим клиента. Назовем его mobile-client
.
Будем считать, что мы его зарегистрировали динамически на девайсе. Клиент должен быть публичным (Client authentication выключен):
Далее создадим клиентский скоуп mobile_app
:
Также не забываем указать настройку для включения скоупа в токен.
После того, как скоуп создат назначаем его нашему клиенту:
Для повышения безопасности клиента назначим роль скоупу. Это означает, что данный скоуп будет доступен только пользователям с указанной ролью. Допустим у нас есть роль USER
:
Назначим эту роль сначала пользователю user:
И скоупу:
Настройка шлюза
Все довольно просто. Из стартеров нам понадобится spring-cloud-starter-gateway
и spring-boot-starter-oauth2-resource-server
. Класс конфигурации будет выглядеть так:
@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {
@Bean
SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity httpSecurity) {
return httpSecurity
.authorizeExchange(
authorizeExchange ->
authorizeExchange.pathMatchers(
"/actuator/**")
.permitAll()
.anyExchange()
.hasAuthority("SCOPE_mobile_app")
)
.httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
.oauth2ResourceServer(oauth2ResourceServer ->
oauth2ResourceServer.jwt(Customizer.withDefaults()))
.build();
}
}
У такой конфигурации есть один минус. Используемый для получения и декодирования компонент NimbusReactiveJwtDecoder
не поддерживает spring-кэшрование в отличие от его сервлетного аналога. То есть каждый раз при валидации токена мы будем получать jwks от keycloak. Это может создать довольно серьезную нагрузку на keycloak
. Пока поддержка кэширования в NimbusReactiveJwtDecoder
не добавлена единственное, что могу предложить это использовать локальный ключ. Его можно легко выгрузить из keycloak формате PEM x509
и указать в настройках шлюза:
security:
oauth2:
resourceserver:
jwt:
public-key-location: classpath:key.pub
При этом надо понимать, что в случае компрометации ключа мы получим неприятную ситуацию — шлюз будет пытаться валидировать токены старым ключом, т.е. проверять подпись токена. Скорее всего это маловероятная ситуация, но все-таки помнить об этом нужно.
На этом настройка закончена. По умолчанию spring security выполнит за нас всю работу — JwtGrantedAuthoritiesConverter
извлечет из клэйма scope наш mobile_app и все, что нам останется сделать — это проверить его hasAuthority("SCOPE_mobile_app")
. В application.yaml
указываем:
spring:
application:
name: mobile-api-gateway
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8080/realms/test
cloud:
gateway:
routes:
- id: test-app
uri: http://localhost:8085/
predicates:
- Path=/test/**
- Method= GET
На этот раз никаких фильтров указывать не нужно.
Защищенный ресурс
Как и в прошлый раз будем использовать защищенный ресурс /test
:
@RestController
public class TestController {
@GetMapping("/test")
public String get() {
return "Hello World!";
}
}
И его конфигурация:
@SpringBootApplication
@EnableWebSecurity
public class TestAppApplication {
public static void main(String[] args) {
SpringApplication.run(TestAppApplication.class, args);
}
@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.yam
l:
spring:
application:
name: test-app
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8080/realms/test
Тестируем
Для начала получим токен. Для простоты я использовал запрос с password grant_type
:
curl --location 'http://localhost:8080/realms/test/protocol/openid-connect/token' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'scope=openid' \
--data-urlencode 'username=user' \
--data-urlencode 'password=user' \
--data-urlencode 'grant_type=password' \
--data-urlencode 'client_id=mobile-client'
Полученный токен используем в заголовке Authorization
:
curl --location 'http://localhost:8082/test' \
--header 'Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJvSzNybW9pWkVfRk1FTERwVjVDVTU3QkVpc3lSaUtiRVBtVEU3dUxuSGxBIn0.eyJleHAiOjE3MzY3MTIzODIsImlhdCI6MTczNjcxMjA4MiwianRpIjoiZjUxNzUxODYtNmM3My00ZDk0LWI5NGEtMDk2NWUzYjBiZDdhIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwL3JlYWxtcy90ZXN0IiwiYXVkIjoiYWNjb3VudCIsInN1YiI6ImY5ZWEwMjUxLWVlNDMtNDE2OS1iYjg0LTdmMTBmZmY2ZjAxMiIsInR5cCI6IkJlYXJlciIsImF6cCI6Im1vYmlsZS1jbGllbnQiLCJzaWQiOiJiYjg3ZWRmYS1mMmZlLTRkMzMtYTkzNi02OTZkMDY1ZjFkZTMiLCJhY3IiOiIxIiwiYWxsb3dlZC1vcmlnaW5zIjpbIioiXSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbImRlZmF1bHQtcm9sZXMtdGVzdCIsIm9mZmxpbmVfYWNjZXNzIiwidW1hX2F1dGhvcml6YXRpb24iLCJVU0VSIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJvcGVuaWQgZW1haWwgcHJvZmlsZSBtb2JpbGVfYXBwIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJuYW1lIjoiZmlyc3QgbGFzdCIsInByZWZlcnJlZF91c2VybmFtZSI6InVzZXIiLCJnaXZlbl9uYW1lIjoiZmlyc3QiLCJmYW1pbHlfbmFtZSI6Imxhc3QiLCJlbWFpbCI6InVzZXJAbWFpbC5ydSJ9.uDvHVkEDqvXb2BMyK0qMe_ODBF15rXg3MK07xaoBJ2u4wbITy10Hk_LWCqeCH5cGEBnViuPNXh6ZOlMx0wKwlCLLf9aAMO2nUYbyrbmt4SJhEt0dXKIUdu5KeotAc3_hrNknyBcnBLlOvUbvn-UYFULUc7Z0MhtbZh_4VIOEfNdDMepxvZ1nCieD8UaGKeYrzhTVewxUeZAM-dU2JtuSlkgvhsTksXylnWN9YGPm75A8OWS8L5HVwZUEExyWyYbDBv--pwjTDOtKiyY1S5HZ_LbbBgAKQUzHT7FHtlAwRt1D3AXR2YuAPBC8M8HkplS0LBqFXzBfj_2OIhN2qDdoqw'
Получаем:
Шлюз будет пропускать только запросы с токенами от пользователей с ролью USER
и скоупом mobile_app
. Саму роль проверять не нужно, т.к. keycloak не выдаст скоуп mobile_app
в клэйме scope пользователям без роли USER
. Если токен невалиден, то мы, как и положено получим 401 от шлюза, до бэка такой запрос не дойдет. При отсутствии нужного скоупа в токене мы получим 403 также от шлюза. В данной архитектуре бэк в принципе не может выдать 401 ошибку, но может выдать 403 если, например, пользователю не хватает нужных ролей.
Данная архитектура довольно хорошо показала себя в продакшн-условиях — она надежная и простая, ее легко поддерживать. Код шлюза можно найти репозитории на github, там же есть код бэка. Друзья, буду рад любым вашим комментариям. Не забудьте подписаться на мой телеграм-канал — там действительно много интересного авторского контента.