В статье будет рассмотрен способ организации инфраструктуры 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, там же есть код бэка. Друзья, буду рад любым вашим комментариям. Не забудьте подписаться на мой телеграм-канал – там действительно много интересного авторского контента.
ссылка на оригинал статьи https://habr.com/ru/articles/873148/
Добавить комментарий