* 登录 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:
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");
|
||||
|
||||
+12
@@ -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);
|
||||
}
|
||||
+7
@@ -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);
|
||||
|
||||
/**
|
||||
|
||||
+32
@@ -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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user