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 被写死在前端代码里,属于“最小泄露”原则。


这才是本文的重头戏:如何安全地存储 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),我们为所有 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
  • 即使用户点击恶意链接,也不会自动带上身份凭证

当 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


原始标题:OAuth2 for a Spring REST API – Handle the Refresh Token in AngularJS (legacy OAuth stack)