Перейти к основному содержимому

Spring WebClient и поддержка OAuth2

· 12 мин. чтения

1. Обзор

Spring Security 5 обеспечивает поддержку OAuth2 для неблокирующего класса WebClient Spring Webflux .

В этом руководстве мы проанализируем различные подходы к доступу к защищенным ресурсам с помощью этого класса.

Кроме того, мы заглянем под капот, чтобы понять, как Spring обрабатывает процесс авторизации OAuth2.

2. Настройка сценария

В соответствии со спецификацией OAuth2 , помимо нашего клиента, который является предметом нашего внимания в этой статье, нам, естественно, нужны сервер авторизации и сервер ресурсов.

Мы можем использовать известных поставщиков авторизации, таких как Google или Github. Чтобы лучше понять роль клиента OAuth2, мы также можем использовать наши собственные серверы, реализация которых доступна здесь . Мы не будем показывать полную конфигурацию, так как это не тема данного руководства, достаточно знать, что:

  • Сервер авторизации будет:

  • работает на порту 8081

  • предоставление конечных точек / oauth /authorize, /oauth/token и oauth/check_token для выполнения желаемой функциональности

  • сконфигурирован с примерами пользователей (например, john / 123 ) и одним клиентом OAuth ( fooClientIdPassword / secret )

  • Сервер ресурсов будет отделен от Сервера аутентификации и будет:

  • работает на порту 8082

  • обслуживание простого защищенного ресурса объекта Foo , доступного с помощью конечной точки /foos/{id}

Примечание: важно понимать, что несколько проектов Spring предлагают различные функции и реализации, связанные с OAuth. Мы можем изучить, что предоставляет каждая библиотека в этой матрице Spring Projects .

WebClient и все реактивные функции, связанные с Webflux , являются частью проекта Spring Security 5. Поэтому в этой статье мы в основном будем использовать эту структуру.

3. Spring Security 5 под капотом

Чтобы полностью понять предстоящие примеры, полезно знать, как Spring Security внутренне управляет функциями OAuth2.

Эта структура предлагает возможности для:

  • полагаться на учетную запись поставщика OAuth2 для входа пользователей в приложение
  • настроить наш сервис как клиент OAuth2
  • управлять процедурами авторизации для нас
  • автоматически обновлять токены
  • сохранить учетные данные, если это необходимо

Некоторые из фундаментальных концепций мира OAuth2 Spring Security описаны на следующей диаграмме:

./5d3021ded7f969b4f8bb9134bac0b68a.png

3.1. Провайдеры

Spring определяет роль поставщика OAuth2, отвечающую за предоставление защищенных ресурсов OAuth 2.0.

В нашем примере наша служба аутентификации будет предлагать возможности провайдера.

3.2. Регистрация клиентов

ClientRegistration — это объект, содержащий всю необходимую информацию о конкретном клиенте, зарегистрированном в поставщике OAuth2 (или OpenID) .

В нашем сценарии это будет клиент, зарегистрированный на сервере аутентификации, идентифицируемый идентификатором bael-client- id.

3.3. Авторизованные клиенты

Как только конечный пользователь (также известный как владелец ресурса) предоставляет клиенту разрешения на доступ к его ресурсам, создается объект OAuth2AuthorizedClient .

Он будет отвечать за связывание токенов доступа с регистрацией клиентов и владельцами ресурсов (представленными объектами Principal ).

3.4. Репозитории

Кроме того, Spring Security также предлагает классы репозитория для доступа к упомянутым выше объектам.

В частности, классы ReactiveClientRegistrationRepository и ServerOAuth2AuthorizedClientRepository используются в реактивных стеках и по умолчанию используют хранилище в памяти.

Spring Boot 2.x создает bean-компоненты этих классов репозитория и автоматически добавляет их в контекст.

3.5. Сеть веб-фильтров безопасности

Одной из ключевых концепций Spring Security 5 является реактивная сущность SecurityWebFilterChain .

Как видно из его названия, он представляет собой связанную коллекцию объектов WebFilter .

Когда мы включаем функции OAuth2 в нашем приложении, Spring Security добавляет в цепочку два фильтра:

  1. Один фильтр отвечает на запросы авторизации ( URI /oauth2/authorization/{registrationId} ) или генерирует ClientAuthorizationRequiredException . Он содержит ссылку на ReactiveClientRegistrationRepository и отвечает за создание запроса авторизации для перенаправления пользовательского агента.
  2. Второй фильтр зависит от того, какую функцию мы добавляем (возможности клиента OAuth2 или функции входа OAuth2). В обоих случаях основной обязанностью этого фильтра является создание экземпляра OAuth2AuthorizedClient и его сохранение с помощью ServerOAuth2AuthorizedClientRepository.

3.6. Веб-клиент

Веб-клиент будет настроен с функцией ExchangeFilterFunction , содержащей ссылки на репозитории.

Он будет использовать их для получения токена доступа, чтобы автоматически добавить его в запрос.

4. Поддержка Spring Security 5 — поток учетных данных клиента

Spring Security позволяет настроить наше приложение как клиент OAuth2.

В этой статье мы будем использовать экземпляр WebClient для получения ресурсов, сначала используя тип гранта «Учетные данные клиента» `` , а затем используя поток «Код авторизации».

Первое, что нам нужно сделать, это настроить регистрацию клиента и поставщика, который мы будем использовать для получения токена доступа.

4.1. Конфигурации клиента и провайдера

Как мы видели в статье OAuth2 Login , мы можем либо настроить его программно, либо полагаться на автоматическую настройку Spring Boot, используя свойства для определения нашей регистрации:

spring.security.oauth2.client.registration.bael.authorization-grant-type=client_credentials
spring.security.oauth2.client.registration.bael.client-id=bael-client-id
spring.security.oauth2.client.registration.bael.client-secret=bael-secret

spring.security.oauth2.client.provider.bael.token-uri=http://localhost:8085/oauth/token

Это все конфигурации, которые нам нужны для получения ресурса с помощью потока client_credentials .

4.2. Использование веб- клиента

Мы используем этот тип гранта в межмашинных взаимодействиях, когда конечный пользователь не взаимодействует с нашим приложением.

Например, давайте представим, что у нас есть задание cron , пытающееся получить защищенный ресурс с помощью WebClient в нашем приложении:

@Autowired
private WebClient webClient;

@Scheduled(fixedRate = 5000)
public void logResourceServiceResponse() {

webClient.get()
.uri("http://localhost:8084/retrieve-resource")
.retrieve()
.bodyToMono(String.class)
.map(string
-> "Retrieved using Client Credentials Grant Type: " + string)
.subscribe(logger::info);
}

4.3. Настройка веб- клиента

Затем давайте установим экземпляр webClient , который мы автоматически подключили в нашей запланированной задаче:

@Bean
WebClient webClient(ReactiveClientRegistrationRepository clientRegistrations) {
ServerOAuth2AuthorizedClientExchangeFilterFunction oauth =
new ServerOAuth2AuthorizedClientExchangeFilterFunction(
clientRegistrations,
new UnAuthenticatedServerOAuth2AuthorizedClientRepository());
oauth.setDefaultClientRegistrationId("bael");
return WebClient.builder()
.filter(oauth)
.build();
}

Как мы уже говорили, репозиторий регистрации клиентов автоматически создается и добавляется в контекст Spring Boot.

Следующее, на что следует обратить внимание, это то, что мы используем экземпляр UnAuthenticatedServerOAuth2AuthorizedClientRepository . Это связано с тем, что ни один конечный пользователь не будет принимать участие в процессе, поскольку это межмашинное взаимодействие. Наконец, мы заявили, что будем использовать регистрацию клиента bael по умолчанию.

В противном случае нам пришлось бы указать его к тому времени, когда мы определяем запрос в задании cron:

webClient.get()
.uri("http://localhost:8084/retrieve-resource")
.attributes(
ServerOAuth2AuthorizedClientExchangeFilterFunction
.clientRegistrationId("bael"))
.retrieve()
// ...

4.4. Тестирование

Если мы запустим наше приложение с включенным уровнем ведения журнала DEBUG , мы сможем увидеть вызовы, которые Spring Security делает для нас:

o.s.w.r.f.client.ExchangeFunctions:
HTTP POST http://localhost:8085/oauth/token
o.s.http.codec.json.Jackson2JsonDecoder:
Decoded [{access_token=89cf72cd-183e-48a8-9d08-661584db4310,
token_type=bearer,
expires_in=41196,
scope=read
(truncated)...]
o.s.w.r.f.client.ExchangeFunctions:
HTTP GET http://localhost:8084/retrieve-resource
o.s.core.codec.StringDecoder:
Decoded "This is the resource!"
c.b.w.c.service.WebClientChonJob:
We retrieved the following resource using Client Credentials Grant Type: This is the resource!

Мы также заметим, что при втором запуске задачи приложение запрашивает ресурс, не запрашивая сначала токен, поскольку срок действия последнего еще не истек.

5. Поддержка Spring Security 5 — реализация с использованием потока кода авторизации

Этот тип гранта обычно используется в тех случаях, когда менее надежным сторонним приложениям требуется доступ к ресурсам.

5.1. Конфигурации клиента и провайдера

Чтобы выполнить процесс OAuth2 с использованием потока кода авторизации, нам потребуется определить еще несколько свойств для регистрации нашего клиента и поставщика:

spring.security.oauth2.client.registration.bael.client-name=bael
spring.security.oauth2.client.registration.bael.client-id=bael-client-id
spring.security.oauth2.client.registration.bael.client-secret=bael-secret
spring.security.oauth2.client.registration.bael
.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.bael
.redirect-uri=http://localhost:8080/login/oauth2/code/bael

spring.security.oauth2.client.provider.bael.token-uri=http://localhost:8085/oauth/token
spring.security.oauth2.client.provider.bael
.authorization-uri=http://localhost:8085/oauth/authorize
spring.security.oauth2.client.provider.bael.user-info-uri=http://localhost:8084/user
spring.security.oauth2.client.provider.bael.user-name-attribute=name

Помимо свойств, которые мы использовали в предыдущем разделе, на этот раз нам также нужно включить:

  • Конечная точка для аутентификации на сервере аутентификации
  • URL-адрес конечной точки, содержащей информацию о пользователе.
  • URL-адрес конечной точки в нашем приложении, на которую будет перенаправлен пользовательский агент после аутентификации.

Разумеется, для известных провайдеров первые два пункта указывать не нужно.

Конечная точка перенаправления создается Spring Security автоматически.

По умолчанию для него настроен URL-адрес /[action]/oauth2/code/[registrationId], с разрешенными только действиями авторизации и входа (во избежание бесконечного цикла).

Эта конечная точка отвечает за:

  • получение кода аутентификации в качестве параметра запроса
  • используя его для получения токена доступа
  • создание экземпляра авторизованного клиента
  • перенаправление пользовательского агента обратно в исходную конечную точку

5.2. Конфигурации безопасности HTTP

Далее нам нужно настроить SecurityWebFilterChain.

Наиболее распространенным сценарием является использование возможностей входа OAuth2 Spring Security для аутентификации пользователей и предоставления им доступа к нашим конечным точкам и ресурсам.

Если это наш случай, то простого включения директивы oauth2Login в определение ServerHttpSecurity будет достаточно, чтобы наше приложение также работало как клиент OAuth2:

@Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
http.authorizeExchange()
.anyExchange()
.authenticated()
.and()
.oauth2Login();
return http.build();
}

5.3. Настройка веб- клиента

Теперь пришло время установить наш экземпляр WebClient :

@Bean
WebClient webClient(
ReactiveClientRegistrationRepository clientRegistrations,
ServerOAuth2AuthorizedClientRepository authorizedClients) {
ServerOAuth2AuthorizedClientExchangeFilterFunction oauth =
new ServerOAuth2AuthorizedClientExchangeFilterFunction(
clientRegistrations,
authorizedClients);
oauth.setDefaultOAuth2AuthorizedClient(true);
return WebClient.builder()
.filter(oauth)
.build();
}

На этот раз мы внедряем как репозиторий регистрации клиентов, так и авторизованный репозиторий клиентов из контекста.

Мы также включаем параметр setDefaultOAuth2AuthorizedClient . С его помощью платформа попытается получить информацию о клиенте из текущего объекта аутентификации , управляемого в Spring Security.

Мы должны учитывать, что при этом все HTTP-запросы будут включать токен доступа, что может быть нежелательным.

Позже мы проанализируем альтернативы для указания клиента, который будет использовать конкретная транзакция WebClient .

5.4. Использование веб- клиента

Для кода авторизации требуется пользовательский агент, который может выполнять перенаправления (например, браузер) для выполнения процедуры.

Поэтому мы используем этот тип гранта, когда пользователь взаимодействует с нашим приложением, обычно вызывая конечную точку HTTP:

@RestController
public class ClientRestController {

@Autowired
WebClient webClient;

@GetMapping("/auth-code")
Mono<String> useOauthWithAuthCode() {
Mono<String> retrievedResource = webClient.get()
.uri("http://localhost:8084/retrieve-resource")
.retrieve()
.bodyToMono(String.class);
return retrievedResource.map(string ->
"We retrieved the following resource using Oauth: " + string);
}
}

5.5. Тестирование

Наконец, мы вызовем конечную точку и проанализируем, что происходит, проверив записи журнала.

После того, как мы вызываем конечную точку, приложение проверяет, что мы еще не аутентифицированы в приложении:

o.s.w.s.adapter.HttpWebHandlerAdapter: HTTP GET "/auth-code"
...
HTTP/1.1 302 Found
Location: /oauth2/authorization/bael

Приложение перенаправляется на конечную точку службы авторизации для аутентификации с использованием учетных данных, существующих в реестрах провайдера (в нашем случае мы будем использовать bael-user/bael-password ):

HTTP/1.1 302 Found
Location: http://localhost:8085/oauth/authorize
?response_type=code
&client_id=bael-client-id
&state=...
&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Flogin%2Foauth2%2Fcode%2Fbael

После аутентификации пользовательский агент отправляется обратно на URI перенаправления вместе с кодом в качестве параметра запроса и значением состояния, которое было отправлено первым (во избежание атак CSRF ):

o.s.w.s.adapter.HttpWebHandlerAdapter:HTTP GET "/login/oauth2/code/bael?code=...&state=...

Затем приложение использует код для получения токена доступа:

o.s.w.r.f.client.ExchangeFunctions:HTTP POST http://localhost:8085/oauth/token

Он получает информацию о пользователях:

o.s.w.r.f.client.ExchangeFunctions:HTTP GET http://localhost:8084/user

И он перенаправляет пользовательский агент на исходную конечную точку:

HTTP/1.1 302 Found
Location: /auth-code

Наконец, наш экземпляр WebClient может успешно запросить защищенный ресурс:

o.s.w.r.f.client.ExchangeFunctions:HTTP GET http://localhost:8084/retrieve-resource
o.s.w.r.f.client.ExchangeFunctions:Response 200 OK
o.s.core.codec.StringDecoder :Decoded "This is the resource!"

6. Альтернатива – регистрация клиента в звонке

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

Если мы удалим эту команду из конфигурации, нам нужно будет явно указать регистрацию клиента к моменту определения запроса.

Одним из способов, конечно же, является использование clientRegistrationId , как мы это делали раньше при работе с потоком учетных данных клиента.

Поскольку мы связали принципала с авторизованными клиентами, мы можем получить экземпляр OAuth2AuthorizedClient , используя аннотацию @RegisteredOAuth2AuthorizedClient :

@GetMapping("/auth-code-annotated")
Mono<String> useOauthWithAuthCodeAndAnnotation(
@RegisteredOAuth2AuthorizedClient("bael") OAuth2AuthorizedClient authorizedClient) {
Mono<String> retrievedResource = webClient.get()
.uri("http://localhost:8084/retrieve-resource")
.attributes(
ServerOAuth2AuthorizedClientExchangeFilterFunction.oauth2AuthorizedClient(authorizedClient))
.retrieve()
.bodyToMono(String.class);
return retrievedResource.map(string ->
"Resource: " + string
+ " - Principal associated: " + authorizedClient.getPrincipalName()
+ " - Token will expire at: " + authorizedClient.getAccessToken()
.getExpiresAt());
}

7. Избегайте функций входа OAuth2

Как мы уже говорили, наиболее распространенным сценарием является использование поставщика авторизации OAuth2 для входа пользователей в наше приложение.

Но что, если мы хотим избежать этого, но при этом иметь доступ к защищенным ресурсам по протоколу OAuth2? Затем нам нужно будет внести некоторые изменения в нашу конфигурацию.

Для начала и просто для ясности по всем направлениям мы можем использовать действие авторизации вместо входа в систему при определении свойства URI перенаправления:

spring.security.oauth2.client.registration.bael
.redirect-uri=http://localhost:8080/login/oauth2/code/bael

Мы также можем удалить свойства, связанные с пользователем, поскольку мы не будем использовать их для создания принципала в нашем приложении.

Теперь мы настроим SecurityWebFilterChain без включения команды oauth2Login , а вместо этого включим команду oauth2Client .

Несмотря на то, что мы не хотим полагаться на логин OAuth2, мы все же хотим аутентифицировать пользователей перед доступом к нашей конечной точке. По этой причине мы также включим сюда директиву formLogin :

@Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
http.authorizeExchange()
.anyExchange()
.authenticated()
.and()
.oauth2Client()
.and()
.formLogin();
return http.build();
}

Давайте теперь запустим приложение и посмотрим, что происходит, когда мы используем конечную точку с аннотацией /auth-code-annotated .

Сначала нам нужно войти в наше приложение, используя форму входа.

После этого приложение перенаправит нас на логин службы авторизации, чтобы предоставить доступ к нашим ресурсам.

Примечание: после этого мы должны быть перенаправлены обратно к исходной конечной точке, которую мы вызывали. Тем не менее, Spring Security, похоже, вместо этого перенаправляет обратно на корневой путь «/», что кажется ошибкой. Следующие запросы после запуска танца OAuth2 будут выполняться успешно.

В ответе конечной точки мы видим, что авторизованный клиент на этот раз связан с принципалом с именем bael-client-id вместо bael-user, названного в честь пользователя, настроенного в службе аутентификации.

8. Поддержка Spring Framework — ручной подход

По умолчанию Spring 5 предоставляет только один метод службы, связанный с OAuth2, для простого добавления заголовка токена Bearer в запрос. Это метод HttpHeaders#setBearerAuth .

Теперь мы рассмотрим пример, чтобы понять, что потребуется, чтобы получить наш защищенный ресурс, выполнив танец OAuth2 вручную.

Проще говоря, нам нужно связать два HTTP-запроса: один для получения токена аутентификации с сервера авторизации, а другой — для получения ресурса с использованием этого токена:

@Autowired
WebClient client;

public Mono<String> obtainSecuredResource() {
String encodedClientData =
Base64Utils.encodeToString("bael-client-id:bael-secret".getBytes());
Mono<String> resource = client.post()
.uri("localhost:8085/oauth/token")
.header("Authorization", "Basic " + encodedClientData)
.body(BodyInserters.fromFormData("grant_type", "client_credentials"))
.retrieve()
.bodyToMono(JsonNode.class)
.flatMap(tokenResponse -> {
String accessTokenValue = tokenResponse.get("access_token")
.textValue();
return client.get()
.uri("localhost:8084/retrieve-resource")
.headers(h -> h.setBearerAuth(accessTokenValue))
.retrieve()
.bodyToMono(String.class);
});
return resource.map(res ->
"Retrieved the resource using a manual approach: " + res);
}

Этот пример в основном предназначен для того, чтобы понять, насколько громоздким может быть использование запроса, соответствующего спецификации OAuth2, и увидеть, как используется метод setBearerAuth .

В реальном сценарии мы бы позволили Spring Security позаботиться обо всей тяжелой работе за нас прозрачным образом, как мы это делали в предыдущих разделах.

9. Заключение

В этом руководстве мы увидели, как настроить наше приложение в качестве клиента OAuth2 и, в частности, как настроить и использовать WebClient для получения защищенного ресурса в полностью реактивном стеке.

И последнее, но не менее важное: мы проанализировали, как механизмы Spring Security 5 OAuth2 работают под капотом, чтобы соответствовать спецификации OAuth2.

Как всегда, полный пример доступен на Github .