feat(auth): JWT 滑动续期 + 401 自动登出,修复长挂机莫名掉线

- 后端:JWT 有效期可配(JWT_EXPIRE_HOURS,默认 24h);令牌寿命过半时经
  X-Renewed-Token 响应头滑动续期;新增 POST /api/auth/refresh 以数据库真实身份签发
- 前端 request 层:静默接管续期头并同步 localStorage;任何 401(登录/续期除外)
  统一广播会话过期事件
- auth store:监听过期事件清理登录态并跳转 /login?expired=1(不再卡 403 页);
  每 10 分钟定时续期,避免 WS 重连时令牌已过期
- 新增滑动续期单元测试(回签过期时间戳),go test -race 通过
This commit is contained in:
yml2213
2026-09-05 15:57:42 +08:00
parent 223d30efd0
commit e675f427a7
10 changed files with 277 additions and 17 deletions
+2
View File
@@ -235,6 +235,8 @@ function statsQueryString(params?: StatsQuery) {
// Auth
export const login = (params: LoginParams) => post<LoginResult>('/login', params)
/** 以当前登录身份换取新令牌(前端定时续期,防止长挂机后过期掉线) */
export const refreshSession = () => post<{ token: string }>('/auth/refresh', {})
export type AgentPresence = 'online' | 'busy' | 'offline'
+38
View File
@@ -1,9 +1,15 @@
const BASE = '/api'
export const PERMISSIONS_CHANGED_EVENT = 'permissions-changed'
/** 会话过期(401):由 auth store 统一清理登录态并跳转登录页 */
export const SESSION_EXPIRED_EVENT = 'session-expired'
let token = ''
let permissionVersion = ''
/** 令牌被服务端续期后的回调(auth store 注册,同步 localStorage) */
let tokenRenewedListener: ((next: string) => void) | null = null
/** 会话过期只广播一次,避免并发请求重复触发跳转 */
let sessionExpiredNotified = false
export function setToken(t: string) {
if (token !== t) permissionVersion = ''
@@ -11,6 +17,25 @@ export function setToken(t: string) {
}
export function getToken() { return token }
/** 注册令牌续期回调(返回解绑函数) */
export function onTokenRenewed(listener: (next: string) => void) {
tokenRenewedListener = listener
return () => { if (tokenRenewedListener === listener) tokenRenewedListener = null }
}
function notifySessionExpired() {
if (sessionExpiredNotified) return
sessionExpiredNotified = true
if (typeof window !== 'undefined') {
window.dispatchEvent(new Event(SESSION_EXPIRED_EVENT))
}
}
/** 登录成功/重新登录后复位过期标记 */
export function resetSessionExpiredFlag() {
sessionExpiredNotified = false
}
interface Response<T = unknown> {
code: number
message: string
@@ -46,8 +71,21 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
window.dispatchEvent(new Event(PERMISSIONS_CHANGED_EVENT))
}
}
// 服务端滑动续期:静默替换本地令牌
const renewed = res.headers.get('X-Renewed-Token')
if (renewed && renewed !== token) {
setToken(renewed)
tokenRenewedListener?.(renewed)
}
const json = await res.json()
if (json.code === 401) {
// 登录/续期请求本身的 401 交给调用方提示,不触发全局过期
if (!url.startsWith('/login') && !url.startsWith('/auth/refresh')) {
notifySessionExpired()
}
}
if (json.code !== 0) {
const err = new Error(json.message || '请求失败') as Error & { code: number }
err.code = json.code