OAuth2 pour une API REST Spring - Gérez le jeton d'actualisation dans Angular

1. Vue d'ensemble

Dans ce didacticiel, nous continuerons à explorer le flux de code d'autorisation OAuth2 que nous avons commencé à rassembler dans notre article précédent et nous nous concentrerons sur la façon de gérer le jeton d'actualisation dans une application angulaire. Nous utiliserons également le proxy Zuul.

Nous utiliserons la pile OAuth dans Spring Security 5. Si vous souhaitez utiliser la pile héritée Spring Security OAuth, jetez un œil à cet article précédent: OAuth2 pour une API Spring REST - Gérez le jeton d'actualisation dans AngularJS (pile OAuth héritée)

2. Expiration du jeton d'accès

Tout d'abord, rappelez-vous que le client obtenait un jeton d'accès à l'aide d'un type d'octroi de code d'autorisation en deux étapes. Dans un premier temps, nous obtenons le code d'autorisation. Et dans la deuxième étape, nous obtenons réellement le jeton d'accès.

Notre jeton d'accès est stocké dans un cookie qui expirera en fonction de l'expiration du jeton lui-même:

var expireDate = new Date().getTime() + (1000 * token.expires_in); Cookie.set("access_token", token.access_token, expireDate);

Ce qu'il est important de comprendre, c'est que le cookie lui-même n'est utilisé que pour le stockage et qu'il ne pilote rien d'autre dans le flux OAuth2. Par exemple, le navigateur n'enverra jamais automatiquement le cookie au serveur avec des demandes, nous sommes donc en sécurité ici.

Mais notez comment nous définissons réellement cette fonction retrieveToken () pour obtenir le jeton d'accès:

retrieveToken(code) { let params = new URLSearchParams(); params.append('grant_type','authorization_code'); params.append('client_id', this.clientId); params.append('client_secret', 'newClientSecret'); params.append('redirect_uri', this.redirectUri); params.append('code',code); let headers = new HttpHeaders({'Content-type': 'application/x-www-form-urlencoded; charset=utf-8'}); this._http.post('//localhost:8083/auth/realms/baeldung/protocol/openid-connect/token', params.toString(), { headers: headers }) .subscribe( data => this.saveToken(data), err => alert('Invalid Credentials')); }

Nous envoyons le secret client dans les paramètres , ce qui n'est pas vraiment un moyen sûr de gérer cela. Voyons comment nous pouvons éviter de faire cela.

3. Le mandataire

Donc, nous allons maintenant avoir un proxy Zuul en cours d'exécution dans l'application frontale et essentiellement assis entre le client frontal et le serveur d'autorisation . Toutes les informations sensibles vont être traitées à cette couche.

Le client frontal sera désormais hébergé en tant qu'application de démarrage afin que nous puissions nous connecter de manière transparente à notre proxy Zuul intégré à l'aide du démarreur Spring Cloud Zuul.

Si vous souhaitez passer en revue les bases de Zuul, lisez rapidement l'article principal de Zuul.

Configurons maintenant les routes du proxy :

zuul: routes: auth/code: path: /auth/code/** sensitiveHeaders: url: //localhost:8083/auth/realms/baeldung/protocol/openid-connect/auth auth/token: path: /auth/token/** sensitiveHeaders: url: //localhost:8083/auth/realms/baeldung/protocol/openid-connect/token auth/refresh: path: /auth/refresh/** sensitiveHeaders: url: //localhost:8083/auth/realms/baeldung/protocol/openid-connect/token auth/redirect: path: /auth/redirect/** sensitiveHeaders: url: //localhost:8089/ auth/resources: path: /auth/resources/** sensitiveHeaders: url: //localhost:8083/auth/resources/

Nous avons mis en place des itinéraires pour gérer les éléments suivants:

  • auth / code - obtenez le code d'autorisation et enregistrez-le dans un cookie
  • auth / redirect - gère la redirection vers la page de connexion du serveur d'autorisation
  • auth / resources - correspond au chemin d'accès correspondant du serveur d'autorisation pour ses ressources de page de connexion ( css et js )
  • auth / token - obtenez le jeton d'accès, supprimez refresh_token de la charge utile et enregistrez-le dans un cookie
  • auth / refresh - récupérez le jeton d'actualisation, supprimez-le de la charge utile et enregistrez-le dans un cookie

Ce qui est intéressant ici, c'est que nous ne transmettons que le trafic au serveur d'autorisation et rien d'autre. Nous n'avons vraiment besoin du proxy que lorsque le client obtient de nouveaux jetons.

Ensuite, regardons tout cela un par un.

4. Obtenez le code à l'aide du pré-filtre Zuul

La première utilisation du proxy est simple - nous mettons en place une demande pour obtenir le code d'autorisation:

@Component public class CustomPreZuulFilter extends ZuulFilter { @Override public Object run() { RequestContext ctx = RequestContext.getCurrentContext(); HttpServletRequest req = ctx.getRequest(); String requestURI = req.getRequestURI(); if (requestURI.contains("auth/code")) { Map params = ctx.getRequestQueryParams(); if (params == null) { params = Maps.newHashMap(); } params.put("response_type", Lists.newArrayList(new String[] { "code" })); params.put("scope", Lists.newArrayList(new String[] { "read" })); params.put("client_id", Lists.newArrayList(new String[] { CLIENT_ID })); params.put("redirect_uri", Lists.newArrayList(new String[] { REDIRECT_URL })); ctx.setRequestQueryParams(params); } return null; } @Override public boolean shouldFilter() { boolean shouldfilter = false; RequestContext ctx = RequestContext.getCurrentContext(); String URI = ctx.getRequest().getRequestURI(); if (URI.contains("auth/code") || URI.contains("auth/token") || URI.contains("auth/refresh")) { shouldfilter = true; } return shouldfilter; } @Override public int filterOrder() { return 6; } @Override public String filterType() { return "pre"; } }

Nous utilisons un type de filtre de pré- traitement de la demande avant de la transmettre.

Dans la méthode run () du filtre , nous ajoutons des paramètres de requête pour response_type , scope , client_id et redirect_uri - tout ce dont notre serveur d'autorisation a besoin pour nous amener à sa page de connexion et renvoyer un code.

Notez également la méthode shouldFilter () . Nous ne filtrons que les requêtes avec les 3 URI mentionnés, d'autres ne passent pas par la méthode run .

5. Mettez le code dans un cookie à l' aide du filtre Zuul Post

Ce que nous prévoyons de faire ici, c'est d'enregistrer le code en tant que cookie afin que nous puissions l'envoyer au serveur d'autorisation pour obtenir le jeton d'accès. Le code est présent en tant que paramètre de requête dans l'URL de requête vers laquelle le serveur d'autorisation nous redirige après la connexion.

Nous allons mettre en place un post-filtre Zuul pour extraire ce code et le placer dans le cookie. Ce n'est pas seulement un cookie normal, mais un cookie sécurisé, uniquement HTTP avec un chemin très limité ( / auth / token ) :

@Component public class CustomPostZuulFilter extends ZuulFilter { private ObjectMapper mapper = new ObjectMapper(); @Override public Object run() { RequestContext ctx = RequestContext.getCurrentContext(); try { Map params = ctx.getRequestQueryParams(); if (requestURI.contains("auth/redirect")) { Cookie cookie = new Cookie("code", params.get("code").get(0)); cookie.setHttpOnly(true); cookie.setPath(ctx.getRequest().getContextPath() + "/auth/token"); ctx.getResponse().addCookie(cookie); } } catch (Exception e) { logger.error("Error occured in zuul post filter", e); } return null; } @Override public boolean shouldFilter() { boolean shouldfilter = false; RequestContext ctx = RequestContext.getCurrentContext(); String URI = ctx.getRequest().getRequestURI(); if (URI.contains("auth/redirect") || URI.contains("auth/token") || URI.contains("auth/refresh")) { shouldfilter = true; } return shouldfilter; } @Override public int filterOrder() { return 10; } @Override public String filterType() { return "post"; } }

Afin d'ajouter une couche supplémentaire de protection contre les attaques CSRF, nous ajouterons un en-tête de cookie Same-Site à tous nos cookies .

Pour cela, nous allons créer une classe de configuration:

@Configuration public class SameSiteConfig implements WebMvcConfigurer { @Bean public TomcatContextCustomizer sameSiteCookiesConfig() { return context -> { final Rfc6265CookieProcessor cookieProcessor = new Rfc6265CookieProcessor(); cookieProcessor.setSameSiteCookies(SameSiteCookies.STRICT.getValue()); context.setCookieProcessor(cookieProcessor); }; } }

Ici, nous définissons l'attribut sur strict , de sorte que tout transfert de cookies intersites soit strictement refusé.

6. Obtenir et utiliser le code du cookie

Maintenant que nous avons le code dans le cookie, lorsque l'application Angular frontale tente de déclencher une demande de jeton, elle va envoyer la demande à / auth / token et donc le navigateur enverra bien sûr ce cookie.

Nous allons donc maintenant avoir une autre condition dans notre pré- filtre dans le proxy qui extraira le code du cookie et l'envoyera avec d'autres paramètres de formulaire pour obtenir le jeton :

public Object run() { RequestContext ctx = RequestContext.getCurrentContext(); ... else if (requestURI.contains("auth/token"))) { try { String code = extractCookie(req, "code"); String formParams = String.format( "grant_type=%s&client_id=%s&client_secret=%s&redirect_uri=%s&code=%s", "authorization_code", CLIENT_ID, CLIENT_SECRET, REDIRECT_URL, code); byte[] bytes = formParams.getBytes("UTF-8"); ctx.setRequest(new CustomHttpServletRequest(req, bytes)); } catch (IOException e) { e.printStackTrace(); } } ... } private String extractCookie(HttpServletRequest req, String name) { Cookie[] cookies = req.getCookies(); if (cookies != null) { for (int i = 0; i < cookies.length; i++) { if (cookies[i].getName().equalsIgnoreCase(name)) { return cookies[i].getValue(); } } } return null; }

Et voici notre CustomHttpServletRequest - utilisé pour envoyer le corps de notre requête avec les paramètres de formulaire requis convertis en octets :

public class CustomHttpServletRequest extends HttpServletRequestWrapper { private byte[] bytes; public CustomHttpServletRequest(HttpServletRequest request, byte[] bytes) { super(request); this.bytes = bytes; } @Override public ServletInputStream getInputStream() throws IOException { return new ServletInputStreamWrapper(bytes); } @Override public int getContentLength() { return bytes.length; } @Override public long getContentLengthLong() { return bytes.length; } @Override public String getMethod() { return "POST"; } }

Cela nous permettra d'obtenir un jeton d'accès du serveur d'autorisation dans la réponse. Ensuite, nous verrons comment nous transformons la réponse.

7. Placez le jeton d'actualisation dans un cookie

Passons aux choses amusantes.

Ce que nous prévoyons de faire ici, c'est que le client obtienne le jeton d'actualisation en tant que cookie.

Nous ajouterons à notre post-filtre Zuul pour extraire le jeton d'actualisation du corps JSON de la réponse et le définir dans le cookie. Il s'agit à nouveau d'un cookie sécurisé, uniquement HTTP avec un chemin très limité ( / auth / refresh ):

public Object run() { ... else if (requestURI.contains("auth/token") || requestURI.contains("auth/refresh")) { InputStream is = ctx.getResponseDataStream(); String responseBody = IOUtils.toString(is, "UTF-8"); if (responseBody.contains("refresh_token")) { Map responseMap = mapper.readValue(responseBody, new TypeReference() {}); String refreshToken = responseMap.get("refresh_token").toString(); responseMap.remove("refresh_token"); responseBody = mapper.writeValueAsString(responseMap); Cookie cookie = new Cookie("refreshToken", refreshToken); cookie.setHttpOnly(true); cookie.setPath(ctx.getRequest().getContextPath() + "/auth/refresh"); cookie.setMaxAge(2592000); // 30 days ctx.getResponse().addCookie(cookie); } ctx.setResponseBody(responseBody); } ... }

Comme nous pouvons le voir, nous avons ajouté ici une condition dans notre post-filtre Zuul pour lire la réponse et extraire le jeton d'actualisation pour les routes auth / token et auth / refresh . Nous faisons exactement la même chose pour les deux car le serveur d'autorisation envoie essentiellement la même charge utile tout en obtenant le jeton d'accès et le jeton d'actualisation.

Ensuite, nous avons supprimé refresh_token de la réponse JSON pour nous assurer qu'il n'est jamais accessible au frontal en dehors du cookie.

Un autre point à noter ici est que nous définissons l'âge maximum du cookie à 30 jours - car cela correspond à l'heure d'expiration du jeton.

8. Obtenir et utiliser le jeton d'actualisation du cookie

Now that we have the Refresh Token in the cookie, when the front-end Angular application tries to trigger a token refresh, it's going to send the request at /auth/refresh and so the browser, will, of course, send that cookie.

So we'll now have another condition in our pre filter in the proxy that will extract the Refresh Token from the cookie and send it forward as a HTTP parameter – so that the request is valid:

public Object run() { RequestContext ctx = RequestContext.getCurrentContext(); ... else if (requestURI.contains("auth/refresh"))) { try { String token = extractCookie(req, "token"); String formParams = String.format( "grant_type=%s&client_id=%s&client_secret=%s&refresh_token=%s", "refresh_token", CLIENT_ID, CLIENT_SECRET, token); byte[] bytes = formParams.getBytes("UTF-8"); ctx.setRequest(new CustomHttpServletRequest(req, bytes)); } catch (IOException e) { e.printStackTrace(); } } ... }

This is similar to what we did when we first obtained the Access Token. But notice that the form body is different. Now we're sending a grant_type of refresh_token instead of authorization_code along with the token we'd saved before in the cookie.

After obtaining the response, it again goes through the same transformation in the pre filter as we saw earlier in section 7.

9. Refreshing the Access Token from Angular

Finally, let's modify our simple front-end application and actually make use of refreshing the token:

Here is our function refreshAccessToken():

refreshAccessToken() { let headers = new HttpHeaders({ 'Content-type': 'application/x-www-form-urlencoded; charset=utf-8'}); this._http.post('auth/refresh', {}, {headers: headers }) .subscribe( data => this.saveToken(data), err => alert('Invalid Credentials') ); }

Note how we're simply using the existing saveToken() function – and just passing different inputs to it.

Also notice that we're not adding any form parameters with the refresh_token ourselves – as that's going to be taken care of by the Zuul filter.

10. Run the Front End

Since our front-end Angular client is now hosted as a Boot application, running it will be slightly different than before.

The first step is the same. We need to build the App:

mvn clean install

This will trigger the frontend-maven-plugin defined in our pom.xml to build the Angular code and copy the UI artifacts over to target/classes/static folder. This process overwrites anything else that we have in the src/main/resources directory. So we need to make sure and include any required resources from this folder, such as application.yml, in the copy process.

In the second step, we need to run our SpringBootApplication class UiApplication. Our client app will be up and running on port 8089 as specified in the application.yml.

11. Conclusion

In this OAuth2 tutorial we learned how to store the Refresh Token in an Angular client application, how to refresh an expired Access Token and how to leverage the Zuul proxy for all of that.

The full implementation of this tutorial can be found over on GitHub.