1. 概述
本文将继续深入我们之前文章中搭建的 OAuth2 密码模式(Password Flow)流程,重点讲解如何在 AngularJS 前端应用中安全地处理 刷新令牌(Refresh Token)。
⚠️ 注意:本文使用的是已进入维护模式的 Spring Security OAuth 旧版框架(即 spring-security-oauth
项目)。如果你正在使用 Spring Security 5+ 的新认证体系,请参考我们另一篇文章:《OAuth2 for a Spring REST API – Handle the Refresh Token in Angular》。
2. 访问令牌过期机制
首先回顾一下用户登录时,客户端是如何获取访问令牌(Access Token)的:
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 仅用于存储,不会被浏览器自动随请求发送 —— 这点很多人会误解
- 所有带身份的请求,仍需手动设置
Authorization: Bearer xxx
请求头
调用方式也很直接:
$scope.loginData = {
grant_type:"password",
username: "",
password: "",
client_id: "fooClientIdPassword"
};
$scope.login = function() {
obtainAccessToken($scope.loginData);
}
3. 引入 Zuul 代理层
为了增强安全性,我们在前端服务和授权服务器之间加了一层 Zuul 网关代理。
配置路由规则,只代理 /oauth/**
相关请求到授权服务器:
zuul:
routes:
oauth:
path: /oauth/**
url: http://localhost:8081/spring-security-oauth-server/oauth
📌 为什么这么做?
- 只有获取/刷新令牌这类敏感操作走代理
- 避免前端直接暴露授权服务器地址
- 后续可在代理层统一处理认证逻辑(比如自动注入 client secret)
想了解 Zuul 基础用法?可以先看这篇:《Spring REST 中使用 Zuul 代理》
4. 使用 Zuul Filter 注入 Client Secret
前端 JS 明文写 client_secret
是典型的安全隐患。我们用 Zuul 的前置过滤器(Pre Filter)来解决这个问题。
@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";
}
}
📌 核心逻辑说明:
- ✅ 拦截所有
/oauth/token
请求 - ✅ 自动添加
Authorization: Basic base64(clientId:clientSecret)
请求头 - ✅ 完全对前端透明,JS 不再需要知道
client_secret
⚠️ 注意:这并非真正提升安全性(MITM 攻击仍可能截获),但至少避免了 client_secret
被写死在前端代码里,属于“最小泄露”原则。
5. 将 Refresh Token 存入安全 Cookie
这才是本文的重头戏:如何安全地存储 Refresh Token。
思路
- 不让 Refresh Token 出现在前端 JS 可访问的范围(如 localStorage)
- 使用 HttpOnly + Secure + Path 限制 + SameSite 的强保护 Cookie
实现:Zuul 后置过滤器(Post Filter)
@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";
}
}
✅ 关键设计点:
特性 | 作用 |
---|---|
HttpOnly |
JS 无法通过 document.cookie 读取 |
Secure |
仅 HTTPS 传输 |
Path=/oauth/token |
浏览器只在请求 /oauth/token 时才发送该 Cookie |
Max-Age=30天 |
与 Refresh Token 有效期匹配 |
移除响应中的 refresh_token 字段 |
彻底防止前端获取 |
防 CSRF:启用 SameSite Cookie
进一步防御跨站请求伪造(CSRF),我们为所有 Cookie 启用 SameSite 属性:
@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);
};
}
}
SameSite=Strict
:跨站请求不携带 Cookie- 即使用户点击恶意链接,也不会自动带上身份凭证
6. 从 Cookie 中提取并使用 Refresh Token
当 Access Token 过期后,前端发起刷新请求。此时浏览器会自动带上 /oauth/token
路径下的 refreshToken
Cookie。
我们需要一个 Zuul 过滤器,将 Cookie 中的 Refresh Token 转换为标准请求参数:
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));
}
return null;
}
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;
}
自定义 Request 包装类
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;
}
}
📌 效果:
- 前端只需请求
/oauth/token?grant_type=refresh_token
- 浏览器自动带 Cookie
- Zuul 拦截并注入
refresh_token=xxx
参数 - 最终转发给授权服务器的请求是完整合法的
7. AngularJS 中实现自动刷新
前端改动非常简单,复用之前的 obtainAccessToken
方法即可:
$scope.refreshAccessToken = function() {
obtainAccessToken($scope.refreshData);
}
$scope.refreshData = {grant_type:"refresh_token"};
✅ 优点:
- 无需手动管理 Refresh Token
- 不用拼接任何额外参数
- 完全由代理层透明处理
⚠️ 踩坑提醒:确保 $http
请求走的是代理路径(如 /oauth/token
),否则 Cookie 不会发送。
8. 总结
本文通过 Zuul 代理 + 安全 Cookie 的组合拳,实现了 AngularJS 应用中 Refresh Token 的安全存储与自动刷新。
核心价值:
- ✅ 彻底避免 Refresh Token 暴露在前端 JS 环境
- ✅ 实现无感刷新,提升用户体验
- ✅ 利用网关层统一处理认证细节,前端更轻量
💡 完整代码示例已开源:GitHub - Baeldung/spring-security-oauth
分支:oauth-legacy
,模块:oauth-legacy