登录 token 12h 静默失效补 silent refresh 接口 #3392 (#3402)

* 登录 token 12h 静默失效补 silent refresh 接口 #3392

方案 A:后端 POST /xiaozhi/user/refresh(anon),旧 token 校验通过后强制覆盖为新 token 字符串,旧 token 在 DB 查不到即失效。前端 httpRequest 在 sendRequest 入口检查 token 剩余有效期,剩 1h 内触发 silent refresh(独立 fly 实例 + 单飞锁),成功后 setToken 替换并自动继续发送当前请求,失败走 clearAuth 跳登录。

* refreshToken 改 CAS 防并发 race + 拆前端副作用 #3392

P1:SysUserTokenDao 新增 refreshTokenCAS,条件 UPDATE(oldToken + 未过期 + userId),受影响 0 行抛 TOKEN_INVALID;refreshToken 改调它,避免读-改-写 race。P2:refreshToken 加 @Transactional;httpRequest.getTokenExpireAt 内的 localStorage.setItem 挪到独立 persistTokenExpireAt,由 shouldRefresh 入口调用。

* getTokenExpireAt 改读 localStorage 与持久化同源 #3392

pr01 三改:原代码 getTokenExpireAt 读 store(state.token 缓存),与 persistTokenExpireAt 写 localStorage 不同源,导致 _localStoredAt 缺失 → NaN → shouldRefresh 永 false → silent refresh 永不触发。修复:getTokenExpireAt 改读 localStorage,persistTokenExpireAt 同步刷 store,保证两端一致。e2e 已验证:注入 expire=1800 token → reload → role-config mount 触发 sendRequest → Network 抓到 POST /user/refresh 200 → localStorage token 被替换为新 12h token(含 _localStoredAt)。

* 应用启动同步 localStorage token 到 store #3392

PR #3402 扩展:守卫放行后首次 API 请求时,vuex state.token 还是 null,httpRequest 读 store 取不到 Authorization 头,会被后端 401 → clearAuth 跳登录。在 main.js 应用启动阶段就把 localStorage 里的 token commit 进 store,避免 race。不动 refresh 链路与其它业务文件。
This commit is contained in:
hrz authored and GitHub committed 2026-10-23 09:47:41 +08:00
1 parent fdf280f84a
commit 24fbf7eee2
8 files changed
+197 -23

No files matched your search

@@ -86,6 +86,7 @@ public class ShiroConfig {
filterMap.put("/user/captcha", "anon");
filterMap.put("/user/smsVerification", "anon");
filterMap.put("/user/login", "anon");
filterMap.put("/user/refresh", "anon");
filterMap.put("/user/pub-config", "anon");
filterMap.put("/user/register", "anon");
filterMap.put("/user/retrieve-password", "anon");
@@ -16,6 +16,7 @@ import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -110,6 +111,17 @@ public class LoginController {
return sysUserTokenService.createToken(userDTO.getId());
}
@PostMapping("/refresh")
@Operation(summary = "刷新token:用旧token换新token")
public Result<TokenDTO> refresh(HttpServletRequest request) {
String authorization = request.getHeader(Constant.AUTHORIZATION);
if (StringUtils.isBlank(authorization) || !authorization.startsWith("Bearer ")) {
throw new RenException(ErrorCode.UNAUTHORIZED);
}
String oldToken = authorization.replace("Bearer ", "");
return sysUserTokenService.refreshToken(oldToken);
}
@PostMapping("/register")
@Operation(summary = "注册")
public Result<Void> register(@RequestBody LoginDTO login) {
@@ -21,4 +21,14 @@ public interface SysUserTokenDao extends BaseDao<SysUserTokenEntity> {
SysUserTokenEntity getByUserId(Long userId);
void logout(@Param("userId") Long userId, @Param("expireDate") Date expireDate);
/**
* 用旧 token 换新 token 的 CAS 更新:仅当旧 token 未过期且属于指定 user 时才覆盖。
* 返回受影响行数(0 表示条件不满足 = token 已被并发刷新或过期)。
*/
int refreshTokenCAS(@Param("userId") Long userId,
@Param("oldToken") String oldToken,
@Param("newToken") String newToken,
@Param("newExpire") Date newExpire,
@Param("updateDate") Date updateDate);
}
@@ -21,6 +21,13 @@ public interface SysUserTokenService extends BaseService<SysUserTokenEntity> {
*/
Result<TokenDTO> createToken(Long userId);
/**
* 用旧token换新token,旧token立即失效
*
* @param oldToken 旧token字符串
*/
Result<TokenDTO> refreshToken(String oldToken);
SysUserDTO getUserByToken(String token);
/**
@@ -2,7 +2,9 @@ package xiaozhi.modules.security.service.impl;
import java.util.Date;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import cn.hutool.core.date.DateUtil;
import lombok.AllArgsConstructor;
@@ -81,6 +83,36 @@ public class SysUserTokenServiceImpl extends BaseServiceImpl<SysUserTokenDao, Sy
return new Result<TokenDTO>().ok(tokenDTO);
}
@Override
@Transactional
public Result<TokenDTO> refreshToken(String oldToken) {
if (StringUtils.isBlank(oldToken)) {
throw new RenException(ErrorCode.UNAUTHORIZED);
}
SysUserTokenEntity tokenEntity = baseDao.getByToken(oldToken);
if (tokenEntity == null) {
throw new RenException(ErrorCode.TOKEN_INVALID);
}
if (tokenEntity.getExpireDate().before(new Date())) {
throw new RenException(ErrorCode.UNAUTHORIZED);
}
// CAS 更新:并发场景下只有一个请求能匹配到该 oldToken,另一个返 0 行
String newToken = TokenGenerator.generateValue();
Date now = new Date();
Date expireTime = new Date(now.getTime() + EXPIRE * 1000L);
int affected = baseDao.refreshTokenCAS(tokenEntity.getUserId(), oldToken, newToken, expireTime, now);
if (affected == 0) {
throw new RenException(ErrorCode.TOKEN_INVALID);
}
TokenDTO dto = new TokenDTO();
dto.setToken(newToken);
dto.setExpire(EXPIRE);
dto.setClientHash(HttpContextUtils.getClientCode());
return new Result<TokenDTO>().ok(dto);
}
@Override
public SysUserDTO getUserByToken(String token) {
SysUserTokenEntity userToken = baseDao.getByToken(token);
@@ -14,4 +14,12 @@
<update id="logout">
update sys_user_token set expire_date = #{expireDate} where user_id = #{userId}
</update>
<update id="refreshTokenCAS">
update sys_user_token
set token = #{newToken}, update_date = #{updateDate}, expire_date = #{newExpire}
where token = #{oldToken}
and expire_date > NOW()
and user_id = #{userId}
</update>
</mapper>
+120 -23
View File
@@ -1,6 +1,7 @@
import Fly from 'flyio/dist/npm/fly';
import store from '../store/index';
import Constant from '../utils/constant';
import { getServiceUrl } from '../apis/api';
import { goToPage, isNotNull, showDanger, showWarning } from '../utils/index';
import i18n from '../i18n/index';
@@ -8,13 +9,98 @@ const fly = new Fly()
// 设置超时
fly.config.timeout = 30000
// 独立 fly 实例给 silent refresh 用,避免任何回调链回到 sendRequest
const refreshFly = new Fly()
refreshFly.config.timeout = 30000
// silent refresh:剩 1h 触发换 token,单飞锁避免并发
const REFRESH_AHEAD_MS = 60 * 60 * 1000
let refreshPromise = null
function getTokenExpireAt() {
// 与 persistTokenExpireAt 同源读 localStorage(store 走的是 state.token 缓存,
// 刚写入 localStorage 后再走 store 拿不到补写的 _localStoredAt)
const raw = localStorage.getItem('token')
if (!raw) return 0
try {
const parsed = JSON.parse(raw)
if (!parsed || !parsed.expire) return 0
// login.vue 写入 store 时是 setToken(now),token 对象里没有 createdAt;
// 用 _localStoredAt 作为本地落地时间基准
return (parsed._localStoredAt || 0) + parsed.expire * 1000
} catch (e) {
return 0
}
}
// 首次见到该 token 时记下落地时间;同步刷新 localStorage 与 store,保证两端一致
function persistTokenExpireAt() {
const raw = localStorage.getItem('token')
if (!raw) return
try {
const parsed = JSON.parse(raw)
if (parsed && parsed.expire && !parsed._localStoredAt) {
parsed._localStoredAt = Date.now()
const next = JSON.stringify(parsed)
localStorage.setItem('token', next)
store.commit('setToken', next)
}
} catch (e) { /* ignore */ }
}
function shouldRefresh() {
persistTokenExpireAt()
const exp = getTokenExpireAt()
if (!exp) return false
return exp - Date.now() < REFRESH_AHEAD_MS
}
function doRefresh() {
// 直接走 fly.request,绕过 sendRequest,避免死循环触发 silent refresh
const raw = localStorage.getItem('token')
if (!raw) return Promise.reject(new Error('no token'))
let bearer
try {
bearer = JSON.parse(raw).token
} catch (e) {
return Promise.reject(e)
}
return refreshFly.request(getServiceUrl() + '/user/refresh', {}, {
method: 'POST',
headers: { Authorization: 'Bearer ' + bearer }
}).then((res) => {
const body = res && res.data
if (!body || (body.code !== 0 && body.code !== 'success')) {
throw new Error('refresh failed: ' + (body && body.msg))
}
const newDto = body.data
newDto._localStoredAt = Date.now()
store.commit('setToken', JSON.stringify(newDto))
return newDto
})
}
function triggerSilentRefresh() {
if (!refreshPromise) {
refreshPromise = doRefresh().catch((err) => {
store.commit('clearAuth')
goToPage(Constant.PAGE.LOGIN, true)
throw err
}).finally(() => {
refreshPromise = null
})
}
return refreshPromise
}
/**
* Request服务封装
*/
export default {
sendRequest,
reAjaxFun,
clearRequestTime
clearRequestTime,
triggerSilentRefresh
}
function sendRequest() {
@@ -37,30 +123,41 @@ function sendRequest() {
acceptLanguage = 'en-US';
}
this._header['Accept-Language'] = acceptLanguage;
if (isNotNull(store.getters.getToken)) {
this._header.Authorization = 'Bearer ' + (JSON.parse(store.getters.getToken)).token
const doSend = () => {
if (isNotNull(store.getters.getToken)) {
this._header.Authorization = 'Bearer ' + (JSON.parse(store.getters.getToken)).token
}
// 打印请求信息
fly.request(this._url, this._data, {
method: this._method,
headers: this._header,
responseType: this._responseType
}).then((res) => {
const error = httpHandlerError(res, this._failCallback, this._networkFailCallback);
if (error) {
return
}
if (this._sucCallback) {
this._sucCallback(res)
}
}).catch((res) => {
// 打印失败响应
console.log('catch', res)
httpHandlerError(res, this._failCallback, this._networkFailCallback)
})
}
// 打印请求信息
fly.request(this._url, this._data, {
method: this._method,
headers: this._header,
responseType: this._responseType
}).then((res) => {
const error = httpHandlerError(res, this._failCallback, this._networkFailCallback);
if (error) {
return
}
if (this._sucCallback) {
this._sucCallback(res)
}
}).catch((res) => {
// 打印失败响应
console.log('catch', res)
httpHandlerError(res, this._failCallback, this._networkFailCallback)
})
// 剩 1h 内过期就先 silent refresh 再发,确保请求带新 token
if (shouldRefresh()) {
triggerSilentRefresh().then(doSend).catch(() => {
// 触发 refresh 后被拦截跳登录,这里什么都不用做
})
} else {
doSend()
}
return this
},
'success'(callback) {
+7
View File
@@ -22,6 +22,13 @@ Vue.config.productionTip = false
// 注册Service Worker
registerServiceWorker();
// 应用启动时把 localStorage 里的 token 同步进 store,避免守卫放行后首次请求时
// httpRequest 读到 state.token=null 而漏带 Authorization 头触发 401 跳登录
const cachedToken = localStorage.getItem('token')
if (cachedToken) {
store.commit('setToken', cachedToken)
}
// 创建Vue实例
new Vue({
router,