环境
springboot 3.1.5
maven:3.6.1
java:17
思路
- 续期在后端完成,只给前端传递一个token,不采用两个token的方式,从而不增加前端工作量
- 默认情况下解析Jwt token 时过期的token将抛出过期错误,因为需要自动续期,因此应忽略该错误。见代码片段1
- token等信息保存在Redis中,并设置过期时间为token的过期时间。过期后保存信息自动删除 。修改此处过期时间实现延期
- 记录每次前端请求的时间至Redis中。
- 验证token有效性时,若距离最近一次请求的时差未超过定义的过期时间增量,则尝试进行续期操作。见代码片段2。代码注解很详细,就不在重复
代码片段1
/**
* 获取Claims
*
* @param bearerToken 带前缀的token
* @return Claims
*/
public static Claims getClaims(String bearerToken) {
if (StringUtils.isBlank(bearerToken)) {
return null;
}
Claims claims = null;
try {
String token = formBearerTokenToToken(bearerToken);
if (StringUtils.isNotBlank(token)) {
claims = Jwts.parser().setSigningKey(JwtParams.getSecret()).parseClaimsJws(token).getBody();
}
} catch (ExpiredJwtException e) {
//过期依然返回claims
claims = e.getClaims();
// log.info("获取Claims时已过期, Error:{}; token:{}", e.getLocalizedMessage(), bearerToken);
} catch (Exception e) {
log.info("获取Claims时出错,token:{}; Error:{}", bearerToken, e.getLocalizedMessage());
}
return claims;
}
代码片段2
/**
* @description 判断request的token是否有效,若过期尝试自动续期
* <br>
* <pre>
* 判断及操作流程:
* 1.从request中获取token,其是否格式正确(前缀及签发者)且含有userId。为假,直接返回false
* 2.判断request中token是否与cache中的token一致。不一致,直接返回false
* 3.request中的token,是否未过期
* 3.1 token未过期,直接返回true!!
* 3.2 token过期,判断最后一次操作与当前时间差是否超过配置的过期时间增量(JwtParams.getExpireTime())
* 3.2.1 超过配置的过期时间增量,返回false
* 3.2.2 未超过,尝试自动续期
* 3.2.2.1 自动续期成功,返回true!!
* 3.2.2.2 自动续期失败,返回false
* <pre>
* @param request request
* @return boolean 是否成功
* @author MuYi
* @date 2023/12/12 12:57
*/
public boolean tokenIsValid(HttpServletRequest request) {
if (request == null) return true;
String bearerToken = JwtTokenUtils.getToken(request);
// 验证request是否格式及签发者正确
if (!JwtTokenUtils.tokenIsCorrect(bearerToken)) {
return false;
}
Long userId = JwtTokenUtils.getUserId(bearerToken);
// 验证令牌是否含userId
if (userId == null) {
log.info("token({})内无法解析有效用户id", bearerToken);
return false;
}
//以上检查request中的token格式及签发者是否正确、否含userId
//以下检查request与cache中的token是否一致
String cacheToken = JwtCacheUtils.getTokenFromCache(userId);
String requestInfo = request.getLocalAddr() + ":" + request.getLocalPort() + request.getRequestURI();
//cache中无token。表示用户未登录或已注销或系统重启
//依据前提:
// 1 token的核验以cache中的token为准
// 2 当用户登录时cache中保存其token
// 3 当用户注销时、超时、系统关闭时删除其cache中的token
if (cacheToken == null) {
forceLogout(userId, "使用已被注销令牌", requestInfo);
return false;
}
if (!bearerToken.equals(cacheToken)) {
forceLogout(userId, "令牌不一致", requestInfo);
return false;
}
// 已不再考察request中token的过期时间
// // 验证request中的token是否未过期
// if (!JwtTokenUtils.tokenIsExpired(bearerToken)) return true;
//验证距离最后一次操作时间是否超过配置的过期时间增量
long lastOperateTime = JwtCacheUtils.getLastOperateTime(userId).getTime();
long currentTime = System.currentTimeMillis();
//计算距离最后一次操作时间差,增加TOKEN_ADVANCE_CHECK_TIME时间量,以免出现误差
long diff = currentTime + TOKEN_ADVANCE_CHECK_TIME - lastOperateTime;
//时间差大于过期时间增量,表示令牌过期
if (diff > JwtParams.getExpireDuration()) {
forceLogout(userId, "令牌过期", requestInfo);
return false;
}
//时间差小于配置的过期时间增量,表示令牌未过期。需计算局里上一次需求时差,避免频繁更新cache
//尝试自动续期
diff = currentTime - lastOperateTime;
if (diff >= TOKEN_ADVANCE_CHECK_TIME) {
// 在尝试自动续期前应用防抖逻辑
String debounceKey = "renewalToken:" + userId;
DebounceUtil.debounce(debounceKey, userId, (userIdToRenew) -> {
Date renewalDate = JwtCacheUtils.renewalTokenExpireTimeout(userId);
if (renewalDate == null)
forceLogout(userId, "尝试自动续期失败", requestInfo);
}, ANTI_SHAKE_DELAY); // 假设设置防抖延迟为500毫秒
}
return true;
}
protected void forceLogout(Long userId, String msg, String requestInfo) {
try {
log.info("{},将强制用户(ID={})退出。(request:{})", msg, userId, requestInfo);
logoutProcess(userId);
// request.setAttribute("renewal",false);
} catch (Exception e) {
log.info("{},将强制用户(ID={})退出。(request:{})。发生错误{}", msg, userId, requestInfo, e.getLocalizedMessage());
}
}
BUG修复
-
2024.6.4
- tokenIsValid方法逻辑错误
由于采用后台核对cache的token过期信息,且前端request中的token亦从未更新,因此不应再考察request中token的过期时间,否则整个方法逻辑都是错误的。所以,移除if (!JwtTokenUtils.tokenIsExpired(bearerToken)) return true;
代码 - 防抖函数应用
由于tokenIsValid在某一个会被接连调用譬如间隔1毫秒。导致renewalTokenExpireTimeout(userId)方法可能在几毫秒内被多次调用,因此增加防抖函数。
此方法非必须。
- tokenIsValid方法逻辑错误