Поддръжка на Spring WebClient и OAuth2

1. Общ преглед

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

В този урок ще анализираме различни подходи за достъп до защитени ресурси с помощта на този клас.

Освен това ще надникнем под капака, за да разберем как Spring се справя с процеса на оторизиране на OAuth2.

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

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

Можем да използваме добре познати доставчици на оторизации като Google или Github. За да разберем по-добре ролята на клиента OAuth2, можем да използваме и нашите собствени сървъри, с приложение, налично тук. Няма да показваме пълната конфигурация, тъй като това не е темата на този урок, достатъчно е да знаете, че:

  • сървърът за оторизация ще бъде:
    • работи на порт 8081
    • излагане на / oauth / разрешение, / oauth / token и oauth / check_token крайни точки за изпълнение на желаната функционалност
    • конфигуриран с примерни потребители (напр. john / 123 ) и един OAuth клиент ( fooClientIdPassword / secret )
  • ресурсният сървър ще бъде отделен от сървъра за удостоверяване и ще бъде:
    • работи на порт 8082
    • обслужващ прост обект Foo обезопасен ресурс, достъпен с помощта на / foos / {id} крайна точка

Забележка: Важно е да разберете, че няколко пролетни проекта предлагат различни функции и реализации, свързани с OAuth. Можем да проучим какво предоставя всяка библиотека в тази матрица на Пролетни проекти.

В WebClient и всички свързани с тях реактивен Webflux функционалност е част от проекта Пролет Security 5. Следователно ще използваме основно тази рамка в цялата статия.

3. Spring Security 5 Под капака

За да разберем напълно примерите, които предстоят, е добре да знаете как Spring Security управлява вътрешно функциите на OAuth2.

Тази рамка предлага възможности за:

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

Някои от фундаменталните концепции на света OAuth2 на Spring Security са описани в следната диаграма:

3.1. Доставчици

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

В нашия пример, нашата Удостоверителна услуга ще бъде тази, която предлага възможностите на Доставчика.

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

А ClientRegistration е юридическо лице, съдържащ цялата необходима информация на конкретен клиент, регистрирани в OAuth2 (или OpenID) доставчик.

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

3.3. Оторизирани клиенти

След като крайният потребител (известен още като Собственик на ресурс) дава разрешения на клиента за достъп до неговите ресурси, се създава обект OAuth2AuthorizedClient .

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

3.4. Хранилища

Освен това Spring Security предлага и класове на хранилища за достъп до споменатите по-горе обекти.

По-специално класовете ReactiveClientRegistrationRepository и ServerOAuth2AuthorizedClientRepository се използват в реактивни стекове и те използват паметта в паметта по подразбиране.

Spring Boot 2.x създава компоненти от тези класове хранилища и ги добавя автоматично към контекста.

3.5. Верига за уеб филтри за сигурност

Една от ключовите концепции в Spring Security 5 е реактивният обект SecurityWebFilterChain .

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

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

  1. Един филтър отговаря на заявки за упълномощаване ( URI / oauth2 / auth / {registrationId} ) или изхвърля ClientAuthorizationRequiredException . Той съдържа препратка към ReactiveClientRegistrationRepository и отговаря за създаването на заявка за оторизация за пренасочване на потребителския агент.
  2. Вторият филтър се различава в зависимост от това коя функция добавяме (възможности на OAuth2 Client или функционалността OAuth2 Login). И в двата случая основната отговорност на този филтър е да създаде екземпляр OAuth2AuthorizedClient и да го съхрани с помощта на ServerOAuth2AuthorizedClientRepository.

3.6. Уеб клиент

Уеб клиентът ще бъде конфигуриран с ExchangeFilterFunction, съдържащ препратки към хранилищата.

Ще ги използва, за да получи токена за достъп, за да го добави автоматично към заявката.

4. Поддръжка на Spring Security 5 - поток от идентификационни данни на клиента

Spring Security позволява да конфигурираме нашето приложение като клиент на OAuth2.

В този запис ще използваме екземпляр на WebClient за извличане на ресурси с помощта на „Клиентски идентификационни данни“първо предоставете типа на предоставяне и след това използвайте потока „Код за разрешаване“.

Първото нещо, което ще трябва да направим, е да конфигурираме регистрацията на клиента и доставчика, който ще използваме, за да получим маркера за достъп.

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

As we've seen in the OAuth2 Login article, we can either configure it programmatically or rely on the Spring Boot auto-configuration by using properties to define our registration:

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=//localhost:8085/oauth/token

These are all the configurations that we need to retrieve the resource using the client_credentials flow.

4.2. Using the WebClient

We use this grant type in machine-to-machine communications where there's no end-user interacting with our application.

For example, let's imagine we have a cron job trying to obtain a secured resource using a WebClient in our application:

@Autowired private WebClient webClient; @Scheduled(fixedRate = 5000) public void logResourceServiceResponse() { webClient.get() .uri("//localhost:8084/retrieve-resource") .retrieve() .bodyToMono(String.class) .map(string -> "Retrieved using Client Credentials Grant Type: " + string) .subscribe(logger::info); }

4.3. Configuring the WebClient

Next, let's set the webClient instance that we've autowired in our scheduled task:

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

As we said, the client registration repository is automatically created and added to the context by Spring Boot.

The next thing to notice here is that we're using a UnAuthenticatedServerOAuth2AuthorizedClientRepository instance. This is due to the fact that no end-user will take part in the process since it's a machine-to-machine communication. Finally, we stated that we'd use the bael client registration by default.

Otherwise, we'd have to specify it by the time we define the request in the cron job:

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

4.4. Testing

If we run our application with the DEBUG logging level enabled, we'll be able to see the calls that Spring Security is doing for us:

o.s.w.r.f.client.ExchangeFunctions: HTTP POST //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 //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!

We'll also notice that the second time the task runs, the application requests the resource without asking for a token first since the last one hasn't expired.

5. Spring Security 5 Support – Implementation Using the Authorization Code Flow

This grant type is usually used in cases where less-trusted third-party applications need to access resources.

5.1. Client and Provider Configurations

In order to execute the OAuth2 process using the Authorization Code flow, we'll need to define several more properties for our client registration and the provider:

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=//localhost:8080/login/oauth2/code/bael spring.security.oauth2.client.provider.bael.token-uri=//localhost:8085/oauth/token spring.security.oauth2.client.provider.bael .authorization-uri=//localhost:8085/oauth/authorize spring.security.oauth2.client.provider.bael.user-info-uri=//localhost:8084/user spring.security.oauth2.client.provider.bael.user-name-attribute=name

Apart from the properties, we used in the previous section, this time we also need to include:

  • An endpoint to authenticate on the Authentication Server
  • The URL of an endpoint containing user information
  • The URL of an endpoint in our application to which the user-agent will be redirected after authenticating

Of course, for well-known providers, the first two points don't need to be specified.

The redirect endpoint is created automatically by Spring Security.

By default, the URL configured for it is /[action]/oauth2/code/[registrationId], with only authorize and login actions permitted (in order to avoid an infinite loop).

This endpoint is in charge of:

  • receiving the authentication code as a query param
  • using it to obtain an access token
  • creating the Authorized Client instance
  • redirecting the user-agent back to the original endpoint

5.2. HTTP Security Configurations

Next, we'll need to configure the SecurityWebFilterChain.

The most common scenario is using Spring Security's OAuth2 Login capabilities to authenticate users and give them access to our endpoints and resources.

If that's our case, then just including the oauth2Login directive in the ServerHttpSecurity definition will be enough for our application to work as an OAuth2 Client too:

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

5.3. Configuring the WebClient

Now it's time to put in place our WebClient instance:

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

This time we're injecting both the client registration repository and the authorized client repository from the context.

We're also enabling the setDefaultOAuth2AuthorizedClient option. With it, the framework will try to obtain the client information from the current Authentication object managed in Spring Security.

We have to take into account that with it, all HTTP requests will include the access token, which might not be the desired behavior.

Later we'll analyze alternatives to indicate the client that a specific WebClient transaction will use.

5.4. Using the WebClient

The Authorization Code requires a user-agent that can work out redirections (e.g., a browser) to execute the procedure.

Therefore, we make use of this grant type when the user is interacting with our application, usually calling an HTTP endpoint:

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

5.5. Testing

Finally, we'll call the endpoint and analyze what's going on by checking the log entries.

After we call the endpoint, the application verifies that we're not yet authenticated in the application:

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

The application redirects to the Authorization Service's endpoint to authenticate using credentials existing in the Provider's registries (in our case, we'll use the bael-user/bael-password):

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

After authenticating, the user-agent is sent back to the Redirect URI, together with the code as a query param and the state value that was first sent (to avoid CSRF attacks):

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

The application then uses the code to obtain an access token:

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

It obtains users information:

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

And it redirects the user-agent to the original endpoint:

HTTP/1.1 302 Found Location: /auth-code

Finally, our WebClient instance can request the secured resource successfully:

o.s.w.r.f.client.ExchangeFunctions:HTTP GET //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. An Alternative – Client Registration in the Call

Earlier, we saw that using the setDefaultOAuth2AuthorizedClientimplies that the application will include the access token in any call we realize with the client.

If we remove this command from the configuration, we'll need to specify the client registration explicitly by the time we define the request.

One way, of course, is by using the clientRegistrationId as we did before when working in the client credentials flow.

Since we associated the Principal with authorized clients, we can obtain the OAuth2AuthorizedClient instance using the @RegisteredOAuth2AuthorizedClient annotation:

@GetMapping("/auth-code-annotated") Mono useOauthWithAuthCodeAndAnnotation( @RegisteredOAuth2AuthorizedClient("bael") OAuth2AuthorizedClient authorizedClient) { Mono retrievedResource = webClient.get() .uri("//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. Avoiding the OAuth2 Login Features

As we said, the most common scenario is relying on the OAuth2 authorization provider to login users in our application.

But what if we want to avoid this, but still be able to access secured resources using the OAuth2 protocol? Then we'll need to make some changes in our configuration.

For starters, and just to be clear across the board, we can use the authorize action instead of the login one when defining the redirect URI property:

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

We can also drop the user-related properties since we won't be using them to create the Principal in our application.

Now, we'll configure the SecurityWebFilterChain without including the oauth2Login command, and instead, we'll include the oauth2Client one.

Even though we don't want to rely on the OAuth2 Login, we still want to authenticate users before accessing our endpoint. For this reason, we'll also include the formLogin directive here:

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

Let's now run the application and check out what happens when we use the /auth-code-annotated endpoint.

We'll first have to log in to our application using the form login.

Afterward, the application will redirect us to the Authorization Service login, to grant access to our resources.

Note: after doing this, we should be redirected back to the original endpoint that we called. Nevertheless, Spring Security seems to be redirecting back to the root path “/” instead, which seems to be a bug. The following requests after the one triggering the OAuth2 dance will run successfully.

We can see in the endpoint response that the authorized client this time is associated with a principal named bael-client-id instead of the bael-user, named after the user configured in the Authentication Service.

8. Spring Framework Support – Manual Approach

Out of the box, Spring 5 provides just one OAuth2-related service method to add a Bearer token header to the request easily. It's the HttpHeaders#setBearerAuth method.

We'll now see an example to understand what it would take to obtain our secured resource by performing an OAuth2 dance manually.

Simply put, we'll need to chain two HTTP requests: one to get an authentication token from the Authorization Server, and the other to obtain the resource using this token:

@Autowired WebClient client; public Mono obtainSecuredResource() { String encodedClientData = Base64Utils.encodeToString("bael-client-id:bael-secret".getBytes()); Mono 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); }

This example is mainly to understand how cumbersome it can be to leverage a request following the OAuth2 specification and to see how the setBearerAuth method is used.

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

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

В този урок видяхме как можем да настроим нашето приложение като клиент на OAuth2 и по-конкретно как можем да конфигурираме и използваме WebClient за извличане на защитен ресурс в напълно реактивен стек.

Не на последно място, ние анализирахме как механизмите Spring Security 5 OAuth2 работят под капака, за да се съобразят със спецификацията OAuth2.

Както винаги, пълният пример е достъпен в Github.