OAuth2 for a Spring REST API – Handle the Refresh Token in AngularJS (legacy OAuth stack) – Spring REST API的OAuth2 – 在AngularJS中处理刷新令牌(传统的OAuth栈)

最后修改: 2020年 4月 17日

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

1. Overview

1.概述

In this tutorial, we’ll continue exploring the OAuth password flow that we started putting together in more our previous article and we’ll focus on how to handle the Refresh Token in an AngularJS app.

在本教程中,我们将继续探索我们在更多我们之前的文章中开始整理的OAuth密码流程,我们将重点讨论如何在AngularJS应用程序中处理刷新令牌。

Note: this article is using the Spring OAuth legacy projectFor the version of this article using the new Spring Security 5 stack, have a look at our article OAuth2 for a Spring REST API – Handle the Refresh Token in Angular.

注意:本文使用的是Spring OAuth 传统项目对于使用新的 Spring Security 5 堆栈的本文版本,请查看我们的文章用于 Spring REST API 的 OAuth2 – 在 Angular 中处理 Refresh Token

2. Access Token Expiration

2.访问令牌过期

First, remember that the client was obtaining an Access Token when the user was logging into the application:

首先,请记住,当用户登录到应用程序时,客户端正在获得一个访问令牌。

function obtainAccessToken(params) {
    var req = {
        method: 'POST',
        url: "oauth/token",
        headers: {"Content-type": "application/x-www-form-urlencoded; charset=utf-8"},
        data: $httpParamSerializer(params)
    }
    $http(req).then(
        function(data) {
            $http.defaults.headers.common.Authorization= 'Bearer ' + data.data.access_token;
            var expireDate = new Date (new Date().getTime() + (1000 * data.data.expires_in));
            $cookies.put("access_token", data.data.access_token, {'expires': expireDate});
            window.location.href="index";
        },function() {
            console.log("error");
            window.location.href = "login";
        });   
}

Note how our Access Token is stored in a cookie which will expire based on when the token itself expires.

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

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

需要理解的是,cookie本身只用于存储,它并不驱动OAuth流程中的任何其他东西。例如,浏览器永远不会自动将cookie与请求一起发送到服务器上。

Also note how we actually call this obtainAccessToken() function:

还要注意我们实际上是如何调用这个obtainAccessToken()函数的。

$scope.loginData = {
    grant_type:"password", 
    username: "", 
    password: "", 
    client_id: "fooClientIdPassword"
};

$scope.login = function() {   
    obtainAccessToken($scope.loginData);
}

3. The Proxy

3.代理权

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.

我们现在要在前端应用程序中运行一个Zuul代理,基本上位于前端客户端和授权服务器之间。

Let’s configure the routes of the proxy:

让我们来配置代理的路线。

zuul:
  routes:
    oauth:
      path: /oauth/**
      url: http://localhost:8081/spring-security-oauth-server/oauth

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.

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

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

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

4. A Zuul Filter That Does Basic Authentication

4.一个进行基本认证的Zuul过滤器

The first use of the proxy is simple – instead of revealing our app “client secret” in javascript, we will use a Zuul pre-filter to add an Authorization header to access token requests:

代理的第一个用途很简单–我们将使用Zuul预过滤器为访问令牌请求添加一个授权头,而不是在javascript中透露我们的应用程序”client secret“。

@Component
public class CustomPreZuulFilter extends ZuulFilter {
    @Override
    public Object run() {
        RequestContext ctx = RequestContext.getCurrentContext();
        if (ctx.getRequest().getRequestURI().contains("oauth/token")) {
            byte[] encoded;
            try {
                encoded = Base64.encode("fooClientIdPassword:secret".getBytes("UTF-8"));
                ctx.addZuulRequestHeader("Authorization", "Basic " + new String(encoded));
            } catch (UnsupportedEncodingException e) {
                logger.error("Error occured in pre filter", e);
            }
        }
        return null;
    }

    @Override
    public boolean shouldFilter() {
        return true;
    }

    @Override
    public int filterOrder() {
        return -2;
    }

    @Override
    public String filterType() {
        return "pre";
    }
}

Now keep in mind that this doesn’t add any extra security and the only reason we’re doing it is because the token endpoint is secured with Basic Authentication using client credentials.

现在请记住,这并没有增加任何额外的安全性,我们这样做的唯一原因是,令牌端点是通过使用客户凭证的基本认证来保证安全。

From the point of view of the implementation, the type of filter is especially worth noticing. We’re using a filter type of “pre” to process the request before passing it on.

从实现的角度来看,过滤器的类型特别值得注意。我们使用的过滤器类型是 “pre”,在传递请求之前对其进行处理。

5. Put the Refresh Token in a Cookie

5.将刷新令牌放在一个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. Not just a normal cookie, but a secured, HTTP-only cookie with a very limited path (/oauth/token).

我们计划在这里做的是,让客户端以cookie的形式获得刷新令牌。不仅仅是一个普通的cookie,而是一个安全的、HTTP专用的cookie,其路径非常有限(/oauth/token)。

We’ll set up a Zuul post-filter to extract Refresh Token from the JSON body of the response and set it in the cookie:

我们将设置一个Zuul后置过滤器,从响应的JSON体中提取Refresh Token并将其设置在cookie中。

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

    @Override
    public Object run() {
        RequestContext ctx = RequestContext.getCurrentContext();
        try {
            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.setSecure(true);
                cookie.setPath(ctx.getRequest().getContextPath() + "/oauth/token");
                cookie.setMaxAge(2592000); // 30 days
                ctx.getResponse().addCookie(cookie);
            }
            ctx.setResponseBody(responseBody);
        } catch (IOException e) {
            logger.error("Error occured in zuul post filter", e);
        }
        return null;
    }

    @Override
    public boolean shouldFilter() {
        return true;
    }

    @Override
    public int filterOrder() {
        return 10;
    }

    @Override
    public String filterType() {
        return "post";
    }
}

A few interesting things to understand here:

这里有几件有趣的事情需要了解。

  • We used a Zuul post-filter to read response and extract refresh token
  • We removed the value of the refresh_token from JSON response to make sure it’s never accessible to the front end outside of the cookie
  • We set the max-age of the cookie to 30 days – as this matches the expire time of the token

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 Refresh Token from the Cookie

6.从Cookie中获取并使用刷新令牌

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

现在我们在cookie里有了刷新令牌,当前端AngularJS应用程序试图触发令牌刷新时,它将在/oauth/token发送请求,因此浏览器,当然会发送该cookie。

So we’ll now have another 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:

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

public Object run() {
    RequestContext ctx = RequestContext.getCurrentContext();
    ...
    HttpServletRequest req = ctx.getRequest();
    String refreshToken = extractRefreshToken(req);
    if (refreshToken != null) {
        Map<String, String[]> param = new HashMap<String, String[]>();
        param.put("refresh_token", new String[] { refreshToken });
        param.put("grant_type", new String[] { "refresh_token" });
        ctx.setRequest(new CustomHttpServletRequest(req, param));
    }
    ...
}

private String extractRefreshToken(HttpServletRequest req) {
    Cookie[] cookies = req.getCookies();
    if (cookies != null) {
        for (int i = 0; i < cookies.length; i++) {
            if (cookies[i].getName().equalsIgnoreCase("refreshToken")) {
                return cookies[i].getValue();
            }
        }
    }
    return null;
}

And here is our CustomHttpServletRequest – used to inject our refresh token parameters:

这里是我们的CustomHttpServletRequest–用于注入我们的刷新标记参数

public class CustomHttpServletRequest extends HttpServletRequestWrapper {
    private Map<String, String[]> additionalParams;
    private HttpServletRequest request;

    public CustomHttpServletRequest(
      HttpServletRequest request, Map<String, String[]> additionalParams) {
        super(request);
        this.request = request;
        this.additionalParams = additionalParams;
    }

    @Override
    public Map<String, String[]> getParameterMap() {
        Map<String, String[]> map = request.getParameterMap();
        Map<String, String[]> param = new HashMap<String, String[]>();
        param.putAll(map);
        param.putAll(additionalParams);
        return param;
    }
}

Again, a lot of important implementation notes here:

同样,这里有很多重要的实施说明。

  • The Proxy is extracting the Refresh Token from the Cookie
  • It’s then setting it into the refresh_token parameter
  • It’s also setting the grant_type to refresh_token
  • If there is no refreshToken cookie (either expired or first login) – then the Access Token request will be redirected with no change

7. Refreshing the Access Token from AngularJS

7.从AngularJS刷新访问令牌

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

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

Here is our function refreshAccessToken():

这里是我们的函数refreshAccessToken()

$scope.refreshAccessToken = function() {
    obtainAccessToken($scope.refreshData);
}

And here our $scope.refreshData:

而这里我们的$scope.refreshData

$scope.refreshData = {grant_type:"refresh_token"};

Note how we’re simply using the existing obtainAccessToken function – and just passing different inputs to it.

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

Also notice that we’re not adding the refresh_token ourselves – as that’s going to be taken care of by the Zuul filter.

还注意到我们没有自己添加refresh_token–因为这将由Zuul过滤器来处理。

8. Conclusion

8.结论

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

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

The full implementation of this tutorial can be found in the github project.

本教程的完整实现可以在github项目中找到