Prise en charge de Spring WebClient et OAuth2

1. Vue d'ensemble

Spring Security 5 fournit une prise en charge OAuth2 pour la classe WebClient non bloquante de Spring Webflux .

Dans ce didacticiel, nous analyserons différentes approches pour accéder aux ressources sécurisées à l'aide de cette classe.

Nous allons également jeter un œil sous le capot pour comprendre comment Spring gère le processus d'autorisation OAuth2.

2. Configuration du scénario

Conformément à la spécification OAuth2, outre notre client - qui est notre sujet principal dans cet article - nous avons naturellement besoin d'un serveur d'autorisation et d'un serveur de ressources.

Nous pouvons utiliser des fournisseurs d'autorisation bien connus comme Google ou Github. Pour mieux comprendre le rôle du client OAuth2, nous pouvons également utiliser nos propres serveurs, avec une implémentation disponible ici. Nous ne montrerons pas la configuration complète car ce n'est pas le sujet de ce tutoriel, il suffit de savoir que:

  • le serveur d'autorisation sera:
    • fonctionnant sur le port 8081
    • exposer les points de terminaison / oauth / authorize, / oauth / token et oauth / check_token pour exécuter la fonctionnalité souhaitée
    • configuré avec des exemples d'utilisateurs (par exemple, john / 123 ) et un seul client OAuth ( fooClientIdPassword / secret )
  • le serveur de ressources sera séparé du serveur d'authentification et sera:
    • fonctionnant sur le port 8082
    • au service d' un simple Foo objet ressource sécurisée accessible par le / foos / {id} point final

Remarque: il est important de comprendre que plusieurs projets Spring proposent différentes fonctionnalités et implémentations liées à OAuth. Nous pouvons examiner ce que chaque bibliothèque fournit dans cette matrice de Spring Projects.

Le WebClient et toutes les fonctionnalités réactives liées à Webflux font partie du projet Spring Security 5. Par conséquent, nous utiliserons principalement ce cadre tout au long de cet article.

3. Spring Security 5 sous le capot

Afin de bien comprendre les exemples à venir, il est bon de savoir comment Spring Security gère les fonctionnalités OAuth2 en interne.

Ce cadre offre des capacités pour:

  • s'appuyer sur un compte fournisseur OAuth2 pour connecter les utilisateurs à l'application
  • configurer notre service en tant que client OAuth2
  • gérer les procédures d'autorisation pour nous
  • actualiser automatiquement les jetons
  • stocker les informations d'identification si nécessaire

Certains des concepts fondamentaux du monde OAuth2 de Spring Security sont décrits dans le diagramme suivant:

3.1. Fournisseurs

Spring définit le rôle du fournisseur OAuth2, responsable de l'exposition des ressources protégées OAuth 2.0.

Dans notre exemple, notre service d'authentification sera celui qui offre les capacités du fournisseur.

3.2. Inscriptions des clients

Un ClientRegistration est une entité contenant toutes les informations pertinentes d'un client spécifique enregistré dans un fournisseur OAuth2 (ou OpenID).

Dans notre scénario, ce sera le client enregistré sur le serveur d'authentification, identifié par l' id bael-client- id.

3.3. Clients autorisés

Une fois que l'utilisateur final (alias le propriétaire de la ressource) accorde des autorisations au client pour accéder à ses ressources, une entité OAuth2AuthorizedClient est créée.

Il sera chargé d'associer les jetons d'accès aux inscriptions des clients et aux propriétaires de ressources (représentés par des objets Principal ).

3.4. Référentiels

De plus, Spring Security propose également des classes de référentiel pour accéder aux entités mentionnées ci-dessus.

En particulier, les classes ReactiveClientRegistrationRepository et ServerOAuth2AuthorizedClientRepository sont utilisées dans les piles réactives et utilisent le stockage en mémoire par défaut.

Spring Boot 2.x crée des beans de ces classes de référentiel et les ajoute automatiquement au contexte.

3.5. Chaîne de filtres Web de sécurité

L'un des concepts clés de Spring Security 5 est l' entité SecurityWebFilterChain réactive .

Comme son nom l'indique, il représente une collection chaînée d' objets WebFilter .

Lorsque nous activons les fonctionnalités OAuth2 dans notre application, Spring Security ajoute deux filtres à la chaîne:

  1. Un filtre répond aux demandes d'autorisation (l' URI / oauth2 / autorisation / {registrationId} ) ou lance une exception ClientAuthorizationRequiredException . Il contient une référence au ReactiveClientRegistrationRepository, et il est en charge de créer la demande d'autorisation pour rediriger l'agent utilisateur.
  2. Le deuxième filtre diffère en fonction de la fonctionnalité que nous ajoutons (capacités du client OAuth2 ou fonctionnalité de connexion OAuth2). Dans les deux cas, la principale responsabilité de ce filtre est de créer l' instance OAuth2AuthorizedClient et de la stocker à l'aide de ServerOAuth2AuthorizedClientRepository.

3.6. Client Web

Le client Web sera configuré avec un ExchangeFilterFunction contenant des références aux référentiels.

Il les utilisera pour obtenir le jeton d'accès afin de l'ajouter automatiquement à la demande.

4. Prise en charge de Spring Security 5 - Flux d'informations d'identification du client

Spring Security permet de configurer notre application en tant que client OAuth2.

Dans cet article, nous utiliserons une instance WebClient pour récupérer des ressources à l'aide des 'informations d'identification du client'accordez d'abord le type, puis utilisez le flux «Code d'autorisation».

La première chose que nous devrons faire est de configurer l'enregistrement du client et le fournisseur que nous utiliserons pour obtenir le jeton d'accès.

4.1. Configurations du client et du fournisseur

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.

Dans un scénario réel, nous laisserions Spring Security s'occuper de tout le travail acharné pour nous de manière transparente, comme nous l'avons fait dans les sections précédentes.

9. Conclusion

Dans ce tutoriel, nous avons vu comment nous pouvons configurer notre application en tant que client OAuth2, et plus particulièrement, comment nous pouvons configurer et utiliser le WebClient pour récupérer une ressource sécurisée dans une pile entièrement réactive.

Enfin, nous avons analysé comment les mécanismes Spring Security 5 OAuth2 fonctionnent sous le capot pour se conformer à la spécification OAuth2.

Comme toujours, l'exemple complet est disponible à l'adresse over sur Github.