Данная статья результат поиска некоего каноничного решения организации безопасности доступа к ресурсам в микросервисной архитектуре, построенной в экосистеме Spring. После прочтения десятка статей по данной тематике, к сожалению, не нашел то, что искал. Spring Security оказался одной из самых недопонятых технологий. Основная проблема у всех - изобретение своего велосипеда поверх стандартного функционала Spring Security. Зачастую, данные статьи сопровождаются комментариями никогда так не делать. И у многих, наверно, возникает вопрос, а как собственно можно делать. Ситуацию несколько прояснила официальная документация. Взяв её за основу, я хочу показать, как можно организовать безопасность микросервисов максимально простым и быстрым способом.

Для начала рассмотрим реализуемую схему authorization flow:

Как видно на картинке, дизайн состоит из трех служб: единой точки входящих запросов от пользователей, реализующей Gateway API, IDP сервера (Identity Provider), который аутентифицирует пользователей и выдает токен доступа и сервера ресурсов, который отдает данные. Входящий запрос, не прошедший проверку подлинности, поступает на Gateway и инициирует authorization flow. Gateway делегирует управление учетными записями пользователей и авторизацию IDP серверу. IDP сервер проверяет учетную запись пользователя и возвращает на Gateway токен доступа. Gateway прикрепляет токен к запросу пользователя и отправляет на сервер ресурса. Сервер ресурсов получает от IDP сервера открытый ключ для самостоятельной валидации токена и в случае успешной валидации возвращает запрашиваемые данные. В этой схеме нет ничего необычного, стандартный OAuth 2.0 подход, основная фишка здесь в том, что практически весь этот функционал доступен из коробки и реализуется подключением нужных зависимостей и конфигурированием property, без необходимости писать какой-то сложный код. Далее я приведу пример, как это можно реализовать, создав три соответствующих данной схеме микросервиса. Весь приведенный код доступен на GitHub.

Spring Authorization Server

В качестве Single Sign-On Identity-провайдера я буду использовать Spring Authorization Server, как максимально простой способ поднять сервер авторизации в виде простого Spring Boot приложения, без дополнительных приседаний. При желании, здесь может быть Keyclock или другая сторонняя служба. Для создания сервера нам потребуются следующие зависимости:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.security</groupId>
  <artifactId>spring-security-oauth2-authorization-server</artifactId>
  <version>0.3.1</version>
</dependency>

В application.yml укажем порт:

server:
  port: 9000

Далее мы создадим конфигурацию bean-компонентов Spring специфичных для OAuth.

@Configuration(proxyBeanMethods = false)
public class AuthorizationServerConfig {

    @Bean
    @Order(Ordered.HIGHEST_PRECEDENCE)
    public SecurityFilterChain authServerSecurityFilterChain(HttpSecurity http) throws Exception {
        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
        return http.formLogin(Customizer.withDefaults()).build();
    }

    @Bean
    public RegisteredClientRepository registeredClientRepository() {
        RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
                .clientId("gateway")
                .clientSecret("{noop}secret")
                .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
                .redirectUri("http://127.0.0.1:8080/login/oauth2/code/gateway")
                .scope(OidcScopes.OPENID)
                .scope("resource.read")
                .build();

        return new InMemoryRegisteredClientRepository(registeredClient);
    }

    @Bean
    public JWKSource<SecurityContext> jwkSource() {
        RSAKey rsaKey = generateRsa();
        JWKSet jwkSet = new JWKSet(rsaKey);
        return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
    }

    private static RSAKey generateRsa() {
        KeyPair keyPair = generateRsaKey();
        RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
        RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
        return new RSAKey.Builder(publicKey)
                .privateKey(privateKey)
                .keyID(UUID.randomUUID().toString())
                .build();
    }

    private static KeyPair generateRsaKey() {
        KeyPair keyPair;
        try {
            KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
            keyPairGenerator.initialize(2048);
            keyPair = keyPairGenerator.generateKeyPair();
        } catch (Exception ex) {
            throw new IllegalStateException(ex);
        }
        return keyPair;
    }

    @Bean
    public ProviderSettings providerSettings() {
        return ProviderSettings.builder()
                .issuer("http://localhost:9000")
                .build();
    }
}

В authServerSecurityFilterChain настраиваем bean-компонент для применения безопасности OAuth по умолчанию и создадим страницу входа в форму.

В registeredClientRepository настраиваем репозиторий клиентских сервисов. В нашей архитектуре клиентом будет Spring Cloud Gateway, и соответственно здесь мы задаем интеграцию с ним:

  • Client ID — Spring будет использовать его для определения того, какой клиент пытается получить доступ к ресурсу.

  • Client secret code — секрет, известный клиенту и серверу, который обеспечивает доверие между ними.

  • Authentication method — в нашем случае мы будем использовать обычную аутентификацию, которая представляет собой просто имя пользователя и пароль.

  • Authorization grant type — мы хотим, чтобы клиент мог генерировать как код авторизации, так и токен обновления.

  • Redirect URI — клиент будет использовать его в потоке на основе перенаправления.

  • Scope — этот параметр определяет полномочия, которые может иметь клиент. В нашем случае у нас будет обязательный OidcScopes.OPENID и наш пользовательский resource.read.

В jwkSource настраиваем ключ подписи для токенов для сервера авторизации.

В providerSettings зададим URL-адрес, который провайдер будет использовать в качестве своего идентификатора.

Затем добавим дефолтный конфиг Spring Security:

@EnableWebSecurity
public class DefaultSecurityConfig {

    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeRequests(authorizeRequests ->
                        authorizeRequests
                                .anyRequest()
                                .authenticated()
                )
                .formLogin(withDefaults());
        return http.build();
    }

    @Bean
    UserDetailsService users() {
        UserDetails user = User.withDefaultPasswordEncoder()
                .username("admin")
                .password("password")
                .roles("USER")
                .build();
        return new InMemoryUserDetailsManager(user);
    }

}

Здесь, в defaultSecurityFilterChain мы вызываем uthorizeRequests.anyRequest().authenticated(), чтобы требовать аутентификацию для всех запросов. Мы также предоставляем аутентификацию на основе форм, вызывая метод formLogin(defaults()).

В users мы определим набор пользователей, которых мы будем использовать для тестирования. Для этого примера мы создадим репозиторий только с одним пользователем-администратором.

На этом, с сервером авторизации всё.

Spring Cloud Gateway

Здесь будет происходить самое интересное. Помимо своей стандартной функции маршрутизации входящих запросов, Spring Cloud Gateway будет интегрирован с сервисом авторизации и будет реализовывать механизм Token Reley - как только сервер авторизации передаст шлюзу токен, шлюз помещает его в заголовок запроса к сервису, который проксирует. Для создания сервера нам потребуются следующие зависимости:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.cloud</groupId>
  <artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>

Далее настроим application.yml:

server:
  port: 8080

spring:
  cloud:
    gateway:
      routes:
        - id: resource
          uri: http://127.0.0.1:8090
          predicates:
            - Path=/resource
          filters:
            - TokenRelay=
            - RemoveRequestHeader=Cookie

  security:
    oauth2:
      client:
        registration:
          gateway:
            provider: spring
            client-id: gateway
            client-secret: secret
            authorization-grant-type: authorization_code
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
            scope: openid,resource.read
        provider:
          spring:
            issuer-uri: http://localhost:9000

В секцииspring.cloud.gateway.routes мы задаем конфигурацию маршрута до микросервиса ресурсов. Помимо собственно маршрута, здесь интересны два момента:

  • Фильтр - TokenRelay= задает бин TokenRelayGatewayFilterFactory в качестве фильтра в конфигурации маршрута для нашего сервера ресурсов, который будет осуществлять пересылку токена.

  • Фильтр - RemoveRequestHeader=Cookie сообщает шлюзу удалить за ненадобностью куки из запроса, для получения доступа нам достаточно токена.

В секции spring.oauth2.client.registration мы задаём интеграцию с Identity-провайдером, в нашем случае со Spring Authorization Server. Информация, указанная здесь, будет сопряжена с той, что мы указывали в бине RegisteredClientRepository, когда конфигурировали AuthorizationServerConfig при создании сервера авторизации, поэтому отдельно описывать каждый пункт не буду, они аналогичны. Дополню только, что в issuer-uri мы должны указать адрес нашего Identity-провайдера.

На этом здесь всё, переходим к серверу ресурсов.

Resource Server

Сервер ресурсов будет валидировать токен и отдавать данные по REST. Перейдем к созданию, нам потребуются следующие зависимости:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-web</artifactId>
</dependency>

Далее настроим application.yml:

server:
  port: 8090

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:9000

Здесь мы указываем порт приложения и адрес нашего Identity-провайдера, с которого сервер ресурсов будет получать открытую часть ключа для самостоятельной валидации JWT токена.

Далее настроим WebSecurityConfig:

@EnableWebSecurity
public class ResourceServerConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .mvcMatcher("/resource/**")
                .authorizeRequests()
                .mvcMatchers("/resource/**")
                .access("hasAuthority('SCOPE_resource.read')")
                .and()
                .oauth2ResourceServer()
                .jwt();
        return http.build();
    }

}

Здесь мы указываем, что каждый запрос к ресурсам должен быть авторизован и иметь права resource.read. oauth2ResourceServer() настраивает соединение с Identity-провайдером на основе данных, которые мы указали в application.yml.

Далее создаем REST контроллер:

@RestController
public class ResourceController {

    @GetMapping("/resource")
    public String getResource() {
        return "Resource";
    }
}

Здесь мы будем просто возвращать строку.

На этом всё, теперь запустим и посмотрим как это работает.

Запуск и тестирование:

Откроем браузер и перейдем по ссылке 127.0.0.1:8080/resource. Порт в URL указываем принадлежащий Gateway серверу. После перехода по ссылке нас редиректит на форму ввода логина и пароля:

После ввода логина и пароля у нас происходит успешная авторизация и мы получаем данные:

На этом всё, надеюсь, было полезно, если есть какие-то замечания - пишите.

Для подготовки статьи использовались следующие материалы:

Ссылка проекта на GitHub

Комментарии (6)


  1. Usul
    28.11.2022 06:25

    А где Gateway хранит токен? Сохраняет в сессию в БД? Или помещает в куку, которую отдает пользователю?


    1. kish4ever
      28.11.2022 09:22

      Клиент хранит его сам у себя. Правильный флоу для api это не использовать куки, иначе привет CSRF


      1. Usul
        28.11.2022 10:33

        Клиентом в данной ситуации является Gateway. У него есть и id, и secret, и получение токена идет через back-channel. Токен он, судя по диаграмме, на сторону UserAgent не отдает, а пользуется им от имени resource owner. Ну и кука, как я понял, здесь используется (`RemoveRequestHeader=Cookie `)...


    1. Arjun7682 Автор
      29.11.2022 10:50
      +1

      В приведенном примере же видно, что нет никой БД, и куки на гейте удаляются. Я думаю, что оно вообще stateless, ничего не хранит, а только ретранслирует. В официальной документации не припоминаю, чтобы про хранение что-то было. Дословно, как это работает, там так: "The filter extracts an access token from the currently authenticated user, and puts it in a request header for the downstream requests."


      1. Usul
        29.11.2022 13:07

        "The filter extracts an access token from the currently authenticated user" - извлекает из куки (ранее созданной самим Gateway)? Или gateway отдает пользователю токен в незашифрованном виде, а UserAgent сам хранит этот токен и передает на gateway через заголовок "Authorization: Bearer ..."?

        И спасибо за статью. Я сам не джавист, было интересно посмотреть, как у вас реализуется auth. Выглядит очень удобно: gateway с минимумом кода - круть!


        1. Invis1ble
          01.12.2022 00:27

          Сам filter никак этим не управляет, он лишь достает Authentication из security context. А тем, как этот токен хранится, управляет ServerSecurityContextRepository.

          Из коробки, в spring есть всего одна реализация (кроме noop): WebSessionServerSecurityContextRepository.

          Для хранения сессий есть отдельный проект spring-session, но по умолчанию in-memory, а клиенту возвращается cookie с id сессии.

          Таким образом, нужно обязательно учитывать (например при настройке политики балансировки на внешнем балансировщике), что gateway stateful.

          Реализовать хранение токена (или даже всей сессии) в cookies несложно самому. На это даже висит issue, но ее не закрывают, как я понимаю, по каким-то религиозным причинам.