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.yaml:

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, там же есть код бэка. Друзья, буду рад любым вашим комментариям. Не забудьте подписаться на мой телеграм-канал – там действительно много интересного авторского контента.


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


Комментарии

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

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