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:
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+44
-1
@@ -1,8 +1,11 @@
|
||||
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react'
|
||||
import { PERMISSIONS_CHANGED_EVENT, setToken } from '@/services/request'
|
||||
import {
|
||||
PERMISSIONS_CHANGED_EVENT, SESSION_EXPIRED_EVENT, onTokenRenewed, setToken, resetSessionExpiredFlag,
|
||||
} from '@/services/request'
|
||||
import {
|
||||
getMe,
|
||||
login as loginApi,
|
||||
refreshSession,
|
||||
updateMyStatus,
|
||||
mePermissions,
|
||||
type AgentPresence,
|
||||
@@ -119,6 +122,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
try {
|
||||
const res = await loginApi({ username, password })
|
||||
const u: LoginResult = { ...res.data, status: res.data.status || 'online' }
|
||||
resetSessionExpiredFlag()
|
||||
setToken(u.token)
|
||||
setUser(u)
|
||||
persistUser(u)
|
||||
@@ -137,6 +141,45 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 服务端滑动续期(X-Renewed-Token):同步更新内存与 localStorage 中的令牌
|
||||
useEffect(() => {
|
||||
return onTokenRenewed(nextToken => {
|
||||
setUser(prev => {
|
||||
if (!prev) return prev
|
||||
const updated = { ...prev, token: nextToken }
|
||||
persistUser(updated)
|
||||
return updated
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 定时续期:长挂机场景下每 10 分钟主动换新令牌,避免 WS 重连/请求时已过期
|
||||
useEffect(() => {
|
||||
if (!user?.token) return
|
||||
const timer = window.setInterval(() => {
|
||||
void refreshSession().then(res => {
|
||||
if (res.data?.token) setToken(res.data.token)
|
||||
}).catch(() => { /* 401 已由 request 层统一处理;网络错误等待下一轮 */ })
|
||||
}, 10 * 60 * 1000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [user?.token])
|
||||
|
||||
// 会话过期(所有 401 的统一出口):清理登录态并回到登录页
|
||||
useEffect(() => {
|
||||
const handleExpired = () => {
|
||||
setToken('')
|
||||
setUser(null)
|
||||
setPermissions(new Set())
|
||||
setPermissionsLoaded(false)
|
||||
persistUser(null)
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.replace('/login?expired=1')
|
||||
}
|
||||
}
|
||||
window.addEventListener(SESSION_EXPIRED_EVENT, handleExpired)
|
||||
return () => window.removeEventListener(SESSION_EXPIRED_EVENT, handleExpired)
|
||||
}, [])
|
||||
|
||||
const setPresence = useCallback(async (status: AgentPresence) => {
|
||||
const res = await updateMyStatus(status)
|
||||
const nextStatus = (res.data?.status || status) as string
|
||||
|
||||
Reference in New Issue
Block a user