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:
+2
-2
@@ -22,8 +22,8 @@ func main() {
|
||||
// 初始化数据库
|
||||
model.InitDB(cfg.Database.DSN())
|
||||
|
||||
// 初始化 JWT
|
||||
middleware.InitJWT(cfg.JWT.Secret)
|
||||
// 初始化 JWT(有效期可用 JWT_EXPIRE_HOURS 配置,默认 24 小时)
|
||||
middleware.InitJWT(cfg.JWT.Secret, cfg.JWT.ExpireTime)
|
||||
|
||||
// 对象存储(MinIO / S3 兼容)
|
||||
store, err := storage.NewS3(cfg.Storage)
|
||||
|
||||
@@ -31,8 +31,9 @@ type DatabaseConfig struct {
|
||||
}
|
||||
|
||||
type JWTConfig struct {
|
||||
Secret string
|
||||
ExpireTime time.Duration
|
||||
Secret string
|
||||
ExpireTime time.Duration
|
||||
ExpireHours int
|
||||
}
|
||||
|
||||
// StorageConfig S3 兼容对象存储(MinIO / OSS / COS / S3 共用字段)
|
||||
@@ -70,10 +71,17 @@ func Load() *Config {
|
||||
Name: getEnv("DB_NAME", "kefu_cloud"),
|
||||
SSLMode: getEnv("DB_SSLMODE", "disable"),
|
||||
},
|
||||
JWT: JWTConfig{
|
||||
Secret: getEnv("JWT_SECRET", "kefu-cloud-secret-key"),
|
||||
ExpireTime: 24 * time.Hour,
|
||||
},
|
||||
JWT: func() JWTConfig {
|
||||
hours := getEnvInt("JWT_EXPIRE_HOURS", 24)
|
||||
if hours <= 0 {
|
||||
hours = 24
|
||||
}
|
||||
return JWTConfig{
|
||||
Secret: getEnv("JWT_SECRET", "kefu-cloud-secret-key"),
|
||||
ExpireTime: time.Duration(hours) * time.Hour,
|
||||
ExpireHours: hours,
|
||||
}
|
||||
}(),
|
||||
Storage: StorageConfig{
|
||||
// 默认映射宿主机 9100→容器 9000,避免与其它项目 MinIO 冲突
|
||||
Endpoint: getEnv("STORAGE_ENDPOINT", "localhost:9100"),
|
||||
|
||||
@@ -70,6 +70,27 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// Refresh 以当前登录身份签发新令牌(用于前端定时续期,避免长挂机后令牌过期掉线)。
|
||||
// 以数据库中的真实角色/租户为准,防止携带过期角色的令牌被无限续期。
|
||||
func (h *AuthHandler) Refresh(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
var user model.User
|
||||
if err := model.DB.Select("id", "tenant_id", "role", "status").First(&user, userID).Error; err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "账号状态已变化,请重新登录"})
|
||||
return
|
||||
}
|
||||
if status, message := middleware.ValidateUserAccess(user.ID, user.TenantID, user.Role); status != 0 {
|
||||
c.JSON(status, gin.H{"code": status, "message": message})
|
||||
return
|
||||
}
|
||||
token, err := middleware.GenerateToken(user.ID, user.TenantID, user.Role)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "生成token失败"})
|
||||
return
|
||||
}
|
||||
middleware.JSON(c, gin.H{"token": token})
|
||||
}
|
||||
|
||||
// Me 当前登录用户信息。
|
||||
func (h *AuthHandler) Me(c *gin.Context) {
|
||||
var user model.User
|
||||
|
||||
@@ -56,10 +56,12 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
|
||||
authRequired := api.Group("")
|
||||
authRequired.Use(middleware.AuthRequired())
|
||||
{
|
||||
// 当前用户
|
||||
authRequired.GET("/me", auth.Me)
|
||||
authRequired.GET("/me/permissions", auth.MePermissions)
|
||||
authRequired.PUT("/me/status", auth.UpdatePresence)
|
||||
// 当前用户
|
||||
authRequired.GET("/me", auth.Me)
|
||||
authRequired.GET("/me/permissions", auth.MePermissions)
|
||||
authRequired.PUT("/me/status", auth.UpdatePresence)
|
||||
// 令牌续期(前端定时调用,防止长挂机后过期掉线)
|
||||
authRequired.POST("/auth/refresh", auth.Refresh)
|
||||
|
||||
// WebSocket
|
||||
authRequired.GET("/ws", wsHandler.Connect)
|
||||
|
||||
@@ -36,7 +36,7 @@ func setupRouter(t *testing.T) *gin.Engine {
|
||||
t.Fatalf("迁移测试数据库失败: %v", err)
|
||||
}
|
||||
model.DB = db
|
||||
middleware.InitJWT("test-secret")
|
||||
middleware.InitJWT("test-secret", time.Hour)
|
||||
router := gin.New()
|
||||
mem := storage.NewMemory("http://localhost:9100/kefu")
|
||||
handler.SetupRoutes(router, mem, config.StorageConfig{
|
||||
|
||||
@@ -13,12 +13,21 @@ import (
|
||||
"kefu-cloud/server/internal/model"
|
||||
)
|
||||
|
||||
var jwtSecret []byte
|
||||
var (
|
||||
jwtSecret []byte
|
||||
jwtExpire = 24 * time.Hour
|
||||
)
|
||||
|
||||
func InitJWT(secret string) {
|
||||
func InitJWT(secret string, expire time.Duration) {
|
||||
jwtSecret = []byte(secret)
|
||||
if expire > 0 {
|
||||
jwtExpire = expire
|
||||
}
|
||||
}
|
||||
|
||||
// TokenExpire 返回令牌有效期时长。
|
||||
func TokenExpire() time.Duration { return jwtExpire }
|
||||
|
||||
type Claims struct {
|
||||
UserID uint `json:"user_id"`
|
||||
TenantID uint `json:"tenant_id"`
|
||||
@@ -32,7 +41,7 @@ func GenerateToken(userID, tenantID uint, role string) (string, error) {
|
||||
TenantID: tenantID,
|
||||
Role: role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(jwtExpire)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
@@ -125,6 +134,17 @@ func AuthRequired() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// 滑动续期:令牌剩余寿命不足一半时签发新令牌,前端经 X-Renewed-Token 静默替换,
|
||||
// 避免活跃用户在请求间隙令牌过期而"莫名掉线"。
|
||||
if issuedAt := claims.IssuedAt; issuedAt != nil {
|
||||
elapsed := time.Since(issuedAt.Time)
|
||||
if elapsed >= jwtExpire/2 {
|
||||
if renewed, err := GenerateToken(claims.UserID, claims.TenantID, claims.Role); err == nil {
|
||||
c.Header("X-Renewed-Token", renewed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("tenant_id", claims.TenantID)
|
||||
c.Set("role", claims.Role)
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwtlib "github.com/golang-jwt/jwt/v5"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"kefu-cloud/server/internal/model"
|
||||
)
|
||||
|
||||
// setupRenewEnv 初始化测试用 JWT(1 小时)与内存数据库(含可用租户/账号)。
|
||||
func setupRenewEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
InitJWT("renew-test-secret", time.Hour)
|
||||
|
||||
// 每个测试独立命名空间,避免共享内存库的命名冲突
|
||||
dsn := fmt.Sprintf("file:kefu_renew_%d?mode=memory&cache=shared", time.Now().UnixNano())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("打开测试数据库失败: %v", err)
|
||||
}
|
||||
if err := model.Migrate(db); err != nil {
|
||||
t.Fatalf("迁移测试数据库失败: %v", err)
|
||||
}
|
||||
model.DB = db
|
||||
t.Cleanup(func() { model.DB = nil })
|
||||
|
||||
tenant := model.Tenant{Name: "续期测试租户", Status: "normal"}
|
||||
if err := db.Create(&tenant).Error; err != nil {
|
||||
t.Fatalf("创建租户失败: %v", err)
|
||||
}
|
||||
user := model.User{TenantID: tenant.ID, Role: "admin", Username: "renew_admin", PasswordHash: "x", Nickname: "续期管理员", Status: "online"}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("创建用户失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// signToken 按指定签发时间生成令牌(jwtExpire 由 setupRenewEnv 设为 1h)。
|
||||
func signToken(userID, tenantID uint, role string, issuedAt time.Time) string {
|
||||
claims := Claims{
|
||||
UserID: userID, TenantID: tenantID, Role: role,
|
||||
RegisteredClaims: jwtlib.RegisteredClaims{
|
||||
IssuedAt: jwtlib.NewNumericDate(issuedAt),
|
||||
ExpiresAt: jwtlib.NewNumericDate(issuedAt.Add(jwtExpire)),
|
||||
},
|
||||
}
|
||||
token := jwtlib.NewWithClaims(jwtlib.SigningMethodHS256, claims)
|
||||
signed, _ := token.SignedString(jwtSecret)
|
||||
return signed
|
||||
}
|
||||
|
||||
func newRenewRouter() *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(AuthRequired())
|
||||
r.GET("/ping", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"code": 0}) })
|
||||
return r
|
||||
}
|
||||
|
||||
func TestAuthRequiredSlidesTokenWhenHalfExpired(t *testing.T) {
|
||||
setupRenewEnv(t)
|
||||
r := newRenewRouter()
|
||||
|
||||
// jwtExpire=1h,签发于 40 分钟前 → 已过半寿命,应滑动续期
|
||||
oldToken := signToken(1, 1, "admin", time.Now().Add(-40*time.Minute))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+oldToken)
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
renewed := rec.Header().Get("X-Renewed-Token")
|
||||
if renewed == "" {
|
||||
t.Fatal("超过半寿命的令牌应返回 X-Renewed-Token")
|
||||
}
|
||||
// 新令牌应是全新签发(IssuedAt ≈ 现在),可正常解析且未过期
|
||||
claims, err := ParseToken(renewed)
|
||||
if err != nil {
|
||||
t.Fatalf("续期令牌应可解析: %v", err)
|
||||
}
|
||||
if claims.UserID != 1 || claims.Role != "admin" {
|
||||
t.Fatalf("续期令牌身份不符: %+v", claims)
|
||||
}
|
||||
if time.Since(claims.IssuedAt.Time) > time.Minute {
|
||||
t.Fatalf("续期令牌应为新签发: issuedAt=%v", claims.IssuedAt.Time)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRequiredDoesNotRenewFreshToken(t *testing.T) {
|
||||
setupRenewEnv(t)
|
||||
r := newRenewRouter()
|
||||
|
||||
freshToken := signToken(1, 1, "admin", time.Now())
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+freshToken)
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if renewed := rec.Header().Get("X-Renewed-Token"); renewed != "" {
|
||||
t.Fatal("新鲜令牌不应触发续期")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTokenRejectsGarbage(t *testing.T) {
|
||||
InitJWT("parse-test-secret", time.Hour)
|
||||
if _, err := ParseToken("not-a-token"); err == nil {
|
||||
t.Fatal("无效令牌应解析失败")
|
||||
}
|
||||
if _, err := ParseToken(strings.Repeat("x", 64)); err == nil {
|
||||
t.Fatal("垃圾令牌应解析失败")
|
||||
}
|
||||
}
|
||||
@@ -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