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
+44 -1
View File
@@ -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