From 24fbf7eee2c13ad966511b987d187415401d9ca6 Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Fri, 23 Oct 2026 09:47:41 +0800 Subject: [PATCH] =?UTF-8?q?=E7=99=BB=E5=BD=95=20token=2012h=20=E9=9D=99?= =?UTF-8?q?=E9=BB=98=E5=A4=B1=E6=95=88=E8=A1=A5=20silent=20refresh=20?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=20#3392=20(#3402)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 登录 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 链路与其它业务文件。 --- .../modules/security/config/ShiroConfig.java | 1 + .../security/controller/LoginController.java | 12 ++ .../modules/security/dao/SysUserTokenDao.java | 10 ++ .../security/service/SysUserTokenService.java | 7 + .../service/impl/SysUserTokenServiceImpl.java | 32 ++++ .../mapper/security/SysUserTokenDao.xml | 8 + main/manager-web/src/apis/httpRequest.js | 143 +++++++++++++++--- main/manager-web/src/main.js | 7 + 8 files changed, 197 insertions(+), 23 deletions(-) diff --git a/main/manager-api/src/main/java/xiaozhi/modules/security/config/ShiroConfig.java b/main/manager-api/src/main/java/xiaozhi/modules/security/config/ShiroConfig.java index 9e7c6387f..b3f9f1c92 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/security/config/ShiroConfig.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/security/config/ShiroConfig.java @@ -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"); diff --git a/main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java b/main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java index 1ab9809dd..5ec76161e 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java @@ -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 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 register(@RequestBody LoginDTO login) { diff --git a/main/manager-api/src/main/java/xiaozhi/modules/security/dao/SysUserTokenDao.java b/main/manager-api/src/main/java/xiaozhi/modules/security/dao/SysUserTokenDao.java index 05f946d32..02476d4f5 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/security/dao/SysUserTokenDao.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/security/dao/SysUserTokenDao.java @@ -21,4 +21,14 @@ public interface SysUserTokenDao extends BaseDao { 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); } diff --git a/main/manager-api/src/main/java/xiaozhi/modules/security/service/SysUserTokenService.java b/main/manager-api/src/main/java/xiaozhi/modules/security/service/SysUserTokenService.java index d1d094d40..285ef0396 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/security/service/SysUserTokenService.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/security/service/SysUserTokenService.java @@ -21,6 +21,13 @@ public interface SysUserTokenService extends BaseService { */ Result createToken(Long userId); + /** + * 用旧token换新token,旧token立即失效 + * + * @param oldToken 旧token字符串 + */ + Result refreshToken(String oldToken); + SysUserDTO getUserByToken(String token); /** diff --git a/main/manager-api/src/main/java/xiaozhi/modules/security/service/impl/SysUserTokenServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/security/service/impl/SysUserTokenServiceImpl.java index 7f0d311a0..f88e10fb3 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/security/service/impl/SysUserTokenServiceImpl.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/security/service/impl/SysUserTokenServiceImpl.java @@ -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().ok(tokenDTO); } + @Override + @Transactional + public Result 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().ok(dto); + } + @Override public SysUserDTO getUserByToken(String token) { SysUserTokenEntity userToken = baseDao.getByToken(token); diff --git a/main/manager-api/src/main/resources/mapper/security/SysUserTokenDao.xml b/main/manager-api/src/main/resources/mapper/security/SysUserTokenDao.xml index 4f49414a0..10251f46e 100644 --- a/main/manager-api/src/main/resources/mapper/security/SysUserTokenDao.xml +++ b/main/manager-api/src/main/resources/mapper/security/SysUserTokenDao.xml @@ -14,4 +14,12 @@ update sys_user_token set expire_date = #{expireDate} where user_id = #{userId} + + + update sys_user_token + set token = #{newToken}, update_date = #{updateDate}, expire_date = #{newExpire} + where token = #{oldToken} + and expire_date > NOW() + and user_id = #{userId} + \ No newline at end of file diff --git a/main/manager-web/src/apis/httpRequest.js b/main/manager-web/src/apis/httpRequest.js index bb5980aac..5d40e0a64 100755 --- a/main/manager-web/src/apis/httpRequest.js +++ b/main/manager-web/src/apis/httpRequest.js @@ -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) { diff --git a/main/manager-web/src/main.js b/main/manager-web/src/main.js index 395272ae9..20efcf495 100644 --- a/main/manager-web/src/main.js +++ b/main/manager-web/src/main.js @@ -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,