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.

0b5c66a2928274f907390bf3bd7e35b0.png

Кроме того, нас понадобится отдельный oauth scope. Тем самым мы сможем гарантировать, что с мобильных девайсов пользователь сможет получить доступ к определенным защищенным ресурсам. Назовем его mobile_app.

Шлюз в нашей архитектуре будет выполнять роль oauth resource server, выполняющего стандартные для oauth resource server проверки — валидация jwt токена и проверка нужной роли, т.е. в нашем случае скоупа mobile_api.

Клиент проходит процесс авторизации и получает access токен. Для получения доступа к защищенному ресурсу клиент выполняет запрос через шлюз.

bb40f5087800e29a858243f282dce923.png

Ошибки 401 и 403 можно получить как от шлюза, так и от бэкенда, т.к. и тот и другой являются ресурс-серверами. Проверять роли пользователя на стороне шлюза бессмысленно, так как у каждого бэкенда эти роли могут быть свои и лучше делегировать эту задачу самому бэкенду. Все, что должен проверить шлюз — это скоуп токена, в нашем случае это mobile_app.

Настройка Keycloak

Создадим клиента. Назовем его mobile-client.

a8eb37b06a50b903053a3155c47b75a2.jpg

Будем считать, что мы его зарегистрировали динамически на девайсе. Клиент должен быть публичным (Client authentication выключен):

f810579119a7485cfdb2da342ace6d80.jpg

Далее создадим клиентский скоуп mobile_app:

56401e50ad45006f74057ddeb00d3fba.jpg

Также не забываем указать настройку для включения скоупа в токен.

После того, как скоуп создат назначаем его нашему клиенту:

5933e282d886c0aa3d6b83a083fe57ac.jpg

Для повышения безопасности клиента назначим роль скоупу. Это означает, что данный скоуп будет доступен только пользователям с указанной ролью. Допустим у нас есть роль USER:

f57d10a25e168cf8d101d6414f25dd83.jpg

Назначим эту роль сначала пользователю user:

b03fb64d72741412948949ec54174ed9.jpg

И скоупу:

88f32612b331ff42f2e4e75587f87218.jpg

Настройка шлюза

Все довольно просто. Из стартеров нам понадобится 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'

Получаем:

c0b73a5a4ff6b81b6a90d77a379b30e9.jpg

Шлюз будет пропускать только запросы с токенами от пользователей с ролью USER и скоупом mobile_app. Саму роль проверять не нужно, т.к. keycloak не выдаст скоуп mobile_app в клэйме scope пользователям без роли USER. Если токен невалиден, то мы, как и положено получим 401 от шлюза, до бэка такой запрос не дойдет. При отсутствии нужного скоупа в токене мы получим 403 также от шлюза. В данной архитектуре бэк в принципе не может выдать 401 ошибку, но может выдать 403 если, например, пользователю не хватает нужных ролей.

Данная архитектура довольно хорошо показала себя в продакшн-условиях — она надежная и простая, ее легко поддерживать.  Код шлюза можно найти репозитории на github, там же есть код бэка. Друзья, буду рад любым вашим комментариям. Не забудьте подписаться на мой телеграм-канал — там действительно много интересного авторского контента.

© Habrahabr.ru