⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 blog.csdn.net/PleaseBeStrong/article/details/138967393 「躺平攻城狮」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注微信公众号:【芋道源码】有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言都将得到认真回复。甚至不知道如何读源码也可以请教噢。
  4. 新的源码解析文章实时收到通知。每周更新一篇左右。
  5. 认真的源码交流微信群。

1. 前言

最近在搞一个鉴权认证服务器,其中有个问题就是token的无感刷新。Token无感刷新是一种在用户不感知的情况下自动更新访问令牌(Token)的机制,以维持用户的登录状态。

一般是使用一个短期的token来做权限认证,而更长时间的refreshToken来做短token的刷新,而在实现的过程中就有各种问题出来比如:

  • Q1: 是要在服务器端实现还是能在客户端实现?
  • Q2: token过期后无法解析,怎么获取到其中的过期时间?
  • Q3: 无感刷新即是需要在获取到新token后重发原来的request请求,并将二次请求的结果返回给原调用者,如何实现?

下面我就对上面这些问题给出我自己的拙见,希望能对读者有所帮助😁

2. 客户端实现

2.1 初始版本

想法:每次客户端发起的请求会被服务器端gateway拦截,此时在gateway中判断token是否无效(过期):

  • 过期则返回一个特定的状态码(可以自定义也可以用HTTPStatus)告诉客户端当前token失效
  • 没过期则放行,继续原本的业务逻辑

而前端处可以拦截到当前服务器返回的响应状态码,根据状态码来执行对应的操作,也就是下面要引出的axios

2.1.1 服务器端gateway实现拦截器

注意环境springboot3+java17,通过继承GlobalFilter来实现对应的filter逻辑

@Component
public class MyAccessFilter implements GlobalFilter, Ordered
{
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
String uri = request.getURI().getPath();
HttpMethod method = request.getMethod();

// OPTION直接放行
if(method.matches(HttpMethod.OPTIONS.name()))
return chain.filter(exchange);

//登录请求直接放行
if(SecurityAccessConstant.REQUEST_LOGGING_URI.equals(uri) && method.matches(HttpMethod.POST.name()))
return chain.filter(exchange);

//获取token
String token = JWTHelper.getToken(request.getHeaders().getFirst(SecurityAccessConstant.HEADER_NAME_TOKEN));
if(null != token){

//判断token是否过时
if(!JWTHelper.isOutDate(token)){
return chain.filter(exchange);

}else{
if(!SecurityAccessConstant.REQUEST_REFRESH.equals(uri)) //当前不是刷新请求可以刷新返回的状态码就是511
return ResponseUtils.out(exchange , ResultData.fail(ResultCodeEnum.NEED_TO_REFRESH_TOKEN.getCode(),
ResultCodeEnum.NEED_TO_REFRESH_TOKEN.getMessage()));

//当前是刷新请求 但refreshToken都过期了,即刷新不支持
return ResponseUtils.out(exchange , ResultData.fail(ResultCodeEnum.RC401.getCode(), ResultCodeEnum.RC401.getMessage()));
}

}

return ResponseUtils.out(exchange , ResultData.fail(ResultCodeEnum.RC401.getCode(), ResultCodeEnum.RC401.getMessage()));
}

@Override
public int getOrder() {
//数值越小 优先级越高
return Ordered.LOWEST_PRECEDENCE;
}
}

2.1.1.1 问题Q2解决

正常情况下解析的token会报错,那么就在解析的时候拦截错误,如果catch 到JwtException,此时就认为该token无效已经过期了返回true

否则则执行正常逻辑获取并返回token中的过期时间与当前时间比较的结果

//判断当前token是否过期
public static boolean isOutDate(String token){
try {
Jws<Claims> claimsJws = Jwts.parser().setSigningKey(tokenSignKey).parseClaimsJws(token);
Date expirationDate = claimsJws.getBody().getExpiration();
return expirationDate.before(new Date());
} catch (JwtException e) {
// JWT token无效或已损坏
return true;
}
}

2.1.2 axios拦截器

在拦截器中,我们使用判断响应码,如果是401则清空用户数据回退到登录页面,而如果是511则使用refreshToken再请求刷新一次(其他的情况在这里就不做分析,感兴趣的读者可以自行研究)

// 响应拦截器
service.interceptors.response.use(

// 响应成功进入第1个函数
// 该函数的参数是响应对象
function(response) {
console.log(response)
return response.data.data;
},
// 响应失败进入第2个函数,该函数的参数是错误对象
async function(error) {
// 如果响应码是 401 ,则请求获取新的 token
// 响应拦截器中的 error 就是那个响应的错误对象
if(error.response == undefined)
return Promise.reject(error);

const status = error.response.status
const authStore = useAuthStore()
let message = ''

switch(status){
case 401: // 无权限
authStore.reset() // 清空store中的权限数据
window.sessionStorage.removeItem('isAuthenticated')
window.sessionStorage.removeItem('token')
window.sessionStorage.removeItem('refreshToken')
message = 'token 失效,请重新登录'
// 跳转到登录页
window.location.href = '/auth/login';
break;
case 511: // 当前token需要刷新
try {
const data = refresh()

if(data !== null){
data.then((value) => {
// Use the string value here
if(value !== ''){
// 如果获取成功,则把新的 token 更新到容器中
console.log("刷新 token 成功", value);
window.sessionStorage.setItem("token",value)
// 把之前失败的用户请求继续发出去
// config 是一个对象,其中包含本次失败请求相关的那些配置信息,例如 url、method 都有
// return 把 request 的请求结果继续返回给发请求的具体位置
error.config.headers['Authorization'] = 'Bearer ' +value;
return service(error.config);
}
console.log(value);
}).catch((error) => {
// Handle any errors that occurred while resolving the promise
console.error(error);
});

}

} catch (err) {
// 如果获取失败,直接跳转 登录页
console.log("请求刷线 token 失败", err);
router.push("/login");
}
break;
case '403':
message = '拒绝访问'
break;
case '404':
message = '请求地址错误'
break;
case '500':
message = '服务器故障'
break;
default:
message = '网络连接故障'

}

Message.error(message)
return Promise.reject(error);
}
);

2.1.3 refresh刷新token方法实现

这里实现是重新用axios原生发异步请求,而不是使用在request.ts 中导出的请求方法(因为里面定义了请求拦截器,每次请求之前都会取出token并放到请求头,这就又变成请求头中携带的token无效了,导致重复发送刷新请求进入死循环,所以不能这样做)

/**
* 刷新token
* 成功返回新token
* 失败返回空字符串''
*/
export async function refresh() : Promise<string>{
const refreshToken = window.sessionStorage.getItem("refreshToken")
console.log("in >>> " ,refreshToken)
if(refreshToken == undefined)
return '' //本来就没有这个更新token则直接返回

try {
const response = await axios({
method: 'GET',
url: 'http://127.0.0.1:9001/api/simple/cloud/access/refresh',// 认证服务器地址
headers: {
Authorization: `Bearer ${refreshToken}`, //header中放入的是refreshToken用于刷新请求
},
});
// 如果顺利返回会得到 data,由于后端使用统一结果返回ResultData,所以会多封装一层code、data
if (response.data) {
return response.data.data; //所以这里有两个data
} else {
return '';
}
} catch (error) {
console.log(error);
return '';
}
}

2.1.4 正常和刷新情况下的console输出信息分析

细心的读者可以注意到上边的代码有很多地方有控制台的输出,加上这些可以更方便的读懂代码的逻辑,下面我们就运行代码跑跑看看结果返回情况,这里建议各位结合代码分析看看我做输出的地方是在哪里。

下图是正常情况下的返回结果,注意这里的token是以hizFIGg结尾,而refreshToken是以suvm-EgQ结尾(这两个注意与异常的来比对)正常情况下返回的结果肯定是200即ok

“

注意>>>>>处输出的结果是点击该按钮后点击事件返回的结果,对应着Q3的思考,具体分析会结合失败的例子来演示

下面来看异常情况的分析,由于token太长了,所以拆分两张图片更容易看一点,从左边的图开始分析

  • 在发起第一次请求后,后端gateway拦截器报错 511 (是不是就是对应上面case 511 此时应该用refresh token刷新)
  • in ?>> 进来refresh方法的逻辑,成功打印出refreshToken 以suvm-EgQ结尾(是不是跟上面refreshToken相同)
  • 紧接着就是 输出 刷新token成功 此时返回的是刷新后的token,将其覆盖新的token并重新发送请求

到这里左图分析完毕,进入右图的分析(肯定有读者疑惑你这黄色的warn咋不讲)别急这块我会和右图的红色error一起讲解

  • 紧接上面,用新的token发送请求,此时在请求拦截器处捕获到的token是不是就是更新好的 以V0dYcMA结尾,而refreshToken则以suvm-EgQ结尾(得出结论refreshToken用做刷新,但本身并不刷新)
  • 此时捕获到Uncaught error status 511 这不就是我们一开始的报错吗? 其实就是这样的,原来的按钮点击事件调用getAllUser方法已经结束!!! 返回的结果是error 即是这里的511(把左右三个有颜色的块拼起来一起看就懂了)而由于refresh方法是异步调用的所以其执行顺序穿插在其中

最后返回结果可以看到已经没有上面注意部分提到的>>>>>输出内容,令通过更新好的token发送二次请求得到的结果记作data,此时的data已经不能返回原来的getAllUser方法调用处,因为原来的方法已经结束,通俗点话说就是这样的二次调用结果毫无意义,用户还是需要刷新网页或者二次点击以获取资源

这就是Q3提出的思考,由于异步调用而非阻塞式的调用方式导致原方法提前终止,可以考虑换成阻塞式的调用refresh方式刷新token,但是这样又会导致该次点击的响应变慢,用户体验差(有更好想法的读者可以在评论区一起讨论)

2.2 改进版本

既然异步方法不得行,那能不能换种思路?不要在失败的时候发送,而是提前检查存在本地的token有没有过期,当检查token过期时间小于一个临界点,则异步调用刷新token方法,更新现有的token信息,此时是不是就解决上面的问题,只要是服务器端gateway拦截到token失效的请求我都要求重新登录。此时就引出一个定时器的概念

“

在TypeScript中,定时器主要是指通过setInterval和setTimeout这两个函数来实现的周期性或延时执行代码的功能。

首先,setInterval是一个可以按照指定的时间间隔重复执行某段代码或函数的方法。它接受两个参数:第一个参数是你想要周期性执行的函数或代码块,第二个参数是时间间隔,单位为毫秒。

由于当setInterval被调用时,它会在指定的时间间隔后执行给定的函数或代码块。这个时间间隔是以毫秒为单位的,而且它是从调用setInterval的那一刻开始计算的。这意味着一旦setInterval被调用,定时器就会立即启动,并在每个指定的时间间隔后重复执行。所以该定时器的设定应该放在login方法登录返回结果处

2.2.1 定义定时器类

通过该定时器类,可以实现MyTimer.start 方法调用setInterval 间隔delay 时间步执行,判断当前的token过期时间是否小于我们设置的minCheck , 如果小于则使用refreshToken异步刷新token

import { refresh } from "@/api/system/auth/index"
import { jwtDecode } from "jwt-decode";

export class MyTimer {
private timerId: any | null = null;
// delay为重复探查的间隔时间 , minCheck是判断token是否是快过期的
start(delay: number, minCheck : number): void {
this.timerId = setInterval(async () => {
const currentToken = window.sessionStorage.getItem('token');
console.log("timer++++")
if (currentToken) {
// 如果存在token,判断是否过期
let expirationTime = 0;
expirationTime = getExpirationTime(currentToken) ; // 假设有一个函数用于获取token的过期时间

const timeRemaining = expirationTime - Date.now();
if (timeRemaining <= minCheck) {
// 如果剩余时间小于等于5分钟,则异步发送刷新请求并更新token
await refresh();
}
} else {
// 如果不存在token,则直接发送刷新请求并更新token
await refresh();
}
}, delay);
}

stop(): void {
if (this.timerId !== null) {
clearInterval(this.timerId);
this.timerId = null;
}
}
}
// 获取过期时间
function getExpirationTime(rawToken:string) : number{
const res = jwtDecode(rawToken)

return res.exp as number
}

2.2.2 修改Login点击事件

只用看新增的方法,其他的都是一些权限跟token等的存储

import { MyTimer } from "@/utils/tokenMonitor"

const submit = () => {
if (validate()) {
login(formData)
.then((data: UserInfoRes) => {
if (data) {
// 在这里添加需要执行的操作
const token = data.token;

// 将token存储到authStore中
const authStore = useAuthStore()
authStore.setToken(token)
window.sessionStorage.setItem('token', token)
window.sessionStorage.setItem('refreshToken', data.refreshToken)
authStore.setIsAuthenticated(true)
window.sessionStorage.setItem('isAuthenticated', 'true')
authStore.setName(data.name)
authStore.setButtons(data.buttons)
authStore.setRoles(data.roles)
authStore.setRouters(data.routers)
//新增 引入计时器》》》》》》》》》》》》》》》》》》》》》》》》》》》》》》
const clock = new MyTimer();
clock.start(1000*30,1000*30);

init({ message: "logged in success", color: 'success' });
push({ name: 'dashboard' })

}
})
.catch(() => {
init({ message: "logged in fail , please check carefully!", color: '#FF0000' });
});

}else{
Message.error('error submit!!')
return false
}
}

2.2.3 测试

按理来说测试时候应该没有问题,能正确解析token,而实际运行时候却报错,无法正确解析token报错

“

InvalidTokenError: Invalid token specified: invalid json for part #2

而后续换成jwt.verify()使用密钥来解码同样报错,甚至无法加载出页面,console中报错信息如下

半天这token解析不了就很奇怪了,后面在网上查阅资料的过程中总结出来,由于后端生成的token是通过jjwt这个依赖实现的,对于不同的库底层的编码实现逻辑会有差异导致a库加密生成的token并不能完全被b库的方法来解密

找到了原因,那我们应该如何获取token中的过期时间呢?可以使用与jjwt相同的实现逻辑库来解码该token或者不妨换个思路,从服务器端下发token的时候我就带上这个过期时间,这样就省去了前端解码这个步骤,所以就引出了如下最终实现版本

2.3 最终定时器版本(实现可以直接看这里)

2.3.1 服务器端修改

2.3.1.1 根据token获取其过期时间

// 获取当前token过期时间 这里不判断是否过期因为是通过了过期判断才进来的
public static Date getExpirationDate(String token) {
if(StringUtil.isBlank(token))
return null;

Claims claims = Jwts.parser().setSigningKey(tokenSignKey).parseClaimsJws(token).getBody();
return claims.getExpiration();
}

2.3.1.2 发放token处携带过期时间

//存放token到请求头中
String[] tokenArray = JWTHelper.createToken(sysUser.getId(), sysUser.getEmail(), permsList);
map.put("token",tokenArray[0]);
// 新增设置过期时间 毫秒数
map.put("tokenExpire",JWTHelper.getExpirationDate(tokenArray[0]).getTime());
map.put("refreshToken",tokenArray[1]);

同样在refreshToken处也就不是只返回token,也需要带上其过期时间,代码与上面相同就不重复写了

2.3.2 修改监控器类MyTimer

最终版本该类中包含这三个属性,分别是

  • timerId: 定时器的唯一ID
  • delay: 定时器执行的间隔时间
  • minCheck: 判断token过期时间是否小于该值,小于则需执行refresh() 方法来刷新token。

同时使用单例模式全局导出唯一的实例方便管理,对于上面的token无法解析问题,直接从服务器端获取token的过期时间expire然后与当前时间比较就好啦。

import { refresh } from "@/api/system/auth/index"

class MyTimer {
private timerId: any | null = null;
private delay: number; //执行间隔时间
private minCheck: number; //判断token过期时间是否小于该值

private static instance: MyTimer;

public static getInstance(): MyTimer {
if (!MyTimer.instance) {
MyTimer.instance = new MyTimer();
}
return MyTimer.instance;
}

private constructor() {
this.delay = 30000; // Default delay value in milliseconds
this.minCheck = 60000; // Default minCheck value in milliseconds (1 minutes)
}
//启动监控器的方法
start(): void {
this.timerId = setInterval(async () => {
const currentToken = window.sessionStorage.getItem('token');
console.log("timer++++",currentToken)
if (currentToken) {
// 如果存在token,判断是否过期
const tokenExpireStr = window.sessionStorage.getItem('tokenExpire') as string// 假设有一个函数用于获取token的过期时间
const expirationTime = parseInt(tokenExpireStr, 10); //以10进制转换string字符串
const timeRemaining = expirationTime - Date.now();
console.log("ttime sub++++",timeRemaining)
if (timeRemaining <= this.minCheck) {
// 如果剩余时间小于等于minCheck分钟,则异步发送刷新请求并更新token
try{
await refresh();
}catch (error) {
console.error('刷新失败:', error);
window.sessionStorage.removeItem('isAuthenticated')
window.sessionStorage.removeItem('token')
window.sessionStorage.removeItem('refreshToken')
Message.error("token reflesh got some ploblem , please login")
// 跳转到登录页的代码
window.location.href = '/auth/login';
}
}
} else {
Message.error("token invalidate , please login")
// token不存在 则跳转到登录页
window.location.href = '/auth/login';
}
}, this.delay);

console.log(this.timerId)
}
//关闭监控器的方法
stop(): void {
if (this.timerId !== null) {
clearInterval(this.timerId);
this.timerId = null;
}
}
//提供设置监控器的刷新间隔和需要刷新的阈值
setDelay(delay: number): void {
this.delay = delay;
}

setMinCheck(minCheck: number): void {
this.minCheck = minCheck;
}
}
//导出全局唯一的实例方便管理
export const myFilterInstance = MyTimer.getInstance();
// 加到每一个页面上,当页面刷新时候则重启定时器,防止定时器刷掉
export function onPageRender(){
// Stop the current timer if it's running
myFilterInstance.stop();

// Start the timer with the updated delay and minCheck values
myFilterInstance.start();
}

2.3.3 onPageRender 使用

需要注意最后一个方法onPageRender,由于在测试中发现当通过导航栏访问的页面情况下会导致定时器给kill掉了,无法刷新token,发送新请求的时候就会报错,所以最好的方法是在每个页面上添加onPageRender 方法,该方法也很简单就是重启一下定时器,只要给定时器刷新token就能解决上面的问题

在页面中添加的代码如下:

import { onPageRender } from '@/utils/tokenMonitor'
// 新增一个监听器,在页面渲染时候执行
window.addEventListener('load', () => {
onPageRender();
});

2.3.4 测试

根据最终的测试结果(下图,读者可以结合代码中输出语句来看)

  • 可以看到红色的框框就是进入监控器输出的内容,每次进入都会比对token的过期时间判断是否小于阈值(刷新完后还会用新的过期时间继续比较)
  • 当小于阈值(这里设置1min = 60000ms)则进入refresh逻辑,这个就是上面讲到的内容,一样样的,这样就保证每次刷新携带的token大概率都是最新的!!!😁到此客户端实现功能已经全部讲完啦

3. 服务器端实现

这种实现方法是在gateway处做拦截判断当前的token是否过期,如果过期则通过WebClient携带refreshToken异步发起请求到认证服务器更新,下面代码实现了发起请求到获取数据的过程,但是没有实现原来请求的再发送(偷个懒,后面再来填坑)

// 向认证服务器发送请求,获取新的token
Mono<ResultData> newTokenMono = WebClient.create().get()
.uri(buildUri(SecurityAccessConstant.WEB_REQUEST_TO_AUTH_URL+SecurityAccessConstant.REQUEST_REFRESH
, new String[]{"refreshToken", token}))
.retrieve()
.bodyToMono(ResultData.class);


// 原子操作
AtomicBoolean isPass = new AtomicBoolean(false);
//订阅数据
newTokenMono.subscribe(resultData -> {
if(resultData.getCode() == "200"){
exchange.getRequest().getHeaders().set(SecurityAccessConstant.HEADER_NAME_TOKEN,
SecurityAccessConstant.TOKEN_PREFIX + resultData.getData());
isPass.set(true);
}

}).dispose(); // 销毁资源

if(isPass.get()){
// 如果成功获取到资源(新token则发送新请求)
return chain.filter(exchange.mutate().request().build());
}

4. 怎么选择

在服务器端实现的好处如下:

  • 安全性: 在服务器端进行token刷新可以更好地控制和保护token的安全性,避免将敏感信息暴露给客户端
  • 减少客户端逻辑: 客户端无需过多关注token刷新逻辑,降低了客户端的复杂性和维护成本。
  • 集中管理: 所有用户的token刷新逻辑集中在服务器端,方便统一管理和调整。
  • 解决一致性问题: 用户端刷新token可能导致不同客户端之间的状态不一致,比如一个设备刷新了token而另一个设备未刷新,可能会出现异常情况。

而在客户端实现的好处又如下:

  • 即时性: 客户端自动监控可以实现实时监测token的有效性,并及时触发刷新,确保用户操作的流畅性和体验。
  • 离线支持: 对于需要离线访问或长时间不与服务器通信的应用场景,客户端自动监控可以更好地处理token失效情况。
  • 灵活性: 某些特定场景下,客户端可能更容易实现对token状态的监控和处理,例如需要根据用户行为动态调整token刷新策略等。
  • 减轻服务器压力: 用户端刷新token可以减少服务器负担,尤其对于大量用户同时刷新token时,可分散处理压力。

可见在不同的场景下实现的方法有所不同,要根据实际需求来决定,往往在一些高精度高安全性的系统中适合在服务器端做token的刷新,其他场景(例如移动端应用或简单的 Web 应用等)下可以尝试客户端实现的方法分担服务器压力。

文章目录
  1. 1. 1. 前言
  2. 2. 2. 客户端实现
    1. 2.1. 2.1 初始版本
      1. 2.1.1. 2.1.1 服务器端gateway实现拦截器
        1. 2.1.1.1. 2.1.1.1 问题Q2解决
      2. 2.1.2. 2.1.2 axios拦截器
      3. 2.1.3. 2.1.3 refresh刷新token方法实现
      4. 2.1.4. 2.1.4 正常和刷新情况下的console输出信息分析
    2. 2.2. 2.2 改进版本
      1. 2.2.1. 2.2.1 定义定时器类
      2. 2.2.2. 2.2.2 修改Login点击事件
      3. 2.2.3. 2.2.3 测试
    3. 2.3. 2.3 最终定时器版本(实现可以直接看这里)
      1. 2.3.1. 2.3.1 服务器端修改
        1. 2.3.1.1. 2.3.1.1 根据token获取其过期时间
        2. 2.3.1.2. 2.3.1.2 发放token处携带过期时间
      2. 2.3.2. 2.3.2 修改监控器类MyTimer
      3. 2.3.3. 2.3.3 onPageRender 使用
      4. 2.3.4. 2.3.4 测试
  3. 3. 3. 服务器端实现
  4. 4. 4. 怎么选择
    1. 4.0.0.1. 在服务器端实现的好处如下:
    2. 4.0.0.2. 而在客户端实现的好处又如下: