feat: user auth.

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