From b8dfa150b28906838570d911ace8b3711efcfa5f Mon Sep 17 00:00:00 2001 From: laoboli <1293528695@qq.com> Date: Thu, 30 Apr 2026 19:10:47 +0800 Subject: [PATCH] feat: user auth. --- controllers/system_debug.go | 17 +++++++- controllers/user_admin.go | 78 ++++++++++++++++++++++--------------- middleware/jwt_for_user.go | 40 +++++++++---------- models/user.go | 23 +++++------ util/jwt.go | 25 +++++++++--- 5 files changed, 113 insertions(+), 70 deletions(-) diff --git a/controllers/system_debug.go b/controllers/system_debug.go index 4c6f604..22be359 100644 --- a/controllers/system_debug.go +++ b/controllers/system_debug.go @@ -2,6 +2,7 @@ package controllers import ( "errors" + "hr_receiver/config" "hr_receiver/models" "hr_receiver/mqtt" "hr_receiver/util" @@ -82,7 +83,21 @@ func (sc *SystemDebugController) MqttWebSocket(c *gin.Context) { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) return } - if claims.Role != models.UserRoleSuperAdmin { + + var user models.User + if err := config.DB.First(&user, claims.UserID).Error; err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"}) + return + } + if !user.IsActive { + c.JSON(http.StatusForbidden, gin.H{"error": "user is disabled"}) + return + } + if util.IsTokenRevoked(&user, claims) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "token has been revoked"}) + return + } + if user.Role != models.UserRoleSuperAdmin { c.JSON(http.StatusForbidden, gin.H{"error": "super admin required"}) return } diff --git a/controllers/user_admin.go b/controllers/user_admin.go index 4c73467..d536f89 100644 --- a/controllers/user_admin.go +++ b/controllers/user_admin.go @@ -7,6 +7,7 @@ import ( "net/http" "strconv" "strings" + "time" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -17,29 +18,31 @@ type UserAdminController struct { } type userAdminPayload struct { - Email *string `json:"email"` - FlavorType models.UserFlavorType `json:"flavorType"` - IsActive *bool `json:"isActive"` - Password string `json:"password"` - Phone *string `json:"phone"` - RegionIDs []uint32 `json:"regionIds"` - Role models.UserRole `json:"role"` - Username string `json:"username"` + Email *string `json:"email"` + FlavorType models.UserFlavorType `json:"flavorType"` + IsActive *bool `json:"isActive"` + Password string `json:"password"` + Phone *string `json:"phone"` + RegionIDs []uint32 `json:"regionIds"` + RevokeTokens *bool `json:"revokeTokens"` + Role models.UserRole `json:"role"` + Username string `json:"username"` } type userAdminListItem struct { - CreatedAt int64 `json:"created_at"` - Email *string `json:"email"` - FlavorType models.UserFlavorType `json:"flavorType"` - ID uint `json:"id"` - IsActive bool `json:"isActive"` - KindergartenNames []string `json:"kindergartenNames"` - Phone *string `json:"phone"` - RegionIDs []uint32 `json:"regionIds"` - Regions []models.UserRegionBinding `json:"regions"` - Role models.UserRole `json:"role"` - UpdatedAt int64 `json:"updated_at"` - Username string `json:"username"` + CreatedAt int64 `json:"created_at"` + Email *string `json:"email"` + FlavorType models.UserFlavorType `json:"flavorType"` + ID uint `json:"id"` + IsActive bool `json:"isActive"` + KindergartenNames []string `json:"kindergartenNames"` + Phone *string `json:"phone"` + RegionIDs []uint32 `json:"regionIds"` + Regions []models.UserRegionBinding `json:"regions"` + Role models.UserRole `json:"role"` + TokenInvalidBefore int64 `json:"tokenInvalidBefore"` + UpdatedAt int64 `json:"updated_at"` + Username string `json:"username"` } func NewUserAdminController() *UserAdminController { @@ -161,6 +164,7 @@ func (uc *UserAdminController) Update(c *gin.Context) { } if err := uc.DB.Transaction(func(tx *gorm.DB) error { + revokeTokens := payload.RevokeTokens != nil && *payload.RevokeTokens updateData := map[string]interface{}{ "username": user.Username, "email": user.Email, @@ -172,6 +176,9 @@ func (uc *UserAdminController) Update(c *gin.Context) { if strings.TrimSpace(payload.Password) != "" { updateData["password"] = user.Password } + if revokeTokens { + updateData["token_invalid_before"] = time.Now().UnixMilli() + } if err := tx.Model(&models.User{}).Where("id = ?", user.ID).Updates(updateData).Error; err != nil { return err } @@ -211,6 +218,10 @@ func (uc *UserAdminController) Delete(c *gin.Context) { respondUserLookupError(c, err) return } + if isProtectedAdminAccount(user.Username) { + writeError(c, http.StatusBadRequest, "cannot delete protected admin user") + return + } currentUserID, _, ok := currentUser(c) if ok && currentUserID == user.ID { writeError(c, http.StatusBadRequest, "cannot delete current user") @@ -262,17 +273,18 @@ func (uc *UserAdminController) buildUserAdminItems(users []models.User) ([]userA items := make([]userAdminListItem, 0, len(users)) for _, user := range users { item := userAdminListItem{ - CreatedAt: user.CreatedAt, - Email: user.Email, - FlavorType: user.FlavorType, - ID: user.ID, - IsActive: user.IsActive, - Phone: user.Phone, - RegionIDs: user.RegionIDs(), - Regions: user.Regions, - Role: user.Role, - UpdatedAt: user.UpdatedAt, - Username: user.Username, + CreatedAt: user.CreatedAt, + Email: user.Email, + FlavorType: user.FlavorType, + ID: user.ID, + IsActive: user.IsActive, + Phone: user.Phone, + RegionIDs: user.RegionIDs(), + Regions: user.Regions, + Role: user.Role, + TokenInvalidBefore: user.TokenInvalidBefore, + UpdatedAt: user.UpdatedAt, + Username: user.Username, } for _, regionID := range item.RegionIDs { if kindergartenName, exists := kindergartenMap[regionID]; exists { @@ -336,6 +348,10 @@ func normalizeOptionalString(value *string) *string { return &trimmed } +func isProtectedAdminAccount(username string) bool { + return strings.EqualFold(strings.TrimSpace(username), "admin") +} + func respondUserLookupError(c *gin.Context, err error) { if errors.Is(err, gorm.ErrRecordNotFound) { writeError(c, http.StatusNotFound, "user not found") diff --git a/middleware/jwt_for_user.go b/middleware/jwt_for_user.go index e863c53..0c5e7a2 100644 --- a/middleware/jwt_for_user.go +++ b/middleware/jwt_for_user.go @@ -35,33 +35,29 @@ func JWTAuth() gin.HandlerFunc { return } - role := claims.Role - flavorType := claims.FlavorType - regionIDs := claims.RegionIDs - - if role == "" || flavorType == "" { - var user models.User - if err := config.DB.Preload("Regions").First(&user, claims.UserID).Error; err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"}) - c.Abort() - return - } - if !user.IsActive { - c.JSON(http.StatusForbidden, gin.H{"error": "User is disabled"}) - c.Abort() - return - } - role = user.Role - flavorType = user.FlavorType - regionIDs = user.RegionIDs() + var user models.User + if err := config.DB.Preload("Regions").First(&user, claims.UserID).Error; err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"}) + c.Abort() + return + } + if !user.IsActive { + c.JSON(http.StatusForbidden, gin.H{"error": "User is disabled"}) + c.Abort() + return + } + if util.IsTokenRevoked(&user, claims) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Token has been revoked"}) + c.Abort() + return } // 将用户信息存入上下文 c.Set("userID", claims.UserID) c.Set("username", claims.Username) - c.Set("role", role) - c.Set("flavorType", flavorType) - c.Set("regionIDs", regionIDs) + c.Set("role", user.Role) + c.Set("flavorType", user.FlavorType) + c.Set("regionIDs", user.RegionIDs()) c.Next() } diff --git a/models/user.go b/models/user.go index 14497e0..1e9ca95 100644 --- a/models/user.go +++ b/models/user.go @@ -35,17 +35,18 @@ type UserRegionBinding struct { } type User struct { - ID uint `gorm:"primaryKey" json:"id"` - Username string `gorm:"uniqueIndex;not null" json:"username"` - Email *string `gorm:"uniqueIndex;" json:"email"` - Phone *string `gorm:"uniqueIndex;" json:"phone"` - Password string `gorm:"not null" json:"-"` - Role UserRole `gorm:"type:varchar(32);not null;default:'viewer';index" json:"role"` - FlavorType UserFlavorType `gorm:"type:varchar(32);not null;default:'all';index" json:"flavorType"` - IsActive bool `gorm:"not null;default:true;index" json:"isActive"` - Regions []UserRegionBinding `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE" json:"regions"` - CreatedAt int64 `gorm:"not null" json:"created_at"` - UpdatedAt int64 `gorm:"not null" json:"updated_at"` + ID uint `gorm:"primaryKey" json:"id"` + Username string `gorm:"uniqueIndex;not null" json:"username"` + Email *string `gorm:"uniqueIndex;" json:"email"` + Phone *string `gorm:"uniqueIndex;" json:"phone"` + Password string `gorm:"not null" json:"-"` + Role UserRole `gorm:"type:varchar(32);not null;default:'viewer';index" json:"role"` + FlavorType UserFlavorType `gorm:"type:varchar(32);not null;default:'all';index" json:"flavorType"` + IsActive bool `gorm:"not null;default:true;index" json:"isActive"` + TokenInvalidBefore int64 `gorm:"not null;default:0" json:"tokenInvalidBefore"` + Regions []UserRegionBinding `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE" json:"regions"` + CreatedAt int64 `gorm:"not null" json:"created_at"` + UpdatedAt int64 `gorm:"not null" json:"updated_at"` } // HashPassword 密码加密 diff --git a/util/jwt.go b/util/jwt.go index dbde8ff..370290b 100644 --- a/util/jwt.go +++ b/util/jwt.go @@ -20,8 +20,7 @@ type Claims struct { // GenerateToken 生成JWT Token func GenerateToken(user *models.User) (string, error) { - expirationTime := time.Now().Add(24 * 30 * time.Hour) // Token有效期24小时 - //expirationTime := time.Now().Add(1 * time.Second) // Token有效期24小时 + now := time.Now() claims := &Claims{ UserID: user.ID, @@ -30,12 +29,15 @@ func GenerateToken(user *models.User) (string, error) { FlavorType: user.FlavorType, RegionIDs: user.RegionIDs(), RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(expirationTime), - IssuedAt: jwt.NewNumericDate(time.Now()), - NotBefore: jwt.NewNumericDate(time.Now()), + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now), Issuer: "your-app-name", }, } + if user.FlavorType != models.UserFlavorHeartRate { + expirationTime := now.Add(24 * 30 * time.Hour) + claims.ExpiresAt = jwt.NewNumericDate(expirationTime) + } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) tokenString, err := token.SignedString([]byte(ApiSecret)) @@ -61,3 +63,16 @@ func ParseToken(tokenStr string) (*Claims, error) { return claims, nil } + +func IsTokenRevoked(user *models.User, claims *Claims) bool { + if user == nil || claims == nil { + return true + } + if user.TokenInvalidBefore <= 0 { + return false + } + if claims.IssuedAt == nil { + return true + } + return claims.IssuedAt.UnixMilli() <= user.TokenInvalidBefore +}