Spring REST API 与 OAuth2:处理 AngularJS 中的 Token 刷新问题

http://www.baeldung.com/spring-security-oauth2-refresh-token-angular-js
作者:Eugen Paraschiv
译者公众号:oopsguy_com

1、概述

在本教程中,我们将继续探索之前文章中提到的 OAuth 密码流,我们将重点介绍如何在 AngularJS 应用中处理 Refresh Token。

2、Access Token 到期

首先,请记住,当用户登录应用程序后,客户端需要得到 Access Token:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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";
});
}

请注意我们的 Access Token 存储在 Cookie 中,该 cookie 将根据令牌本身到期的时间过期。

重要的一点:cookie 本身只用于存储,它不会在 OAuth 流中驱动任何其他东西。例如,浏览器永远不会主动通过请求将 cookie 发送到服务器。

还要注意应该如何调用这个 getsAccessToken() 函数:

1
2
3
4
5
6
7
8
9
10
$scope.loginData = {
grant_type:"password",
username: "",
password: "",
client_id: "fooClientIdPassword"
};

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

3、代理

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

让我们配置代理路由:

1
2
3
4
5
zuul:
routes:
oauth:
path: /oauth/**
url: http://localhost:8081/spring-security-oauth-server/oauth

有趣的是,我们只是代理授权服务器的流量,而没有做其他事情。当客户端获取新的令牌时,我们才真正需要代理。

如果您想了解 Zuul 的基础知识,可参阅 《Spring REST 与 Zuul 代理》(可在发文历史中找到)。

4、执行 Basic Authentication 的 Zuul Filter

使用代理很简单,您不用在 javascript 中声明应用程序的客户端密钥,我们将使用 Zuul 前置过滤器来将授权头添加到获取访问令牌的请求中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@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";
}
}

请注意,这样不会增加任何额外的安全保障,我们这样做的目的是因为使用了客户端凭据的 Basic Authentication 来保护令牌端点。

从实现的角度来看,需要特别注意此过滤器的类型。我们使用“前置”类型的过滤器来处理请求,之后再把请求传递下去。

我们计划在这里让客户端将刷新令牌作为一个 cookie,但这不是一个普通的 cookie,而是有一个有着安全的限制路径(/oauth/token)和 HTTP-only 的 cookie。

我们将设置一个 Zuul 后置过滤器,从响应的 JSON 正文中提取 Refresh Token,并将其设置到 cookie 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@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";
}
}

您需要了解几件事:

  • 我们使用 Zuul 后置过滤器来读取响应并提取刷新令牌
  • 我们从 JSON 响应中删除了 refresh_token 的值,以确保它不能在 cookie 之外的前端被访问
  • 我们将 cookie 的 max age 设置为 30 天,这符合令牌的过期时间

我们在 cookie 中有了 Refresh Token,当前端 AngularJS 应用尝试触发令牌刷新时,它会将请求发送到 /oauth/token,因此浏览器当然会发送该 cookie。

因此,我们现在将在代理中使用另一个过滤器,从 Cookie 中提取 Refresh Token,并将其作为 HTTP 参数发送,是的该请求是有效的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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;
}

以下是我们的 CustomHttpServletRequest — 用于注入我们的刷新令牌参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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;
}
}

同样,这里有许多重要的实现要点:

  • 代理从 Cookie 中提取 Refresh Token
  • 之后将其设置到 refresh_token 参数
  • 它也将 grant_type 设置到 refresh_token
  • 如果没有 refreshToken cookie(过期或第一次登录),则 Access Token 请求将被重定向,而不会作出任何改变

7、AngularJS 刷新 Access Token

最后,让我们修改前端应用,并使用令牌刷新:

以下是我们的函数 refreshAccessToken()

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

以及 $scope.refreshData

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

请注意,我们简单地使用了现有的 getAccessToken 函数 — 只是传入的参数不同。

还要注意的是,我们没有添加 refresh_token,因为这属于 Zuul 过滤器负责。

8、结论

在此 OAuth 教程中,我们学习了如何在 AngularJS 客户端应用中存储 Refresh Token、如何刷新过期的 Access Token 以及如何利用 Zuul 代理这些工作。

本教程的完整实现可以在项目 github 中找到 — 这是一个基于 Eclipse 的项目,因此应该很容易导入和运行。

原文实例代码

https://github.com/eugenp/spring-security-oauth/