From 6ad3209af06715b1930aa9cac65e7dbb42b9489d Mon Sep 17 00:00:00 2001 From: skyle1995 Date: Mon, 4 May 2026 22:02:26 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B0=83=E6=95=B4=20=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E8=AE=A4=E8=AF=81=E6=96=B9=E5=BC=8F=E4=B8=BA=20OAuth2=20?= =?UTF-8?q?=E9=89=B4=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controllers/admin/auth.go | 348 ++++++++++++++++++++++++-------------- database/migrate.go | 1 + database/settings.go | 35 ++-- models/refresh_token.go | 29 ++++ services/log_cleanup.go | 10 ++ services/refresh_token.go | 121 +++++++++++++ services/settings.go | 35 +++- 7 files changed, 431 insertions(+), 148 deletions(-) create mode 100644 models/refresh_token.go create mode 100644 services/refresh_token.go diff --git a/controllers/admin/auth.go b/controllers/admin/auth.go index c97fc98..f738fc3 100644 --- a/controllers/admin/auth.go +++ b/controllers/admin/auth.go @@ -125,7 +125,7 @@ func LoginHandler(c *gin.Context) { return } - // 生成JWT令牌 + // 生成 access JWT token, err := generateJWTTokenForAdmin(user.Username, user.Password, user.UUID, user.Role) if err != nil { recordLoginLog(c, user.UUID, body.Username, 0, "生成令牌失败") @@ -133,21 +133,44 @@ func LoginHandler(c *gin.Context) { return } - // 设置JWT Cookie(HttpOnly,安全) - // 使用系统配置的Cookie参数 + // 签发 refreshToken(新 family) settingsService := services.GetSettingsService() - secure, sameSite, domain, maxAge := settingsService.GetCookieConfig() - cookie := utils.CreateSecureCookie("admin_session", token, maxAge, domain, secure, sameSite) - c.SetCookie(cookie.Name, cookie.Value, cookie.MaxAge, cookie.Path, cookie.Domain, cookie.Secure, cookie.HttpOnly) + refreshTokenSvc := services.GetRefreshTokenService() + refreshDays := settingsService.GetRefreshTokenExpireDays() + absoluteDays := settingsService.GetSessionAbsoluteExpireDays() + if absoluteDays < refreshDays { + absoluteDays = refreshDays + } + refreshExpiresAt := time.Now().Add(time.Duration(refreshDays) * 24 * time.Hour) + absoluteExpiresAt := time.Now().Add(time.Duration(absoluteDays) * 24 * time.Hour) + jti := refreshTokenSvc.NewJTI() + familyID := refreshTokenSvc.NewFamilyID() + refreshToken, err := generateRefreshTokenForAdmin(user.Username, user.Password, user.UUID, user.Role, jti, familyID, refreshExpiresAt) + if err != nil { + recordLoginLog(c, user.UUID, body.Username, 0, "生成刷新令牌失败") + authBaseController.HandleInternalError(c, "生成刷新令牌失败", err) + return + } + if err := refreshTokenSvc.Create(jti, familyID, user.UUID, "admin", + refreshExpiresAt, absoluteExpiresAt, c.Request.UserAgent(), c.ClientIP()); err != nil { + recordLoginLog(c, user.UUID, body.Username, 0, "持久化刷新令牌失败") + authBaseController.HandleInternalError(c, "持久化刷新令牌失败", err) + return + } + + accessExpiresAt := time.Now().Add(time.Duration(settingsService.GetJWTExpire()) * time.Hour) recordLoginLog(c, user.UUID, body.Username, 1, "登录成功") authBaseController.HandleSuccess(c, "登录成功", gin.H{ - "redirect": "/admin", - "avatar": user.Avatar, - "nickname": user.Nickname, - "username": user.Username, - "role": user.Role, - "token": token, + "redirect": "/admin", + "avatar": user.Avatar, + "nickname": user.Nickname, + "username": user.Username, + "role": user.Role, + "token": token, + "accessToken": token, + "refreshToken": refreshToken, + "expires": accessExpiresAt, }) } @@ -179,8 +202,16 @@ func recordLoginLog(c *gin.Context, uuid string, username string, status int, me // LogoutHandler 管理员登出 // - 清理JWT Cookie会话 -// - 确保令牌完全失效 +// - 撤销当前 refreshToken family func LogoutHandler(c *gin.Context) { + // 尝试解析当前 access token,提取 family(通过 refresh DB 反查) + if token, err := getJWTCookie(c); err == nil && token != "" { + if claims, err := parseJWTToken(token); err == nil { + // access token 不带 family,需通过 user uuid 撤销该用户全部活跃 refresh + revokeAllRefreshOfUser(claims.UUID) + } + } + // 清理JWT Cookie clearInvalidJWTCookie(c) @@ -189,34 +220,110 @@ func LogoutHandler(c *gin.Context) { }) } -// RefreshTokenHandler 刷新管理员会话令牌 -// - 校验当前会话(Cookie 或 Authorization) -// - 重新签发 JWT 并同步写回 Cookie -// - 返回前端可直接持久化的新 token 信息 -func RefreshTokenHandler(c *gin.Context) { - token, err := getJWTCookie(c) - if err != nil || token == "" { - clearInvalidJWTCookie(c) - c.JSON(http.StatusUnauthorized, gin.H{ - "code": 1, - "msg": "未登录或会话已过期", - "data": nil, - }) - return - } - - claims, err := parseJWTToken(token) +// revokeAllRefreshOfUser 撤销该用户全部未撤销的 refreshToken +func revokeAllRefreshOfUser(userUUID string) { + db, err := database.GetDB() if err != nil { - clearInvalidJWTCookie(c) + return + } + db.Model(&models.RefreshToken{}). + Where("user_uuid = ? AND user_type = ? AND revoked = ?", userUUID, "admin", false). + Update("revoked", true) +} + +// RefreshTokenHandler 刷新管理员会话令牌 +// - 校验请求体中的 refreshToken(OAuth2 风格) +// - DB 校验:jti 存在、未撤销、未过期、未超绝对上限 +// - 轮换:旧 jti 标记 revoked + replaced_by;签发新 access + 新 refresh +// - 重用检测:旧已撤销 token 再次提交 -> 整 family 撤销 +func RefreshTokenHandler(c *gin.Context) { + var body struct { + RefreshToken string `json:"refreshToken"` + } + _ = c.ShouldBindJSON(&body) + refreshTokenStr := strings.TrimSpace(body.RefreshToken) + if refreshTokenStr == "" { c.JSON(http.StatusUnauthorized, gin.H{ "code": 1, - "msg": "无效的会话信息", + "msg": "缺少刷新令牌", "data": nil, }) return } + // 1. 解析 JWT + claims, err := parseJWTToken(refreshTokenStr) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "code": 1, + "msg": "无效的刷新令牌", + "data": nil, + }) + return + } + + // 2. 必须是 refresh 类型 + if claims.TokenType != TokenTypeRefresh { + c.JSON(http.StatusUnauthorized, gin.H{ + "code": 1, + "msg": "令牌类型错误", + "data": nil, + }) + return + } + + // 3. DB 查询 jti + refreshSvc := services.GetRefreshTokenService() + rec, err := refreshSvc.FindByJTI(claims.ID) + if err != nil { + // 找不到 = 已被清理或伪造 -> 拒绝 + c.JSON(http.StatusUnauthorized, gin.H{ + "code": 1, + "msg": "刷新令牌不存在或已失效", + "data": nil, + }) + return + } + + // 4. 重用检测:已撤销的 token 被再次使用 -> 整族撤销 + if rec.Revoked { + _ = refreshSvc.RevokeFamily(rec.FamilyID) + clearInvalidJWTCookie(c) + c.JSON(http.StatusUnauthorized, gin.H{ + "code": 1, + "msg": "检测到刷新令牌重用,会话已强制失效", + "data": nil, + }) + return + } + + now := time.Now() + + // 5. 过期检查 + if now.After(rec.ExpiresAt) { + _ = refreshSvc.RevokeByJTI(rec.JTI) + c.JSON(http.StatusUnauthorized, gin.H{ + "code": 1, + "msg": "刷新令牌已过期,请重新登录", + "data": nil, + }) + return + } + + // 6. 绝对上限检查 + if now.After(rec.AbsoluteExpiresAt) { + _ = refreshSvc.RevokeFamily(rec.FamilyID) + c.JSON(http.StatusUnauthorized, gin.H{ + "code": 1, + "msg": "会话已达最长有效期,请重新登录", + "data": nil, + }) + return + } + + // 7. 校验用户依然有效 + 密码未变 if !validateAdminPasswordHash(claims, c) { + _ = refreshSvc.RevokeFamily(rec.FamilyID) clearInvalidJWTCookie(c) c.JSON(http.StatusUnauthorized, gin.H{ "code": 1, @@ -231,9 +338,9 @@ func RefreshTokenHandler(c *gin.Context) { authBaseController.HandleInternalError(c, "数据库连接失败", err) return } - var adminUser models.User if err := db.Where("uuid = ?", claims.UUID).First(&adminUser).Error; err != nil { + _ = refreshSvc.RevokeFamily(rec.FamilyID) clearInvalidJWTCookie(c) c.JSON(http.StatusUnauthorized, gin.H{ "code": 1, @@ -243,25 +350,41 @@ func RefreshTokenHandler(c *gin.Context) { return } - newToken, err := generateJWTTokenForAdmin(adminUser.Username, adminUser.Password, adminUser.UUID, adminUser.Role) + // 8. 签发新 access + 新 refresh(一次一换,继承 absolute) + settingsService := services.GetSettingsService() + newAccess, err := generateJWTTokenForAdmin(adminUser.Username, adminUser.Password, adminUser.UUID, adminUser.Role) if err != nil { authBaseController.HandleInternalError(c, "生成令牌失败", err) return } - - secure, sameSite, domain, maxAge := services.GetSettingsService().GetCookieConfig() - cookieObj := utils.CreateSecureCookie("admin_session", newToken, maxAge, domain, secure, sameSite) - c.SetCookie(cookieObj.Name, cookieObj.Value, cookieObj.MaxAge, cookieObj.Path, cookieObj.Domain, cookieObj.Secure, cookieObj.HttpOnly) - - expireHours := services.GetSettingsService().GetJWTExpire() - if expireHours <= 0 { - expireHours = 24 + refreshDays := settingsService.GetRefreshTokenExpireDays() + newRefreshExpiresAt := now.Add(time.Duration(refreshDays) * 24 * time.Hour) + if newRefreshExpiresAt.After(rec.AbsoluteExpiresAt) { + newRefreshExpiresAt = rec.AbsoluteExpiresAt + } + newJTI := refreshSvc.NewJTI() + newRefresh, err := generateRefreshTokenForAdmin(adminUser.Username, adminUser.Password, adminUser.UUID, adminUser.Role, + newJTI, rec.FamilyID, newRefreshExpiresAt) + if err != nil { + authBaseController.HandleInternalError(c, "生成刷新令牌失败", err) + return + } + if err := refreshSvc.Create(newJTI, rec.FamilyID, adminUser.UUID, "admin", + newRefreshExpiresAt, rec.AbsoluteExpiresAt, c.Request.UserAgent(), c.ClientIP()); err != nil { + authBaseController.HandleInternalError(c, "持久化刷新令牌失败", err) + return + } + if err := refreshSvc.Rotate(rec.JTI, newJTI); err != nil { + // 旋转失败不影响响应,但记录 + logrus.WithError(err).Warn("rotate refresh token failed") } + // 9. access token 通过响应体返回,不再同步到 Cookie + authBaseController.HandleSuccess(c, "刷新成功", gin.H{ - "accessToken": newToken, - "refreshToken": newToken, - "expires": time.Now().Add(time.Duration(expireHours) * time.Hour), + "accessToken": newAccess, + "refreshToken": newRefresh, + "expires": now.Add(time.Duration(settingsService.GetJWTExpire()) * time.Hour), }) } @@ -307,13 +430,10 @@ func validateCSRFToken(c *gin.Context, requestToken string) bool { // 辅助函数 // ============================================================================ -// clearInvalidJWTCookie 清理无效的JWT Cookie -// - 统一的Cookie清理函数,确保一致性 -// - 在JWT校验失败时自动调用,提升安全性和用户体验 +// clearInvalidJWTCookie 已废弃:当前使用纯 Bearer Token 模式,无需清理 Cookie +// 保留空实现以兼容历史调用 func clearInvalidJWTCookie(c *gin.Context) { - _, _, domain, _ := services.GetSettingsService().GetCookieConfig() - cookie := utils.CreateExpiredCookie("admin_session", domain) - c.SetCookie(cookie.Name, cookie.Value, cookie.MaxAge, cookie.Path, cookie.Domain, cookie.Secure, cookie.HttpOnly) + _ = c } // getJWTSecret 动态获取当前的JWT密钥 @@ -339,34 +459,36 @@ func getJWTSecret() []byte { // 结构体定义 // ============================================================================ +// Token 类型常量 +const ( + TokenTypeAccess = "access" + TokenTypeRefresh = "refresh" +) + // JWTClaims JWT载荷结构体 type JWTClaims struct { Username string `json:"username"` UUID string `json:"uuid"` // 用户UUID Role int `json:"role"` // 用户角色 PasswordHash string `json:"password_hash"` // 密码哈希摘要,用于验证密码是否被修改 + TokenType string `json:"typ,omitempty"` // access | refresh,旧版无此字段视为 access + FamilyID string `json:"fid,omitempty"` // refresh 专用:会话族 ID jwt.RegisteredClaims } -// generateJWTTokenForAdmin 生成管理员JWT令牌 +// generateJWTTokenForAdmin 生成管理员 access JWT 令牌 // - 包含管理员用户名信息和密码哈希 // - 设置过期时间 // - 使用HMAC-SHA256签名 func generateJWTTokenForAdmin(username, passwordHash string, adminUUID string, role int) (string, error) { - // 生成密码哈希摘要(使用SHA256) - // 注意:传入的 passwordHash 已经是数据库存的 Hash,这里我们再次 Hash 还是直接用? - // atomicLibrary 的实现是: utils.GenerateSHA256Hash(adminUser.Password) - // 这里我们直接用数据库里的 Hash 值作为 Token 的一部分即可,或者对它再 Hash 一次。 - // 为了与 validateAdminPasswordHash 对应,我们需要知道验证时怎么比对。 - // validateAdminPasswordHash: currentPasswordHash := utils.GenerateSHA256Hash(adminPassword.Value) - // 所以这里也应该对数据库里的值进行 Hash。 passwordHashDigest := utils.GenerateSHA256Hash(passwordHash) claims := JWTClaims{ Username: username, UUID: adminUUID, - Role: role, // 用户真实角色 - PasswordHash: passwordHashDigest, // 包含密码哈希摘要 + Role: role, + PasswordHash: passwordHashDigest, + TokenType: TokenTypeAccess, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(services.GetSettingsService().GetJWTExpire()) * time.Hour)), IssuedAt: jwt.NewNumericDate(time.Now()), @@ -380,6 +502,32 @@ func generateJWTTokenForAdmin(username, passwordHash string, adminUUID string, r return token.SignedString(getJWTSecret()) } +// generateRefreshTokenForAdmin 生成管理员 refresh JWT 令牌 +// - 携带 jti / family_id 用于持久化与轮换 +// - 过期时间使用 settings.refresh_token_expire_days +func generateRefreshTokenForAdmin(username, passwordHash, adminUUID string, role int, + jti, familyID string, expiresAt time.Time) (string, error) { + passwordHashDigest := utils.GenerateSHA256Hash(passwordHash) + claims := JWTClaims{ + Username: username, + UUID: adminUUID, + Role: role, + PasswordHash: passwordHashDigest, + TokenType: TokenTypeRefresh, + FamilyID: familyID, + RegisteredClaims: jwt.RegisteredClaims{ + ID: jti, + ExpiresAt: jwt.NewNumericDate(expiresAt), + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + Issuer: "NetworkAuth", + Subject: username, + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(getJWTSecret()) +} + // parseJWTToken 解析并验证JWT令牌 // - 验证签名有效性 // - 检查过期时间 @@ -403,20 +551,16 @@ func parseJWTToken(tokenString string) (*JWTClaims, error) { return nil, fmt.Errorf("invalid token") } -// getJWTCookie 获取JWT cookie的通用函数,支持从Cookie或Authorization Header中获取 +// getJWTCookie 从 Authorization Bearer 头中读取 access token +// 注意:函数名保留以兼容历史调用,已不再读取 Cookie func getJWTCookie(c *gin.Context) (string, error) { - cookie, err := c.Cookie("admin_session") - if err == nil && cookie != "" { - return cookie, nil - } - - // 如果Cookie中没有,尝试从Authorization Header中获取 (兼容前端在非HTTPS环境下无法设置Secure Cookie的情况) authHeader := c.GetHeader("Authorization") if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") { token := strings.TrimPrefix(authHeader, "Bearer ") - return token, nil + if token != "" { + return token, nil + } } - return "", fmt.Errorf("未找到会话信息") } @@ -595,10 +739,10 @@ func GetCurrentAdminUser(r *http.Request) (*JWTClaims, error) { return claims, nil } -// GetCurrentAdminUserWithRefresh 获取当前登录的管理员用户信息并自动刷新令牌 -// - 从JWT令牌中提取用户信息 -// - 自动刷新接近过期的令牌(剩余时间少于6小时时刷新) -// - 返回用户ID、用户名、角色和是否刷新了令牌 +// GetCurrentAdminUserWithRefresh 获取当前登录的管理员用户信息 +// - 仅校验 access token 是否有效(不再做滑动续期) +// - 续期统一由前端调用 /refresh-token 完成(OAuth2 风格) +// - 第二个返回值保留为 false 以兼容历史调用方 func GetCurrentAdminUserWithRefresh(c *gin.Context) (*JWTClaims, bool, error) { cookie, err := getJWTCookie(c) if err != nil { @@ -610,60 +754,16 @@ func GetCurrentAdminUserWithRefresh(c *gin.Context) (*JWTClaims, bool, error) { return nil, false, fmt.Errorf("无效的会话信息") } - // 验证密码哈希 + // access token 必须是 access 类型 + if claims.TokenType != "" && claims.TokenType != TokenTypeAccess { + return nil, false, fmt.Errorf("令牌类型错误") + } + if !validateAdminPasswordHash(claims, c) { return nil, false, fmt.Errorf("会话已失效,请重新登录") } - // 检查是否需要刷新令牌 - refreshed := false - - // 动态获取刷新阈值:默认剩余时间少于6小时刷新 - refreshThresholdHours := services.GetSettingsService().GetJWTRefresh() - if refreshThresholdHours <= 0 { - refreshThresholdHours = 6 // 默认值 - } - refreshThreshold := time.Duration(refreshThresholdHours) * time.Hour - - // 动态获取JWT总有效期 - expireHours := services.GetSettingsService().GetJWTExpire() - if expireHours <= 0 { - expireHours = 24 // 默认值 - } - - // 动态获取Cookie配置(用于更新Cookie过期时间) - secure, sameSite, domain, maxAge := services.GetSettingsService().GetCookieConfig() - - // 1. 默认情况下,每次请求都更新Cookie的过期时间(滑动过期) - tokenToSet := cookie - shouldUpdateCookie := true - - // 2. 检查是否需要刷新JWT令牌(生成新的Token) - if time.Until(claims.ExpiresAt.Time) < refreshThreshold { - // 获取当前的 PasswordHash - db, _ := database.GetDB() - var adminUser models.User - db.Where("uuid = ? AND role = ?", claims.UUID, claims.Role).First(&adminUser) - - // 使用新的有效期生成令牌 - newToken, err := generateJWTTokenForAdmin(claims.Username, adminUser.Password, claims.UUID, adminUser.Role) - if err == nil { - tokenToSet = newToken - refreshed = true - - // 更新当前claims的过期时间 - claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(time.Duration(expireHours) * time.Hour)) - claims.IssuedAt = jwt.NewNumericDate(time.Now()) - } - } - - // 3. 执行Cookie更新 - if shouldUpdateCookie { - cookieObj := utils.CreateSecureCookie("admin_session", tokenToSet, maxAge, domain, secure, sameSite) - c.SetCookie(cookieObj.Name, cookieObj.Value, cookieObj.MaxAge, cookieObj.Path, cookieObj.Domain, cookieObj.Secure, cookieObj.HttpOnly) - } - - return claims, refreshed, nil + return claims, false, nil } // AdminAuthRequired 管理员认证拦截中间件 (Gin Middleware) diff --git a/database/migrate.go b/database/migrate.go index 393a52e..2538148 100644 --- a/database/migrate.go +++ b/database/migrate.go @@ -24,6 +24,7 @@ func AutoMigrate() error { &models.API{}, &models.Variable{}, &models.Function{}, + &models.RefreshToken{}, ); err != nil { logrus.WithError(err).Error("AutoMigrate 执行失败") return err diff --git a/database/settings.go b/database/settings.go index 39119eb..e49dccc 100644 --- a/database/settings.go +++ b/database/settings.go @@ -61,20 +61,25 @@ func SeedDefaultSettings() error { Value: jwtSecret, Description: "JWT签名密钥", }, - { - Name: "jwt_refresh", - Value: "6", - Description: "JWT令牌刷新阈值(小时)", - }, { Name: "jwt_expire", - Value: "24", - Description: "JWT令牌有效期(小时)", + Value: "2", + Description: "accessToken 有效期(小时),建议 0.5~2 小时", }, { - Name: "session_timeout", - Value: "3600", - Description: "会话超时时间(秒),默认1小时", + Name: "refresh_token_expire_days", + Value: "7", + Description: "refreshToken 滑动有效期(天),每次刷新可重新计算", + }, + { + Name: "session_absolute_expire_days", + Value: "30", + Description: "会话绝对过期上限(天),超过必须重新登录,refreshToken 滑动续期不能突破此上限", + }, + { + Name: "refresh_advance_seconds", + Value: "60", + Description: "accessToken 提前多少秒触发刷新(前端读取)", }, { Name: "max_upload_size", @@ -110,6 +115,11 @@ func SeedDefaultSettings() error { Value: "10000", Description: "操作日志保留条数(0表示不按数量清理)", }, + { + Name: "refresh_token_cleanup_days", + Value: "7", + Description: "刷新令牌过期后保留天数(0表示不自动清理)", + }, }...) // ===== Cookie相关默认项 ===== @@ -324,11 +334,6 @@ func SeedDefaultSettings() error { } } - // 移除已废弃的旧设置项,管理员登录入口改由门户导航控制 - if err := db.Where("name = ?", "hide_login_entrance").Delete(&models.Settings{}).Error; err != nil { - return err - } - logrus.Info("系统设置初始化完成") return nil } diff --git a/models/refresh_token.go b/models/refresh_token.go new file mode 100644 index 0000000..95c4987 --- /dev/null +++ b/models/refresh_token.go @@ -0,0 +1,29 @@ +package models + +import "time" + +// RefreshToken 刷新令牌持久化记录 +// - 一次一换:每次刷新生成新 jti,并把旧记录标记 Revoked +// - 同一登录会话共享 FamilyID,便于整体撤销 +// - 重用检测:一旦某条已 Revoked 的 token 被再次提交,整个 family 全部失效 +type RefreshToken struct { + ID uint `gorm:"primaryKey" json:"id"` + JTI string `gorm:"uniqueIndex;size:64;not null;comment:JWT ID" json:"jti"` + FamilyID string `gorm:"index;size:64;not null;comment:同一登录会话的 token 族" json:"family_id"` + UserUUID string `gorm:"index;size:36;not null;comment:用户UUID" json:"user_uuid"` + UserType string `gorm:"size:16;not null;comment:用户类型 admin/user" json:"user_type"` + IssuedAt time.Time `gorm:"not null;comment:签发时间" json:"issued_at"` + ExpiresAt time.Time `gorm:"not null;comment:过期时间" json:"expires_at"` + AbsoluteExpiresAt time.Time `gorm:"not null;comment:会话绝对过期时间" json:"absolute_expires_at"` + Revoked bool `gorm:"not null;default:false;comment:是否已撤销" json:"revoked"` + ReplacedBy string `gorm:"size:64;comment:被哪个新 jti 替换" json:"replaced_by"` + UserAgent string `gorm:"size:255;comment:登录设备 UA" json:"user_agent"` + IP string `gorm:"size:64;comment:登录 IP" json:"ip"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` +} + +// TableName 指定表名 +func (RefreshToken) TableName() string { + return "refresh_tokens" +} diff --git a/services/log_cleanup.go b/services/log_cleanup.go index 42a7168..420b6e9 100644 --- a/services/log_cleanup.go +++ b/services/log_cleanup.go @@ -47,6 +47,16 @@ func cleanupLogs() { logrus.WithError(err).Error("清理操作日志失败") } + // 清理刷新令牌(已过期的记录,包括已撤销的旧记录) + refreshTokenDays := getSettingInt("refresh_token_cleanup_days", 7) + if refreshTokenDays > 0 { + if err := GetRefreshTokenService().CleanupExpired(refreshTokenDays); err != nil { + logrus.WithError(err).Error("清理刷新令牌失败") + } else { + logrus.Debugf("清理刷新令牌完成 (保留 %d 天)", refreshTokenDays) + } + } + logrus.Debug("日志清理任务执行完成") } diff --git a/services/refresh_token.go b/services/refresh_token.go new file mode 100644 index 0000000..4752fce --- /dev/null +++ b/services/refresh_token.go @@ -0,0 +1,121 @@ +package services + +import ( + "NetworkAuth/database" + "NetworkAuth/models" + "errors" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// RefreshTokenService 提供 refreshToken 的持久化、轮换、撤销等业务能力 +type RefreshTokenService struct{} + +var refreshTokenService = &RefreshTokenService{} + +// GetRefreshTokenService 单例获取 +func GetRefreshTokenService() *RefreshTokenService { + return refreshTokenService +} + +// NewJTI 生成新的 jti +func (s *RefreshTokenService) NewJTI() string { + return uuid.New().String() +} + +// NewFamilyID 生成新的 family_id(每次登录都新建) +func (s *RefreshTokenService) NewFamilyID() string { + return uuid.New().String() +} + +// Create 持久化一条 refreshToken 记录 +// - 登录场景:传入新的 familyID + absolute(now + 绝对过期天数) +// - 刷新场景:复用旧 familyID 与旧 absolute,保证滑动续期不能突破上限 +func (s *RefreshTokenService) Create(jti, familyID, userUUID, userType string, + expiresAt, absoluteExpiresAt time.Time, ua, ip string) error { + db, err := database.GetDB() + if err != nil { + return err + } + rec := models.RefreshToken{ + JTI: jti, + FamilyID: familyID, + UserUUID: userUUID, + UserType: userType, + IssuedAt: time.Now(), + ExpiresAt: expiresAt, + AbsoluteExpiresAt: absoluteExpiresAt, + UserAgent: ua, + IP: ip, + } + return db.Create(&rec).Error +} + +// FindByJTI 根据 jti 查询 +func (s *RefreshTokenService) FindByJTI(jti string) (*models.RefreshToken, error) { + db, err := database.GetDB() + if err != nil { + return nil, err + } + var rec models.RefreshToken + if err := db.Where("jti = ?", jti).First(&rec).Error; err != nil { + return nil, err + } + return &rec, nil +} + +// RevokeFamily 撤销整个 family 下所有未撤销的 refreshToken(用于重用检测/登出) +func (s *RefreshTokenService) RevokeFamily(familyID string) error { + db, err := database.GetDB() + if err != nil { + return err + } + return db.Model(&models.RefreshToken{}). + Where("family_id = ? AND revoked = ?", familyID, false). + Update("revoked", true).Error +} + +// RevokeByJTI 撤销单条 refreshToken(一般在轮换时使用 Rotate) +func (s *RefreshTokenService) RevokeByJTI(jti string) error { + db, err := database.GetDB() + if err != nil { + return err + } + return db.Model(&models.RefreshToken{}). + Where("jti = ?", jti). + Update("revoked", true).Error +} + +// Rotate 标记旧 jti 为已撤销,并记录被替换为新 jti +func (s *RefreshTokenService) Rotate(oldJTI, newJTI string) error { + db, err := database.GetDB() + if err != nil { + return err + } + return db.Model(&models.RefreshToken{}). + Where("jti = ?", oldJTI). + Updates(map[string]interface{}{ + "revoked": true, + "replaced_by": newJTI, + }).Error +} + +// CleanupExpired 清理过期且过期时间早于 retentionDays 天前的记录 +func (s *RefreshTokenService) CleanupExpired(retentionDays int) error { + db, err := database.GetDB() + if err != nil { + return err + } + cutoff := time.Now().AddDate(0, 0, -retentionDays) + return db.Where("expires_at < ?", cutoff).Delete(&models.RefreshToken{}).Error +} + +// ErrRefreshNotFound refreshToken 不存在 +var ErrRefreshNotFound = errors.New("refresh token not found") + +// IsNotFound 判断是否为记录未找到错误 +func IsNotFound(err error) bool { + return errors.Is(err, gorm.ErrRecordNotFound) +} diff --git a/services/settings.go b/services/settings.go index f27d229..9e1b702 100644 --- a/services/settings.go +++ b/services/settings.go @@ -144,11 +144,6 @@ func (s *SettingsService) RefreshCache() { s.loadAllSettings() } -// GetSessionTimeout 获取会话超时时间(秒) -func (s *SettingsService) GetSessionTimeout() int { - return s.GetInt("session_timeout", 3600) // 默认1小时 -} - // IsMaintenanceMode 检查是否开启维护模式 func (s *SettingsService) IsMaintenanceMode() bool { return s.GetBool("maintenance_mode", false) @@ -164,14 +159,36 @@ func (s *SettingsService) GetEncryptionKey() string { return s.GetString("encryption_key", "") } -// GetJWTRefresh 获取JWT刷新时间(小时) +// GetJWTRefresh 已废弃,请使用 GetRefreshTokenExpireDays func (s *SettingsService) GetJWTRefresh() int { - return s.GetInt("jwt_refresh", 6) + return 0 } -// GetJWTExpire 获取JWT有效期(小时) +// GetJWTExpire 获取 accessToken 有效期(小时) func (s *SettingsService) GetJWTExpire() int { - return s.GetInt("jwt_expire", 24) + v := s.GetInt("jwt_expire", 2) + if v <= 0 { + return 2 + } + return v +} + +// GetRefreshTokenExpireDays 获取 refreshToken 有效期(天) +func (s *SettingsService) GetRefreshTokenExpireDays() int { + v := s.GetInt("refresh_token_expire_days", 7) + if v <= 0 { + return 7 + } + return v +} + +// GetSessionAbsoluteExpireDays 获取会话绝对过期上限(天) +func (s *SettingsService) GetSessionAbsoluteExpireDays() int { + v := s.GetInt("session_absolute_expire_days", 30) + if v <= 0 { + return 30 + } + return v } // GetCookieConfig 获取Cookie配置