OAuth2 за пролетен REST API - Обработвайте маркера за опресняване в Angular

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

В този урок ще продължим да изследваме потока на OAuth2 Authorization Code, който започнахме да събираме в предишната ни статия, и ще се съсредоточим върху това как да се справим с Refresh Token в Angular приложение. Също така ще използваме прокси Zuul.

Ще използваме стека OAuth в Spring Security 5. Ако искате да използвате наследствения стек Spring Security OAuth, разгледайте тази предишна статия: OAuth2 за Spring REST API - Обработете маркера за опресняване в AngularJS (стария стек OAuth)

2. Достъп до изтичане на токена

Първо, не забравяйте, че клиентът е получил маркер за достъп, използвайки тип на предоставяне на код за разрешаване в две стъпки. В първата стъпка получаваме Кода за разрешение. И във втората стъпка всъщност получаваме жетона за достъп.

Нашият токен за достъп се съхранява в бисквитка, която ще изтече въз основа на изтичането на самия токен:

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

Това, което е важно да се разбере, е, че самата бисквитка се използва само за съхранение и не управлява нищо друго в потока OAuth2. Например браузърът никога няма автоматично да изпрати бисквитката до сървъра с искания, така че ние сме защитени тук.

Но обърнете внимание как всъщност дефинираме тази функция retrieveToken (), за да получим маркера за достъп:

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

Изпращаме клиентска тайна в параметрите , което всъщност не е сигурен начин да се справим с това. Нека видим как можем да избегнем това.

3. Пълномощникът

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

Клиентът отпред ще бъде хостван като приложение за стартиране, за да можем да се свържем безпроблемно с вградения ни Zuul прокси с помощта на Spring Cloud Zuul стартер.

Ако искате да разгледате основите на Zuul, прочетете набързо основната статия на Zuul.

Сега нека конфигурираме маршрутите на проксито :

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/

Създадохме маршрути, за да се справим със следното:

  • auth / code - вземете кода за разрешение и го запазете в бисквитка
  • auth / redirect - обработва пренасочването към страницата за вход на сървъра за разрешаване
  • auth / resources - картографиране на съответния път на сървъра за оторизация за неговите ресурси на страницата за вход ( css и js )
  • auth / token - вземете токена за достъп, премахнете refresh_token от полезния товар и го запазете в бисквитка
  • auth / refresh - вземете Refresh Token, извадете го от полезния товар и го запазете в бисквитка

Интересното тук е, че ние само проксираме трафик към Authorization Server, а не нищо друго. Наистина имаме нужда проксито да влезе само когато клиентът получава нови токени.

След това нека разгледаме всички тези един по един.

4. Вземете кода с помощта на Zuul Pre Filter

Първото използване на проксито е просто - създадохме заявка за получаване на кода за разрешаване:

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

Използваме тип филтър за предварително, за да обработим заявката, преди да я предадем.

В метода run () на филтъра добавяме параметри на заявката за response_type , scope , client_id и redirect_uri - всичко необходимо на нашия Authorization Server, за да ни отведе до страницата си за вход и да изпрати обратно код.

Също така обърнете внимание на метода shouldFilter () . Ние сме филтриране само заявки с 3 URL адресите, посочени, други не минават през с бягане метод.

5. Поставете кода в бисквитка с помощта на Zuul Post Filter

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

Ще настроим Zuul пост-филтър, за да извлечем този код и да го зададем в бисквитката. Това не е просто нормална бисквитка, а защитена, само HTTP бисквитка с много ограничен път ( / 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"; } }

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

За това ще създадем конфигурационен клас:

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

Тук ние задаваме атрибута на стриктно , така че всяко прехвърляне на бисквитки между сайтове е стриктно задържано.

6. Вземете и използвайте кода от бисквитката

Сега, когато имаме кода в бисквитката, когато приложението Angular отпред се опитва да задейства заявка за Token, ще изпрати заявката на / auth / 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; }

И ето нашата CustomHttpServletRequest - използвана за изпращане на нашето тяло с искане с необходимите параметри на формуляра, преобразувани в байтове :

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.