Spring WebClient e supporto OAuth2

1. Panoramica

Spring Security 5 fornisce il supporto OAuth2 per la classe WebClient non bloccante di Spring Webflux .

In questo tutorial, analizzeremo diversi approcci per accedere alle risorse protette usando questa classe.

Inoltre, daremo un'occhiata dietro le quinte per capire come Spring gestisce il processo di autorizzazione OAuth2.

2. Impostazione dello scenario

In linea con la specifica OAuth2, oltre al nostro client, che è il nostro argomento principale in questo articolo, abbiamo naturalmente bisogno di un server di autorizzazione e di un server di risorse.

Possiamo utilizzare noti fornitori di autorizzazioni come Google o Github. Per comprendere meglio il ruolo del client OAuth2, possiamo anche utilizzare i nostri server, con un'implementazione disponibile qui. Non mostreremo la configurazione completa poiché non è l'argomento di questo tutorial, è sufficiente sapere che:

  • il server di autorizzazione sarà:
    • in esecuzione sulla porta 8081
    • esporre gli endpoint / oauth / authorize, / oauth / token e oauth / check_token per eseguire la funzionalità desiderata
    • configurato con utenti di esempio (ad esempio john / 123 ) e un singolo client OAuth ( fooClientIdPassword / secret )
  • il Resource Server sarà separato dal server di autenticazione e sarà:
    • in esecuzione sulla porta 8082
    • servire una semplice risorsa protetta oggetto Foo accessibile utilizzando l' endpoint / foos / {id}

Nota: è importante capire che diversi progetti Spring offrono diverse funzionalità e implementazioni relative a OAuth. Possiamo esaminare ciò che ogni libreria fornisce in questa matrice di Spring Projects.

Il WebClient e tutte le funzionalità relative a Webflux reattive fanno parte del progetto Spring Security 5. Pertanto, utilizzeremo principalmente questo framework in questo articolo.

3. Spring Security 5 Sotto il cofano

Per comprendere appieno gli esempi in arrivo, è utile sapere come Spring Security gestisce internamente le funzionalità OAuth2.

Questo framework offre funzionalità per:

  • fare affidamento su un account provider OAuth2 per accedere agli utenti nell'applicazione
  • configurare il nostro servizio come client OAuth2
  • gestire per noi le procedure autorizzative
  • aggiorna automaticamente i token
  • memorizzare le credenziali se necessario

Alcuni dei concetti fondamentali del mondo OAuth2 di Spring Security sono descritti nel diagramma seguente:

3.1. Fornitori

Spring definisce il ruolo del provider OAuth2, responsabile dell'esposizione delle risorse protette OAuth 2.0.

Nel nostro esempio, il nostro servizio di autenticazione sarà quello che offre le funzionalità del provider.

3.2. Registrazioni dei clienti

Un ClientRegistration è un'entità contenente tutte le informazioni rilevanti di un client specifico registrato in un provider OAuth2 (o OpenID).

Nel nostro scenario, sarà il client registrato nel server di autenticazione, identificato dall'id bael-client- id.

3.3. Clienti autorizzati

Una volta che l'utente finale (ovvero il proprietario della risorsa) concede le autorizzazioni al client per accedere alle sue risorse, viene creata un'entità OAuth2AuthorizedClient .

Sarà responsabile dell'associazione dei token di accesso alle registrazioni dei client e ai proprietari delle risorse (rappresentati dagli oggetti Principal ).

3.4. Archivi

Inoltre, Spring Security offre anche classi di repository per accedere alle entità sopra menzionate.

In particolare, le classi ReactiveClientRegistrationRepository e ServerOAuth2AuthorizedClientRepository vengono utilizzate in stack reattivi e utilizzano l'archiviazione in memoria per impostazione predefinita.

Spring Boot 2.x crea i bean di queste classi di repository e li aggiunge automaticamente al contesto.

3.5. Catena di filtri web di sicurezza

Uno dei concetti chiave di Spring Security 5 è l' entità reattiva SecurityWebFilterChain .

Come indica il nome, rappresenta una raccolta concatenata di oggetti WebFilter .

Quando abilitiamo le funzionalità OAuth2 nella nostra applicazione, Spring Security aggiunge due filtri alla catena:

  1. Un filtro risponde alle richieste di autorizzazione (l' URI / oauth2 / permission / {registrationId} ) o genera un'eccezione ClientAuthorizationRequiredException . Contiene un riferimento al ReactiveClientRegistrationRepository ed è incaricato di creare la richiesta di autorizzazione per reindirizzare lo user-agent.
  2. Il secondo filtro varia a seconda della funzionalità che stiamo aggiungendo (funzionalità del client OAuth2 o funzionalità di accesso OAuth2). In entrambi i casi, la responsabilità principale di questo filtro è creare l' istanza OAuth2AuthorizedClient e archiviarla utilizzando ServerOAuth2AuthorizedClientRepository.

3.6. Client Web

Il client Web verrà configurato con una ExchangeFilterFunction contenente riferimenti ai repository.

Li utilizzerà per ottenere il token di accesso per aggiungerlo automaticamente alla richiesta.

4. Supporto Spring Security 5 - Flusso delle credenziali del client

Spring Security consente di configurare la nostra applicazione come client OAuth2.

In questo articolo, utilizzeremo un'istanza WebClient per recuperare le risorse utilizzando le "Credenziali client"prima il tipo di concessione, quindi utilizzando il flusso "Codice di autorizzazione".

La prima cosa che dovremo fare è configurare la registrazione del client e il provider che useremo per ottenere il token di accesso.

4.1. Configurazioni client e provider

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.

In uno scenario di vita reale, lasceremmo che Spring Security si occupasse di tutto il duro lavoro per noi in modo trasparente, come abbiamo fatto nelle sezioni precedenti.

9. Conclusione

In questo tutorial, abbiamo visto come possiamo configurare la nostra applicazione come client OAuth2 e, più in particolare, come possiamo configurare e utilizzare WebClient per recuperare una risorsa protetta in uno stack completamente reattivo.

Ultimo ma non meno importante, abbiamo analizzato il modo in cui i meccanismi OAuth2 di Spring Security 5 operano sotto il cofano per conformarsi alla specifica OAuth2.

Come sempre, l'esempio completo è disponibile su Github.