diff --git a/server/cmd/main.go b/server/cmd/main.go index aafc99d..50bb6ca 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -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) diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 73e415e..36f9b9b 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -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"), diff --git a/server/internal/handler/auth.go b/server/internal/handler/auth.go index 381860c..78aadc9 100644 --- a/server/internal/handler/auth.go +++ b/server/internal/handler/auth.go @@ -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 diff --git a/server/internal/handler/router.go b/server/internal/handler/router.go index 74ed689..10e8c8c 100644 --- a/server/internal/handler/router.go +++ b/server/internal/handler/router.go @@ -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) diff --git a/server/internal/handler/security_integration_test.go b/server/internal/handler/security_integration_test.go index 09390f5..1f269c1 100644 --- a/server/internal/handler/security_integration_test.go +++ b/server/internal/handler/security_integration_test.go @@ -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{ diff --git a/server/internal/middleware/auth.go b/server/internal/middleware/auth.go index 2572028..e0d77b7 100644 --- a/server/internal/middleware/auth.go +++ b/server/internal/middleware/auth.go @@ -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) diff --git a/server/internal/middleware/renew_test.go b/server/internal/middleware/renew_test.go new file mode 100644 index 0000000..ba65b8c --- /dev/null +++ b/server/internal/middleware/renew_test.go @@ -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("垃圾令牌应解析失败") + } +} diff --git a/web/src/services/api.ts b/web/src/services/api.ts index d707cf9..b8ff485 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -235,6 +235,8 @@ function statsQueryString(params?: StatsQuery) { // Auth export const login = (params: LoginParams) => post('/login', params) +/** 以当前登录身份换取新令牌(前端定时续期,防止长挂机后过期掉线) */ +export const refreshSession = () => post<{ token: string }>('/auth/refresh', {}) export type AgentPresence = 'online' | 'busy' | 'offline' diff --git a/web/src/services/request.ts b/web/src/services/request.ts index 0b39e87..606e156 100644 --- a/web/src/services/request.ts +++ b/web/src/services/request.ts @@ -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 { code: number message: string @@ -46,8 +71,21 @@ async function request(url: string, options: RequestInit = {}): Promise { 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 diff --git a/web/src/stores/auth.tsx b/web/src/stores/auth.tsx index 2f730e4..6ee9f1e 100644 --- a/web/src/stores/auth.tsx +++ b/web/src/stores/auth.tsx @@ -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