Данная статья результат поиска некоего каноничного решения организации безопасности доступа к ресурсам в микросервисной архитектуре, построенной в экосистеме 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 серверу. После перехода по ссылке нас редиректит на форму ввода логина и пароля:

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

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