1. 概述

在本教程中,我们将继续探讨之前文章中搭建的 OAuth2 授权码流程(Authorization Code Flow),重点是如何在 Angular 应用中处理刷新令牌(Refresh Token)。我们还会使用 Zuul 代理来增强安全性。

我们使用的是 Spring Security 5 中的 OAuth2 支持。如果你希望使用旧版的 Spring Security OAuth 栈,可以参考这篇文章:OAuth2 for a Spring REST API – Handle the Refresh Token in AngularJS (legacy OAuth stack)

2. Access Token 过期处理

首先,客户端是通过授权码授权类型(Authorization Code Grant Type)分两步获取 Access Token 的。第一步是获取授权码,第二步则是换取 Access Token

Access Token 被存储在一个 cookie 中,其过期时间与 Token 本身一致:

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

⚠️ 注意:cookie 只是用来存储,不会自动发送给服务器,因此相对安全。

下面是获取 Access Token 的方法:

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

⚠️ 问题来了:client_secret 被直接写在前端代码中,这是不安全的! 接下来我们看看如何通过代理来避免这种做法。

3. 使用代理(Zuul)提升安全性

我们将在前端应用中引入一个 Zuul 代理,作为前端与授权服务器之间的中间层。所有敏感信息(如 client_secret)都由代理处理。

前端应用将作为 Spring Boot 应用运行,以便无缝集成 Zuul 代理。

如果你还不熟悉 Zuul,建议先阅读这篇文章:Spring REST with Zuul 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/

路由说明如下:

auth/code – 获取授权码并存入 cookie
auth/redirect – 重定向到授权服务器登录页
auth/resources – 映射授权服务器的登录页资源(如 CSS、JS)
auth/token – 获取 Access Token,移除 refresh_token 后存入 cookie
auth/refresh – 获取 Refresh Token,移除 refresh_token 后存入 cookie

💡 这里只代理与 Token 相关的请求,其他请求不经过代理,这样既安全又高效。

4. 使用 Zuul Pre Filter 获取授权码

我们使用 Zuul 的 pre 类型过滤器,在请求发送前动态添加必要的参数:

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

✅ 该过滤器会在请求发送前添加必要的 OAuth 参数,比如 response_type, client_id, redirect_uri 等。

5. 使用 Zuul Post Filter 存储授权码

我们将授权码存入一个安全的 HTTP-only 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";
    }
}

✅ Cookie 是 HTTP-only 的,防止 XSS 攻击。

为了防止 CSRF 攻击,我们配置 SameSite Cookie:

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

✅ 设置为 Strict,阻止跨站传输 cookie。

当 Angular 应用发起 /auth/token 请求时,浏览器会自动带上 cookie 中的授权码。我们通过 Zuul 的 pre filter 提取它并构造 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;
}

自定义 HttpServletRequest

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";
    }
}

✅ 该类用于构造带有 form 参数的 POST 请求体。

在获取 Token 的响应中,提取 refresh_token 并存入另一个 HTTP-only 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);
    }
    ...
}

✅ 这样做可以防止前端直接访问 refresh_token,增强安全性。

当需要刷新 Token 时,前端发起 /auth/refresh 请求,浏览器自动带上 cookie 中的 refresh_token:

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();
        }
    } 
    ...
}

✅ 构造 grant_type=refresh_token 的请求体,完成 Token 刷新。

9. 在 Angular 中刷新 Access Token

在前端代码中,我们只需要调用 /auth/refresh 接口即可:

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

✅ 不需要手动添加 refresh_token 参数,Zuul 会自动处理。

10. 运行前端应用

由于前端现在是一个 Spring Boot 应用,运行方式略有不同:

第一步:构建项目

mvn clean install

该命令会触发 frontend-maven-plugin 构建 Angular 代码,并将资源拷贝到 target/classes/static

第二步:启动应用

运行 UiApplication 类,应用将运行在 8089 端口。

11. 总结

本教程展示了如何在 Angular 应用中安全地存储和刷新 OAuth Token,通过 Zuul 代理隐藏敏感信息(如 client_secret 和 refresh_token),从而提升整体安全性。

完整代码可参考 GitHub 项目:https://github.com/Baeldung/spring-security-oauth/tree/master/oauth-rest


原始标题:Handle the OAuth Refresh Token from a Spring API