OAuth2 per un'API REST di primavera: gestisci il token di aggiornamento in angolare

1. Panoramica

In questo tutorial, continueremo ad esplorare il flusso del codice di autorizzazione OAuth2 che abbiamo iniziato a mettere insieme nel nostro articolo precedente e ci concentreremo su come gestire il token di aggiornamento in un'app Angular. Utilizzeremo anche il proxy Zuul.

Useremo lo stack OAuth in Spring Security 5. Se desideri utilizzare lo stack legacy OAuth di Spring Security, dai un'occhiata a questo articolo precedente: OAuth2 per un'API REST di Spring - Gestisci il token di aggiornamento in AngularJS (stack OAuth legacy)

2. Scadenza del token di accesso

Innanzitutto, ricorda che il client stava ottenendo un token di accesso utilizzando un tipo di concessione del codice di autorizzazione in due passaggi. Nella prima fase otteniamo il codice di autorizzazione. E nella seconda fase, otteniamo effettivamente il token di accesso.

Il nostro token di accesso è memorizzato in un cookie che scadrà in base alla scadenza del token stesso:

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

Ciò che è importante capire è che il cookie stesso viene utilizzato solo per l'archiviazione e non guida nient'altro nel flusso OAuth2. Ad esempio, il browser non invierà mai automaticamente il cookie al server con richieste, quindi qui siamo protetti.

Ma nota come definiamo effettivamente questa funzione retrieveToken () per ottenere il token di accesso:

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')); }

Stiamo inviando il client secret nei parametri , che non è davvero un modo sicuro per gestirlo. Vediamo come possiamo evitare di farlo.

3. Il proxy

Quindi, ora avremo un proxy Zuul in esecuzione nell'applicazione front-end e fondamentalmente seduto tra il client front-end e il server di autorizzazione . Tutte le informazioni sensibili verranno gestite a questo livello.

Il client front-end sarà ora ospitato come applicazione di avvio in modo che possiamo connetterci senza problemi al nostro proxy Zuul incorporato utilizzando lo starter Spring Cloud Zuul.

Se vuoi approfondire le basi di Zuul, leggi rapidamente l'articolo principale di Zuul.

Ora configuriamo le rotte del 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/

Abbiamo impostato percorsi per gestire quanto segue:

  • auth / code : ottieni il codice di autorizzazione e salvalo in un cookie
  • auth / redirect - gestisce il reindirizzamento alla pagina di accesso del server di autorizzazione
  • auth / resources - mappa al percorso corrispondente del server di autorizzazione per le risorse della sua pagina di accesso ( css e js )
  • auth / token : ottieni il token di accesso, rimuovi refresh_token dal payload e salvalo in un cookie
  • auth / refresh : ottieni il token di aggiornamento, rimuovilo dal payload e salvalo in un cookie

Ciò che è interessante qui è che stiamo solo inviando il traffico al server di autorizzazione e non nient'altro. Abbiamo solo bisogno del proxy per entrare quando il client ottiene nuovi token.

Quindi, diamo un'occhiata a tutti questi uno per uno.

4. Ottieni il codice utilizzando Zuul Pre Filter

Il primo utilizzo del proxy è semplice: abbiamo impostato una richiesta per ottenere il codice di autorizzazione:

@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"; } }

Stiamo utilizzando un tipo di filtro di pre per elaborare la richiesta prima di trasmetterla.

Nel metodo run () del filtro , aggiungiamo parametri di query per response_type , scope , client_id e redirect_uri - tutto ciò di cui il nostro server di autorizzazione ha bisogno per portarci alla sua pagina di accesso e inviare un codice.

Notare anche il metodo shouldFilter () . Stiamo solo filtrando le richieste con i 3 URI menzionati, altri non passano al metodo run .

5. Inserisci il codice in un cookie utilizzando Zuul Post Filter

Quello che abbiamo intenzione di fare qui è salvare il codice come cookie in modo da poterlo inviare al server di autorizzazione per ottenere il token di accesso. Il codice è presente come parametro di query nell'URL della richiesta a cui il server di autorizzazione ci reindirizza dopo l'accesso.

Configureremo un post-filtro Zuul per estrarre questo codice e impostarlo nel cookie. Questo non è solo un normale cookie, ma un cookie protetto solo HTTP con un percorso molto limitato ( / 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"; } }

Per aggiungere un ulteriore livello di protezione contro gli attacchi CSRF, aggiungeremo un'intestazione del cookie Same-Site a tutti i nostri cookie .

Per questo, creeremo una classe di configurazione:

@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); }; } }

Qui stiamo impostando l'attributo su rigoroso , in modo che qualsiasi trasferimento di cookie tra siti venga rigorosamente negato.

6. Ottieni e utilizza il codice dal cookie

Ora che abbiamo il codice nel cookie, quando l'applicazione angolare front-end tenta di attivare una richiesta di token, invierà la richiesta a / auth / token e quindi il browser, ovviamente, invierà quel cookie.

Quindi ora avremo un'altra condizione nel nostro pre- filtro nel proxy che estrarrà il codice dal cookie e lo invierà insieme ad altri parametri del modulo per ottenere il token :

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; }

Ed ecco il nostro CustomHttpServletRequest - utilizzato per inviare il corpo della nostra richiesta con i parametri del modulo richiesti convertiti in byte :

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"; } }

This will get us an Access Token from the Authorization Server in the response. Next, we'll see how we are transforming the response.

7. Put the Refresh Token in a Cookie

On to the fun stuff.

What we're planning to do here is to have the client get the Refresh Token as a cookie.

We'll add to our Zuul post-filter to extract the Refresh Token from the JSON body of the response and set it in the cookie. This is again a secured, HTTP-only cookie with a very limited path (/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); } ... }

As we can see, here we added a condition in our Zuul post-filter to read the response and extract the Refresh Token for the routes auth/token and auth/refresh. We are doing the exact same thing for the two because the Authorization Server essentially sends the same payload while obtaining the Access Token and the Refresh Token.

Then we removed refresh_token from the JSON response to make sure it's never accessible to the front end outside of the cookie.

Another point to note here is that we set the max age of the cookie to 30 days – as this matches the expire time of the Token.

8. Get and Use the Refresh Token from the 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.