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 攻击。
增强安全性:设置 SameSite Cookie
为了防止 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。
6. 从 Cookie 中读取授权码并换取 Token
当 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 请求体。
7. 将 Refresh Token 存入 Cookie
在获取 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,增强安全性。
8. 从 Cookie 中读取 Refresh Token 并刷新 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