OAuth2 for a Spring REST API – Handle the Refresh Token in Angular – 为Spring REST API提供OAuth2 – 在Angular中处理Refresh Token

最后修改: 2016年 3月 5日

中文/混合/英文(键盘快捷键:t)

1. Overview

1.概述

In this tutorial, we’ll continue exploring the OAuth2 Authorization Code flow that we started putting together in our previous article and we’ll focus on how to handle the Refresh Token in an Angular app. We’ll also be making use of the Zuul proxy.

在本教程中,我们将继续探索在我们之前的文章中开始整理的OAuth2授权代码流程,我们将重点讨论如何在Angular应用中处理刷新令牌。我们还将利用Zuul代理。

We’ll use the OAuth stack in Spring Security 5. If you want to use the Spring Security OAuth legacy stack, have a look at this previous article: OAuth2 for a Spring REST API – Handle the Refresh Token in AngularJS (legacy OAuth stack)

我们将使用Spring Security 5中的OAuth栈。如果你想使用Spring Security的OAuth传统栈,请看之前的这篇文章。Spring REST API的OAuth2 – 在AngularJS中处理Refresh Token(传统OAuth栈)

2. Access Token Expiration

2.访问令牌过期

First, remember that the client was obtaining an Access Token using an Authorization Code grant type in two steps. In the first step, we obtain the Authorization Code. And in the second step, we actually obtain the Access Token.

首先,请记住,客户是使用授权码授予类型分两步获得访问令牌的。在第一步中,我们获得授权码。而在第二步中,我们实际上获得访问令牌

Our Access Token is stored in a cookie which will expire based on when the Token itself expires:

我们的访问令牌存储在一个cookie中,它将根据令牌本身的过期时间而过期。

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

What’s important to understand is that the cookie itself is only used for storage and it doesn’t drive anything else in the OAuth2 flow. For example, the browser will never automatically send out the cookie to the server with requests, so we are secured here.

需要了解的是,cookie本身只用于存储,它不会驱动OAuth2流程中的任何其他东西。例如,浏览器永远不会自动将cookie随请求发送到服务器,所以我们在这里是安全的。

But note how we actually define this retrieveToken() function to get the Access Token:

但请注意我们实际上是如何定义这个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('http://localhost:8083/auth/realms/baeldung/protocol/openid-connect/token',
    params.toString(), { headers: headers })
    .subscribe(
      data => this.saveToken(data),
      err => alert('Invalid Credentials'));
}

We are sending the client secret in the params, which is not really a secure way to handle this. Let’s see how we can avoid doing this.

我们在params中发送客户端的秘密,这并不是一种真正安全的处理方式。让我们看看如何避免这样做。

3. The Proxy

3.代理权

So, we’re now going to have a Zuul proxy running in the front-end application and basically sitting between the front-end client and the Authorization Server. All the sensitive information is going to be handled at this layer.

因此,我们现在要在前端应用程序中运行一个Zuul代理,基本上位于前端客户端和授权服务器之间。所有的敏感信息都将在这一层得到处理。

The front-end client will be now hosted as a Boot application so that we can connect seamlessly to our embedded Zuul proxy using the Spring Cloud Zuul starter.

前端客户端现在将被托管为一个Boot应用程序,这样我们就可以使用Spring Cloud Zuul启动器无缝连接到我们的嵌入式Zuul代理。

If you want to go over the basics of Zuul, have a quick read of the main Zuul article.

如果你想了解Zuul的基本情况,请快速阅读Zuul的主要文章

Now let’s configure the routes of the proxy:

现在让我们来配置代理的路线

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

We have set up routes to handle the following:

我们已经设置了路线来处理以下问题。

  • auth/code – get the Authorization Code and save it in a cookie
  • auth/redirect – handle the redirect to the Authorization Server’s login page
  • auth/resources – map to the Authorization Server’s corresponding path for its login page resources (css and js)
  • auth/token – get the Access Token, remove refresh_token from the payload and save it in a cookie
  • auth/refresh – get the Refresh Token, remove it from the payload and save it in a cookie

What’s interesting here is that we’re only proxying traffic to the Authorization Server and not anything else. We only really need the proxy to come in when the client is obtaining new tokens.

这里有趣的是,我们只是代理了到授权服务器的流量,而不是其他的。我们只需要在客户端获得新的令牌时才真正需要代理进来。

Next, let’s look at all these one by one.

接下来,让我们逐一看一下所有这些。

4. Get the Code Using Zuul Pre Filter

4.使用Zuul Pre Filter获取代码

The first use of the proxy is simple – we set up a request to get the Authorization Code:

代理的第一次使用很简单–我们设置了一个请求来获取授权码:

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

We’re using a filter type of pre to process the request before passing it on.

我们使用pre的过滤器类型来处理请求,然后再传递给它。

In the filter’s run() method, we add query parameters for response_type, scope, client_id and redirect_uri – everything that our Authorization Server needs to take us to its login page and send back a Code.

在过滤器的run()方法中,我们为response_typescopeclient_idredirect_uri添加查询参数,这是我们的授权服务器将我们带到其登录页面并送回一个代码所需要的。

Also note the shouldFilter() method. We are only filtering requests with the 3 URIs mentioned, others do not go through to the run method.

同时注意shouldFilter()方法。我们只过滤上述3个URI的请求,其他的不进入run方法。

5. Put the Code in a Cookie Using Zuul Post Filter

5.将代码放在一个Cookie中使用Zuul Post Filter

What we’re planning to do here is to save the Code as a cookie so that we can send it across to the Authorization Server to get the Access Token. The Code is present as a query parameter in the request URL that the Authorization Server redirects us to after logging in.

我们计划在这里做的是把代码保存为一个cookie,这样我们就可以把它发送到授权服务器上以获得访问令牌。代码作为一个查询参数出现在授权服务器在登录后将我们重定向到的请求URL中。

We’ll set up a Zuul post-filter to extract this Code and set it in the cookie. This is not just a normal cookie, but a secured, HTTP-only cookie with a very limited path (/auth/token):

我们将设置一个Zuul后置过滤器来提取这个代码并将其设置在cookie中。这不是一个普通的cookie,而是一个安全的、HTTP专用的cookie,其路径非常有限(/auth/token

@Component
public class CustomPostZuulFilter extends ZuulFilter {
    private ObjectMapper mapper = new ObjectMapper();

    @Override
    public Object run() {
        RequestContext ctx = RequestContext.getCurrentContext();
        try {
            Map<String, List> 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";
    }
}

In order to add an extra layer of protection against CSRF attacks, we’ll add a Same-Site cookie header to all our cookies.

为了增加对CSRF攻击的额外保护,我们将在所有的cookie中添加一个相同网站cookie

For that, we’ll create a configuration class:

为此,我们将创建一个配置类。

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

Here we’re setting the attribute to strict, so that any cross site transfer of cookies is strictly withheld.

在这里,我们将属性设置为strict,以便严格禁止任何跨网站的cookies传输。

6. Get and Use the Code from the Cookie

6.从Cookie中获取并使用代码

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

现在我们在cookie里有了代码,当前端Angular应用程序试图触发一个Token请求时,它将在/auth/token发送请求,因此浏览器当然会发送该cookie。

So we’ll now have another condition in our pre filter in the proxy that will extract the Code from the cookie and send it along with other form parameters to obtain the Token:

因此,我们现在将在代理中的pre过滤器中设置另一个条件,将从cookie中提取代码并与其他表单参数一起发送以获得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;
}

And here is our CustomHttpServletRequest – used to send our request body with the required form parameters converted to bytes:

这里是我们的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

7.将刷新令牌放在一个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.

我们打算在这里做的是,让客户端以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):

我们将添加到我们的Zuul后置过滤器,从响应的JSON主体中提取刷新令牌,并将其设置在cookie中。这又是一个安全的、HTTP专用的cookie,其路径非常有限(/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<String, Object> responseMap = mapper.readValue(responseBody, 
              new TypeReference<Map<String, Object>>() {});
            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.

我们可以看到,这里我们在Zuul的后置过滤器中添加了一个条件,以读取响应并提取路由auth/tokenauth/refresh的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.

然后我们从JSON响应中删除了refresh_token,以确保它永远不会被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.

这里需要注意的另一点是,我们将cookie的最大年龄设置为30天–因为这与Token的过期时间相匹配。

8. Get and Use the Refresh Token from the Cookie

8.从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.

现在我们在cookie中加入了刷新令牌,当前端Angular应用程序试图触发令牌刷新时,它将在/auth/refresh发送请求,因此浏览器,当然会发送该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:

因此,我们现在将在代理中的pre过滤器中设置另一个条件,该条件将从cookie中提取刷新令牌,并将其作为HTTP参数向前发送–这样,请求就有效了。

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.

这与我们第一次获得访问令牌时的做法相似。但请注意,表格主体是不同的。现在我们发送的是grant_typerefresh_token,而不是authorization_code以及我们之前保存在cookie中的令牌

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

获得响应后,它再次在pre过滤器中经过相同的转换,正如我们在第7节中看到的那样。

9. Refreshing the Access Token from Angular

9.从Angular刷新访问令牌

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

最后,让我们修改我们简单的前端应用程序,并实际利用刷新令牌。

Here is our function refreshAccessToken():

这里是我们的函数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.

注意我们是如何简单地使用现有的saveToken()函数的–只是向它传递不同的输入。

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.

还注意到,我们没有自己添加任何带有refresh_token的表单参数–因为这将由Zuul过滤器来处理

10. Run the Front End

10.运行前端

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

由于我们的前端Angular客户端现在被托管为Boot应用程序,运行它将与以前略有不同。

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.

这将触发frontend-maven-plugin中定义的pom.xml来构建Angular代码,并将UI工件复制到target/classes/static文件夹。这个过程会覆盖我们在src/main/resources目录下的其他东西。所以我们需要确保在复制过程中包括这个文件夹中的任何所需资源,例如application.yml

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.

在第二步中,我们需要运行我们的SpringBootApplicationUiApplication。我们的客户端应用程序将按照application.yml中指定的端口8089启动和运行。

11. Conclusion

11.结论

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.

在这个OAuth2教程中,我们学习了如何在Angular客户端应用程序中存储刷新令牌,如何刷新过期的访问令牌,以及如何利用Zuul代理来实现这一切。

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

本教程的完整实现可以在GitHub上找到