- 后端: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 通过
127 lines
3.8 KiB
Go
127 lines
3.8 KiB
Go
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("垃圾令牌应解析失败")
|
|
}
|
|
}
|