From dd5bef9733775579a05e3b5e6a5ad3d82a276f13 Mon Sep 17 00:00:00 2001 From: engigu Date: Thu, 12 Mar 2026 09:57:34 +0800 Subject: [PATCH] fix: auth login ways --- internal/controllers/auth_controller.go | 21 ++++++++- internal/middleware/auth.go | 34 +++++++++----- internal/models/user.go | 9 ++-- internal/services/user_service.go | 62 +++++++++++++++++++++---- internal/utils/token.go | 24 ++++++---- web/package-lock.json | 6 ++- web/package.json | 2 +- 7 files changed, 122 insertions(+), 36 deletions(-) diff --git a/internal/controllers/auth_controller.go b/internal/controllers/auth_controller.go index 76cd95f..c8f0de4 100644 --- a/internal/controllers/auth_controller.go +++ b/internal/controllers/auth_controller.go @@ -29,6 +29,22 @@ type loginAttempt struct { var loginAttempts sync.Map +func init() { + // 定期清理过期的登录尝试统计,防止内存溢出 + go func() { + ticker := time.NewTicker(30 * time.Minute) + for range ticker.C { + loginAttempts.Range(func(key, value any) bool { + attempt := value.(*loginAttempt) + if time.Since(attempt.LastAttempt) > 10*time.Minute { + loginAttempts.Delete(key) + } + return true + }) + } + }() +} + func NewAuthController(userService *services.UserService, settingsService *services.SettingsService, loginLogService *services.LoginLogService) *AuthController { return &AuthController{ userService: userService, @@ -107,7 +123,7 @@ func (ac *AuthController) Login(c *gin.Context) { } // 生成 token - token, err := utils.GenerateToken(user.ID, user.Username, expireDays, constant.Secret) + token, err := utils.GenerateToken(user.ID, user.Username, user.TokenVersion, expireDays, constant.Secret) if err != nil { eventbus.DefaultBus.Publish(eventbus.Event{ Type: constant.EventUserLogin, @@ -144,6 +160,9 @@ func (ac *AuthController) Login(c *gin.Context) { } func (ac *AuthController) Logout(c *gin.Context) { + if userID, exists := c.Get("userID"); exists { + ac.userService.InvalidateUserTokens(userID.(string)) + } middleware.ClearAuthCookie(c) utils.SuccessMsg(c, "退出成功") } diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index e424264..c37329a 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -1,6 +1,8 @@ package middleware import ( + "crypto/sha256" + "crypto/subtle" "encoding/json" "net/http" "time" @@ -26,17 +28,16 @@ func AuthRequired() gin.HandlerFunc { } // 验证 token - userID, username, err := utils.ParseToken(token, constant.Secret) + userID, username, tokenVersion, err := utils.ParseToken(token, constant.Secret) if err != nil { utils.Unauthorized(c, "登录已过期,请重新登录") c.Abort() return } - // 安全增强:校验数据库中该用户的 ID 是否与 Token 一致 - // 防止迁移后旧 Token 中的数字 ID 污染新数据 + // 安全增强:校验数据库中该用户的 ID 是否与 Token 一致,并验证 TokenVersion var user models.User - if err := database.DB.Where("username = ?", username).First(&user).Error; err != nil || user.ID != userID { + if err := database.DB.Where("username = ?", username).First(&user).Error; err != nil || user.ID != userID || user.TokenVersion != tokenVersion { utils.Unauthorized(c, "会话失效,请重新登录") ClearAuthCookie(c) c.Abort() @@ -101,7 +102,14 @@ func checkOpenapiToken(c *gin.Context, settingsSvc *services.SettingsService) bo return false } - if tokenConfig.Token == "" || openapiToken != tokenConfig.Token { + if tokenConfig.Token == "" { + return false + } + + // 使用恒定时间比较防止时序攻击 + h1 := sha256.Sum256([]byte(openapiToken)) + h2 := sha256.Sum256([]byte(tokenConfig.Token)) + if subtle.ConstantTimeCompare(h1[:], h2[:]) != 1 { return false } @@ -200,13 +208,17 @@ func SwaggerAuth() gin.HandlerFunc { } // 检查提供的 token 是否匹配 - if providedToken != "" && providedToken == tokenConfig.Token { - // 如果是通过 url 参数进来的,自动将其种入 Cookie,便于后续加载静态资源 (如 json) - if tokenQuery != "" { - c.SetCookie("openapi_token", providedToken, 86400, "/openapi", "", false, false) + if providedToken != "" { + h1 := sha256.Sum256([]byte(providedToken)) + h2 := sha256.Sum256([]byte(tokenConfig.Token)) + if subtle.ConstantTimeCompare(h1[:], h2[:]) == 1 { + // 如果是通过 url 参数进来的,自动将其种入 Cookie,便于后续加载静态资源 (如 json) + if tokenQuery != "" { + c.SetCookie("openapi_token", providedToken, 86400, "/openapi", "", false, false) + } + c.Next() + return } - c.Next() - return } // 验证失败,不再返回 WWW-Authenticate 头触发浏览器反人类原生弹窗 diff --git a/internal/models/user.go b/internal/models/user.go index d2f05d5..0cb29a4 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -12,10 +12,11 @@ type User struct { Username string `json:"username" gorm:"size:100;uniqueIndex;not null"` Password string `json:"password" gorm:"size:255;not null"` Email string `json:"email" gorm:"size:255"` - Role string `json:"role" gorm:"size:20;default:user"` // admin, user - CreatedAt LocalTime `json:"created_at"` - UpdatedAt LocalTime `json:"updated_at"` - DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` + Role string `json:"role" gorm:"size:20;default:user"` // admin, user + TokenVersion int `json:"-" gorm:"default:1"` // 用于 JWT 失效校验 + CreatedAt LocalTime `json:"created_at"` + UpdatedAt LocalTime `json:"updated_at"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` } func (User) TableName() string { diff --git a/internal/services/user_service.go b/internal/services/user_service.go index 008a030..a7a8e56 100644 --- a/internal/services/user_service.go +++ b/internal/services/user_service.go @@ -3,11 +3,16 @@ package services import ( "crypto/sha256" "encoding/hex" + "strings" + + "golang.org/x/crypto/bcrypt" "github.com/engigu/baihu-panel/internal/constant" "github.com/engigu/baihu-panel/internal/database" "github.com/engigu/baihu-panel/internal/models" "github.com/engigu/baihu-panel/internal/utils" + + "gorm.io/gorm" ) type UserService struct{} @@ -16,18 +21,28 @@ func NewUserService() *UserService { return &UserService{} } -func (us *UserService) hashPassword(password string) string { +func (us *UserService) hashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(bytes), nil +} + +func (us *UserService) legacyHashPassword(password string) string { hash := sha256.Sum256([]byte(password + constant.Secret)) return hex.EncodeToString(hash[:]) } func (us *UserService) CreateUser(username, password, email, role string) *models.User { + hashedPassword, _ := us.hashPassword(password) user := &models.User{ - ID: utils.GenerateID(), - Username: username, - Password: us.hashPassword(password), - Email: email, - Role: role, + ID: utils.GenerateID(), + Username: username, + Password: hashedPassword, + Email: email, + Role: role, + TokenVersion: 1, } database.DB.Create(user) return user @@ -42,7 +57,26 @@ func (us *UserService) GetUserByUsername(username string) *models.User { } func (us *UserService) ValidatePassword(user *models.User, password string) bool { - return user.Password == us.hashPassword(password) + // 尝试 bcrypt 校验 + err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) + if err == nil { + return true + } + + // 如果 bcrypt 失败,检查是否为旧的 SHA256 格式 + // 旧格式是 64 位十六进制字符串 + if len(user.Password) == 64 && !strings.HasPrefix(user.Password, "$2") { + if user.Password == us.legacyHashPassword(password) { + // 校验成功,迁移到 bcrypt + newHash, err := us.hashPassword(password) + if err == nil { + database.DB.Model(user).Update("password", newHash) + } + return true + } + } + + return false } func (us *UserService) EnsureAdminExists() { @@ -62,5 +96,17 @@ func (us *UserService) AuthenticateUser(username, password string) bool { } func (us *UserService) UpdatePassword(userID string, newPassword string) error { - return database.DB.Model(&models.User{}).Where("id = ?", userID).Update("password", us.hashPassword(newPassword)).Error + hashedPassword, err := us.hashPassword(newPassword) + if err != nil { + return err + } + // 修改密码时同时失效旧 Token + return database.DB.Model(&models.User{}).Where("id = ?", userID).Updates(map[string]interface{}{ + "password": hashedPassword, + "token_version": gorm.Expr("token_version + 1"), + }).Error +} + +func (us *UserService) InvalidateUserTokens(userID string) error { + return database.DB.Model(&models.User{}).Where("id = ?", userID).Update("token_version", gorm.Expr("token_version + 1")).Error } diff --git a/internal/utils/token.go b/internal/utils/token.go index 679e91c..0475cbb 100644 --- a/internal/utils/token.go +++ b/internal/utils/token.go @@ -8,16 +8,18 @@ import ( ) type Claims struct { - UserID string `json:"user_id"` - Username string `json:"username"` + UserID string `json:"user_id"` + Username string `json:"username"` + TokenVersion int `json:"version"` jwt.RegisteredClaims } // GenerateToken 生成 JWT token -func GenerateToken(userID string, username string, expireDays int, secret string) (string, error) { +func GenerateToken(userID string, username string, version int, expireDays int, secret string) (string, error) { claims := Claims{ - UserID: userID, - Username: username, + UserID: userID, + Username: username, + TokenVersion: version, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(expireDays) * 24 * time.Hour)), IssuedAt: jwt.NewNumericDate(time.Now()), @@ -29,18 +31,22 @@ func GenerateToken(userID string, username string, expireDays int, secret string } // ParseToken 解析 JWT token -func ParseToken(tokenString string, secret string) (string, string, error) { +func ParseToken(tokenString string, secret string) (string, string, int, error) { token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) { + // 校验算法 + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errors.New("unexpected signing method") + } return []byte(secret), nil }) if err != nil { - return "", "", err + return "", "", 0, err } if claims, ok := token.Claims.(*Claims); ok && token.Valid { - return claims.UserID, claims.Username, nil + return claims.UserID, claims.Username, claims.TokenVersion, nil } - return "", "", errors.New("invalid token") + return "", "", 0, errors.New("invalid token") } diff --git a/web/package-lock.json b/web/package-lock.json index 3e17d32..a53e1c5 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -45,7 +45,7 @@ "@vue/tsconfig": "^0.8.1", "tw-animate-css": "^1.4.0", "typescript": "~5.9.3", - "vite": "^7.2.4", + "vite": "^7.3.1", "vite-plugin-static-copy": "^2.3.0", "vue-tsc": "^3.1.4" } @@ -1942,7 +1942,9 @@ } }, "node_modules/vite": { - "version": "7.3.0", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", "dependencies": { "esbuild": "^0.27.0", diff --git a/web/package.json b/web/package.json index 9ccc756..9c64bdd 100644 --- a/web/package.json +++ b/web/package.json @@ -46,7 +46,7 @@ "@vue/tsconfig": "^0.8.1", "tw-animate-css": "^1.4.0", "typescript": "~5.9.3", - "vite": "^7.2.4", + "vite": "^7.3.1", "vite-plugin-static-copy": "^2.3.0", "vue-tsc": "^3.1.4" },