Данная статья результат поиска некоего каноничного решения организации безопасности доступа к ресурсам в микросервисной архитектуре, построенной в экосистеме 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 серверу. После перехода по ссылке нас редиректит на форму ввода логина и пароля:
После ввода логина и пароля у нас происходит успешная авторизация и мы получаем данные:
На этом всё, надеюсь, было полезно, если есть какие-то замечания - пишите.
Для подготовки статьи использовались следующие материалы:
Usul
А где Gateway хранит токен? Сохраняет в сессию в БД? Или помещает в куку, которую отдает пользователю?
kish4ever
Клиент хранит его сам у себя. Правильный флоу для api это не использовать куки, иначе привет CSRF
Usul
Клиентом в данной ситуации является Gateway. У него есть и id, и secret, и получение токена идет через back-channel. Токен он, судя по диаграмме, на сторону UserAgent не отдает, а пользуется им от имени resource owner. Ну и кука, как я понял, здесь используется (`
RemoveRequestHeader=Cookie
`)...Arjun7682 Автор
В приведенном примере же видно, что нет никой БД, и куки на гейте удаляются. Я думаю, что оно вообще stateless, ничего не хранит, а только ретранслирует. В официальной документации не припоминаю, чтобы про хранение что-то было. Дословно, как это работает, там так: "The filter extracts an access token from the currently authenticated user, and puts it in a request header for the downstream requests."
Usul
"The filter extracts an access token from the currently authenticated user" - извлекает из куки (ранее созданной самим Gateway)? Или gateway отдает пользователю токен в незашифрованном виде, а UserAgent сам хранит этот токен и передает на gateway через заголовок "Authorization: Bearer ..."?
И спасибо за статью. Я сам не джавист, было интересно посмотреть, как у вас реализуется auth. Выглядит очень удобно: gateway с минимумом кода - круть!
Invis1ble
Сам filter никак этим не управляет, он лишь достает Authentication из security context. А тем, как этот токен хранится, управляет ServerSecurityContextRepository.
Из коробки, в spring есть всего одна реализация (кроме noop): WebSessionServerSecurityContextRepository.
Для хранения сессий есть отдельный проект spring-session, но по умолчанию in-memory, а клиенту возвращается cookie с id сессии.
Таким образом, нужно обязательно учитывать (например при настройке политики балансировки на внешнем балансировщике), что gateway stateful.
Реализовать хранение токена (или даже всей сессии) в cookies несложно самому. На это даже висит issue, но ее не закрывают, как я понимаю, по каким-то религиозным причинам.