fix: auth login ways

This commit is contained in:
engigu
2026-03-12 09:57:34 +08:00
parent 8e1df7a55a
commit dd5bef9733
7 changed files with 122 additions and 36 deletions
+20 -1
View File
@@ -29,6 +29,22 @@ type loginAttempt struct {
var loginAttempts sync.Map 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 { func NewAuthController(userService *services.UserService, settingsService *services.SettingsService, loginLogService *services.LoginLogService) *AuthController {
return &AuthController{ return &AuthController{
userService: userService, userService: userService,
@@ -107,7 +123,7 @@ func (ac *AuthController) Login(c *gin.Context) {
} }
// 生成 token // 生成 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 { if err != nil {
eventbus.DefaultBus.Publish(eventbus.Event{ eventbus.DefaultBus.Publish(eventbus.Event{
Type: constant.EventUserLogin, Type: constant.EventUserLogin,
@@ -144,6 +160,9 @@ func (ac *AuthController) Login(c *gin.Context) {
} }
func (ac *AuthController) Logout(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) middleware.ClearAuthCookie(c)
utils.SuccessMsg(c, "退出成功") utils.SuccessMsg(c, "退出成功")
} }
+23 -11
View File
@@ -1,6 +1,8 @@
package middleware package middleware
import ( import (
"crypto/sha256"
"crypto/subtle"
"encoding/json" "encoding/json"
"net/http" "net/http"
"time" "time"
@@ -26,17 +28,16 @@ func AuthRequired() gin.HandlerFunc {
} }
// 验证 token // 验证 token
userID, username, err := utils.ParseToken(token, constant.Secret) userID, username, tokenVersion, err := utils.ParseToken(token, constant.Secret)
if err != nil { if err != nil {
utils.Unauthorized(c, "登录已过期,请重新登录") utils.Unauthorized(c, "登录已过期,请重新登录")
c.Abort() c.Abort()
return return
} }
// 安全增强:校验数据库中该用户的 ID 是否与 Token 一致 // 安全增强:校验数据库中该用户的 ID 是否与 Token 一致,并验证 TokenVersion
// 防止迁移后旧 Token 中的数字 ID 污染新数据
var user models.User 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, "会话失效,请重新登录") utils.Unauthorized(c, "会话失效,请重新登录")
ClearAuthCookie(c) ClearAuthCookie(c)
c.Abort() c.Abort()
@@ -101,7 +102,14 @@ func checkOpenapiToken(c *gin.Context, settingsSvc *services.SettingsService) bo
return false 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 return false
} }
@@ -200,13 +208,17 @@ func SwaggerAuth() gin.HandlerFunc {
} }
// 检查提供的 token 是否匹配 // 检查提供的 token 是否匹配
if providedToken != "" && providedToken == tokenConfig.Token { if providedToken != "" {
// 如果是通过 url 参数进来的,自动将其种入 Cookie,便于后续加载静态资源 (如 json) h1 := sha256.Sum256([]byte(providedToken))
if tokenQuery != "" { h2 := sha256.Sum256([]byte(tokenConfig.Token))
c.SetCookie("openapi_token", providedToken, 86400, "/openapi", "", false, false) 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 头触发浏览器反人类原生弹窗 // 验证失败,不再返回 WWW-Authenticate 头触发浏览器反人类原生弹窗
+5 -4
View File
@@ -12,10 +12,11 @@ type User struct {
Username string `json:"username" gorm:"size:100;uniqueIndex;not null"` Username string `json:"username" gorm:"size:100;uniqueIndex;not null"`
Password string `json:"password" gorm:"size:255;not null"` Password string `json:"password" gorm:"size:255;not null"`
Email string `json:"email" gorm:"size:255"` Email string `json:"email" gorm:"size:255"`
Role string `json:"role" gorm:"size:20;default:user"` // admin, user Role string `json:"role" gorm:"size:20;default:user"` // admin, user
CreatedAt LocalTime `json:"created_at"` TokenVersion int `json:"-" gorm:"default:1"` // 用于 JWT 失效校验
UpdatedAt LocalTime `json:"updated_at"` CreatedAt LocalTime `json:"created_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` UpdatedAt LocalTime `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
} }
func (User) TableName() string { func (User) TableName() string {
+54 -8
View File
@@ -3,11 +3,16 @@ package services
import ( import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"strings"
"golang.org/x/crypto/bcrypt"
"github.com/engigu/baihu-panel/internal/constant" "github.com/engigu/baihu-panel/internal/constant"
"github.com/engigu/baihu-panel/internal/database" "github.com/engigu/baihu-panel/internal/database"
"github.com/engigu/baihu-panel/internal/models" "github.com/engigu/baihu-panel/internal/models"
"github.com/engigu/baihu-panel/internal/utils" "github.com/engigu/baihu-panel/internal/utils"
"gorm.io/gorm"
) )
type UserService struct{} type UserService struct{}
@@ -16,18 +21,28 @@ func NewUserService() *UserService {
return &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)) hash := sha256.Sum256([]byte(password + constant.Secret))
return hex.EncodeToString(hash[:]) return hex.EncodeToString(hash[:])
} }
func (us *UserService) CreateUser(username, password, email, role string) *models.User { func (us *UserService) CreateUser(username, password, email, role string) *models.User {
hashedPassword, _ := us.hashPassword(password)
user := &models.User{ user := &models.User{
ID: utils.GenerateID(), ID: utils.GenerateID(),
Username: username, Username: username,
Password: us.hashPassword(password), Password: hashedPassword,
Email: email, Email: email,
Role: role, Role: role,
TokenVersion: 1,
} }
database.DB.Create(user) database.DB.Create(user)
return 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 { 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() { 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 { 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
} }
+15 -9
View File
@@ -8,16 +8,18 @@ import (
) )
type Claims struct { type Claims struct {
UserID string `json:"user_id"` UserID string `json:"user_id"`
Username string `json:"username"` Username string `json:"username"`
TokenVersion int `json:"version"`
jwt.RegisteredClaims jwt.RegisteredClaims
} }
// GenerateToken 生成 JWT token // 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{ claims := Claims{
UserID: userID, UserID: userID,
Username: username, Username: username,
TokenVersion: version,
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(expireDays) * 24 * time.Hour)), ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(expireDays) * 24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()), IssuedAt: jwt.NewNumericDate(time.Now()),
@@ -29,18 +31,22 @@ func GenerateToken(userID string, username string, expireDays int, secret string
} }
// ParseToken 解析 JWT token // 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) { 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 return []byte(secret), nil
}) })
if err != nil { if err != nil {
return "", "", err return "", "", 0, err
} }
if claims, ok := token.Claims.(*Claims); ok && token.Valid { 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")
} }
+4 -2
View File
@@ -45,7 +45,7 @@
"@vue/tsconfig": "^0.8.1", "@vue/tsconfig": "^0.8.1",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^7.2.4", "vite": "^7.3.1",
"vite-plugin-static-copy": "^2.3.0", "vite-plugin-static-copy": "^2.3.0",
"vue-tsc": "^3.1.4" "vue-tsc": "^3.1.4"
} }
@@ -1942,7 +1942,9 @@
} }
}, },
"node_modules/vite": { "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", "license": "MIT",
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
+1 -1
View File
@@ -46,7 +46,7 @@
"@vue/tsconfig": "^0.8.1", "@vue/tsconfig": "^0.8.1",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^7.2.4", "vite": "^7.3.1",
"vite-plugin-static-copy": "^2.3.0", "vite-plugin-static-copy": "^2.3.0",
"vue-tsc": "^3.1.4" "vue-tsc": "^3.1.4"
}, },