From 88b1df64e7bf264dd4a5fecb542f3c92a9bd5b50 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 11 Jun 2026 07:23:00 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=A0=E5=9B=BA=E5=90=8E=E5=8F=B0=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=AE=89=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.env.example | 12 ++ backend/.env.prod.example | 11 ++ backend/cmd/api/main.go | 12 ++ backend/internal/config/config.go | 93 +++++++++--- backend/internal/middleware/auth.go | 74 ++++++++- backend/internal/middleware/permission.go | 74 +++++---- backend/internal/middleware/rate_limit.go | 62 +++++++- backend/internal/model/admin_user.go | 20 +-- .../internal/modules/adminauth/bootstrap.go | 101 +++++++++++++ backend/internal/modules/adminauth/dto.go | 17 ++- backend/internal/modules/adminauth/handler.go | 69 ++++++++- .../internal/modules/adminauth/repository.go | 140 ++++++++++++------ backend/internal/modules/adminauth/service.go | 24 ++- backend/internal/modules/adminmgr/dto.go | 20 ++- backend/internal/modules/adminmgr/handler.go | 37 ++++- .../internal/modules/adminmgr/repository.go | 56 ++++++- backend/internal/modules/adminmgr/service.go | 45 +++++- .../internal/modules/adminrole/repository.go | 66 ++++++++- backend/internal/modules/auth/jwt.go | 28 ++-- backend/internal/modules/listing/handler.go | 20 ++- .../modules/listing/handler_external.go | 88 +++++++++++ .../modules/listing/handler_external_test.go | 96 ++++++++++++ .../internal/modules/paymentconfig/handler.go | 11 +- .../internal/modules/paymentconfig/query.go | 11 +- .../internal/modules/paymentconfig/service.go | 4 +- backend/internal/router/router.go | 41 +++-- backend/migrations/000001_init.sql | 22 +-- backend/migrations/000002_admin_security.sql | 19 +++ deploy/caddy/Caddyfile | 1 + frontend/src/features/admin/api/adminAuth.ts | 18 +-- frontend/src/features/admin/api/adminMgr.ts | 14 +- frontend/src/features/admin/index.ts | 2 +- .../features/admin/views/AdminLoginView.vue | 10 +- .../admin/views/AdminMgrUsersView.vue | 32 ++-- frontend/src/layouts/AdminLayout.vue | 98 +++++++++++- frontend/src/router/index.ts | 27 ++-- frontend/src/shared/api/client.ts | 20 ++- frontend/src/shared/utils/authStorage.ts | 10 +- frontend/src/stores/adminSession.ts | 34 ++--- scripts/dev.sh | 2 +- scripts/load_stress.go | 28 ++-- 41 files changed, 1276 insertions(+), 293 deletions(-) create mode 100644 backend/internal/modules/adminauth/bootstrap.go create mode 100644 backend/internal/modules/listing/handler_external_test.go create mode 100644 backend/migrations/000002_admin_security.sql diff --git a/backend/.env.example b/backend/.env.example index bb50d6f..6b7b8eb 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -14,6 +14,12 @@ REDIS_DB=0 JWT_SECRET=change-me +# 首次启动且管理员表为空时,按以下变量创建首个超级管理员。 +# 本地开发可自行填写,生产环境请使用随机强密码,创建后建议从 .env 移除。 +ADMIN_BOOTSTRAP_USERNAME= +ADMIN_BOOTSTRAP_PASSWORD= +ADMIN_BOOTSTRAP_NICKNAME=超级管理员 + # API 限流:默认开启,每个 IP 每分钟 300 次。 RATE_LIMIT_ENABLED=true RATE_LIMIT_REQUESTS_PER_MINUTE=300 @@ -49,3 +55,9 @@ REALNAME_CLOUDMARKET_APPCODE= # 用于加密存储支付商户配置中的敏感信息(sign_key、notify_key) # 生成方式:openssl rand -hex 16 PAYMENT_CONFIG_ENCRYPTION_KEY= + +# 开放导入接口签名密钥;留空时 /api/open/listing-uploads 不可用。 +# 请求需携带 X-HFB-Timestamp 和 X-HFB-Signature。 +EXTERNAL_UPLOAD_SECRET= +# 可选:逗号分隔的 IP 或 CIDR 白名单,例如 127.0.0.1,10.0.0.0/8。 +EXTERNAL_UPLOAD_ALLOWED_IPS= diff --git a/backend/.env.prod.example b/backend/.env.prod.example index a19d697..2367079 100644 --- a/backend/.env.prod.example +++ b/backend/.env.prod.example @@ -19,6 +19,12 @@ REDIS_DB=0 JWT_SECRET=change-to-a-long-random-secret +# 首次启动且管理员表为空时创建首个超级管理员;生产密码至少 12 位且包含字母和数字。 +# 创建成功后建议从 .env 移除,后续管理员通过后台维护。 +ADMIN_BOOTSTRAP_USERNAME=change-admin-username +ADMIN_BOOTSTRAP_PASSWORD=change-admin-password-123 +ADMIN_BOOTSTRAP_NICKNAME=超级管理员 + # API 限流:默认开启,每个 IP 每分钟 300 次。 RATE_LIMIT_ENABLED=true RATE_LIMIT_REQUESTS_PER_MINUTE=300 @@ -55,3 +61,8 @@ REALNAME_CLOUDMARKET_APPCODE= # 生成方式:openssl rand -hex 16 # 警告:此密钥一旦设置不要更改,否则已有配置无法解密 PAYMENT_CONFIG_ENCRYPTION_KEY=change-to-32-byte-encryption-key + +# 开放导入接口签名密钥,生产环境必填;生成方式:openssl rand -hex 32 +EXTERNAL_UPLOAD_SECRET=change-to-a-long-random-upload-secret +# 可选:逗号分隔的 IP 或 CIDR 白名单,例如 203.0.113.10,10.0.0.0/8。 +EXTERNAL_UPLOAD_ALLOWED_IPS= diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 99c1053..59296f5 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -12,6 +12,7 @@ import ( "hfb_sys/backend/internal/database" "hfb_sys/backend/internal/jobs/ordertimeout" "hfb_sys/backend/internal/logging" + "hfb_sys/backend/internal/modules/adminauth" "hfb_sys/backend/internal/router" "go.uber.org/zap" @@ -45,6 +46,9 @@ func main() { defer func() { _ = logger.Sync() }() + if err := cfg.ValidateProductionSecurity(); err != nil { + logger.Fatal("production security config invalid", zap.Error(err)) + } var deps router.Dependencies db, err := database.OpenMySQL(cfg.MySQLDSN, cfg.Log.Level) @@ -52,6 +56,14 @@ func main() { logger.Warn("mysql unavailable; database-backed APIs will return 503", zap.Error(err)) } else { deps.DB = db + if err := adminauth.BootstrapAdmin(context.Background(), db, adminauth.BootstrapConfig{ + AppEnv: cfg.AppEnv, + Username: cfg.BootstrapAdminUsername, + Password: cfg.BootstrapAdminPassword, + Nickname: cfg.BootstrapAdminNickname, + }); err != nil { + logger.Fatal("admin bootstrap failed", zap.Error(err)) + } } redisClient, err := database.OpenRedis(context.Background(), database.RedisConfig{ Addr: cfg.RedisAddr, diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 9654893..31c5cc5 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -1,23 +1,31 @@ package config import ( + "errors" "os" "strconv" + "strings" ) type Config struct { - AppEnv string - AppAddr string - MySQLDSN string - RedisAddr string - RedisPassword string - RedisDB int - JWTSecret string - Storage StorageConfig - SMS SMSConfig - Realname RealnameConfig - Log LogConfig - RateLimit RateLimitConfig + AppEnv string + AppAddr string + MySQLDSN string + RedisAddr string + RedisPassword string + RedisDB int + JWTSecret string + PaymentConfigEncryptionKey string + ExternalUploadSecret string + ExternalUploadAllowedIPs []string + BootstrapAdminUsername string + BootstrapAdminPassword string + BootstrapAdminNickname string + Storage StorageConfig + SMS SMSConfig + Realname RealnameConfig + Log LogConfig + RateLimit RateLimitConfig } type StorageConfig struct { @@ -56,13 +64,19 @@ type RateLimitConfig struct { func Load() Config { return Config{ - AppEnv: getEnv("APP_ENV", "development"), - AppAddr: getEnv("APP_ADDR", ":8080"), - MySQLDSN: getEnv("MYSQL_DSN", "hfb:secret@tcp(127.0.0.1:3306)/hfb_sys?charset=utf8mb4&parseTime=True&loc=Local"), - RedisAddr: getEnv("REDIS_ADDR", "127.0.0.1:6379"), - RedisPassword: getEnv("REDIS_PASSWORD", ""), - RedisDB: getEnvInt("REDIS_DB", 0), - JWTSecret: getEnv("JWT_SECRET", "change-me"), + AppEnv: getEnv("APP_ENV", "development"), + AppAddr: getEnv("APP_ADDR", ":8080"), + MySQLDSN: getEnv("MYSQL_DSN", "hfb:secret@tcp(127.0.0.1:3306)/hfb_sys?charset=utf8mb4&parseTime=True&loc=Local"), + RedisAddr: getEnv("REDIS_ADDR", "127.0.0.1:6379"), + RedisPassword: getEnv("REDIS_PASSWORD", ""), + RedisDB: getEnvInt("REDIS_DB", 0), + JWTSecret: getEnv("JWT_SECRET", "change-me"), + PaymentConfigEncryptionKey: getEnv("PAYMENT_CONFIG_ENCRYPTION_KEY", ""), + ExternalUploadSecret: getEnv("EXTERNAL_UPLOAD_SECRET", ""), + ExternalUploadAllowedIPs: getEnvList("EXTERNAL_UPLOAD_ALLOWED_IPS"), + BootstrapAdminUsername: getEnv("ADMIN_BOOTSTRAP_USERNAME", ""), + BootstrapAdminPassword: getEnv("ADMIN_BOOTSTRAP_PASSWORD", ""), + BootstrapAdminNickname: getEnv("ADMIN_BOOTSTRAP_NICKNAME", "超级管理员"), Storage: StorageConfig{ Endpoint: getEnv("STORAGE_ENDPOINT", "http://localhost:9000"), Bucket: getEnv("STORAGE_BUCKET", "hfb-sys"), @@ -95,6 +109,26 @@ func Load() Config { } } +func (c Config) ValidateProductionSecurity() error { + if strings.ToLower(strings.TrimSpace(c.AppEnv)) != "production" { + return nil + } + if strings.TrimSpace(c.JWTSecret) == "" || isPlaceholder(c.JWTSecret) || len([]byte(c.JWTSecret)) < 32 { + return errors.New("JWT_SECRET must be a non-default random value of at least 32 bytes in production") + } + keyLen := len([]byte(c.PaymentConfigEncryptionKey)) + if isPlaceholder(c.PaymentConfigEncryptionKey) || (keyLen != 16 && keyLen != 24 && keyLen != 32) { + return errors.New("PAYMENT_CONFIG_ENCRYPTION_KEY must be 16, 24, or 32 bytes in production") + } + if strings.TrimSpace(c.ExternalUploadSecret) == "" || isPlaceholder(c.ExternalUploadSecret) { + return errors.New("EXTERNAL_UPLOAD_SECRET is required in production") + } + if c.BootstrapAdminPassword != "" && isPlaceholder(c.BootstrapAdminPassword) { + return errors.New("ADMIN_BOOTSTRAP_PASSWORD must not use the example placeholder in production") + } + return nil +} + func getEnv(key, fallback string) string { value := os.Getenv(key) if value == "" { @@ -126,3 +160,24 @@ func getEnvBool(key string, fallback bool) bool { } return parsed } + +func getEnvList(key string) []string { + raw := os.Getenv(key) + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + values := make([]string, 0, len(parts)) + for _, part := range parts { + value := strings.TrimSpace(part) + if value != "" { + values = append(values, value) + } + } + return values +} + +func isPlaceholder(value string) bool { + normalized := strings.ToLower(strings.TrimSpace(value)) + return normalized == "change-me" || strings.HasPrefix(normalized, "change-") || strings.Contains(normalized, "change-to-") +} diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go index 64b2d9c..62ae9e4 100644 --- a/backend/internal/middleware/auth.go +++ b/backend/internal/middleware/auth.go @@ -1,6 +1,8 @@ package middleware import ( + "context" + "net/http" "strings" "hfb_sys/backend/internal/modules/auth" @@ -10,18 +12,34 @@ import ( ) const ( - ContextUserID = "user_id" - ContextPhone = "phone" - ContextAdminID = "admin_id" - ContextUsername = "username" + ContextUserID = "user_id" + ContextPhone = "phone" + ContextAdminID = "admin_id" + ContextUsername = "username" + ContextPasswordMustChange = "password_must_change" + AdminAccessCookieName = "hfb_admin_access" ) -func extractToken(c *gin.Context) string { +type AdminTokenContext struct { + Username string + PasswordMustChange bool +} + +type AdminTokenValidatorFunc func(ctx context.Context, adminID uint64, tokenVersion int64) (AdminTokenContext, error) + +func extractBearerToken(c *gin.Context) string { header := c.GetHeader("Authorization") tokenText := strings.TrimSpace(strings.TrimPrefix(header, "Bearer ")) if tokenText != "" && tokenText != header { return tokenText } + return "" +} + +func extractToken(c *gin.Context) string { + if tokenText := extractBearerToken(c); tokenText != "" { + return tokenText + } return c.Query("token") } @@ -47,9 +65,14 @@ func Auth(jwtManager *auth.JWTManager) gin.HandlerFunc { } } -func AdminAuth(jwtManager *auth.JWTManager) gin.HandlerFunc { +func AdminAuth(jwtManager *auth.JWTManager, validate AdminTokenValidatorFunc) gin.HandlerFunc { return func(c *gin.Context) { - tokenText := extractToken(c) + tokenText := extractBearerToken(c) + if tokenText == "" { + if cookieToken, err := c.Cookie(AdminAccessCookieName); err == nil { + tokenText = strings.TrimSpace(cookieToken) + } + } if tokenText == "" { response.Unauthorized(c, "缺少后台访问令牌") c.Abort() @@ -62,9 +85,44 @@ func AdminAuth(jwtManager *auth.JWTManager) gin.HandlerFunc { c.Abort() return } + username := claims.Phone + passwordMustChange := false + if validate != nil { + tokenContext, err := validate(c.Request.Context(), claims.UserID, claims.TokenVersion) + if err != nil { + response.Unauthorized(c, "后台访问令牌无效或已过期") + c.Abort() + return + } + username = tokenContext.Username + passwordMustChange = tokenContext.PasswordMustChange + } c.Set(ContextAdminID, claims.UserID) - c.Set(ContextUsername, claims.Phone) + c.Set(ContextUsername, username) + c.Set(ContextPasswordMustChange, passwordMustChange) c.Next() } } + +func RequireAdminPasswordChanged() gin.HandlerFunc { + allowed := map[string]bool{ + "/api/admin/me": true, + "/api/admin/auth/logout": true, + "/api/admin/admin-users/me/password": true, + } + return func(c *gin.Context) { + value, ok := c.Get(ContextPasswordMustChange) + if !ok { + c.Next() + return + } + mustChange, ok := value.(bool) + if !ok || !mustChange || allowed[c.FullPath()] { + c.Next() + return + } + response.Error(c, http.StatusForbidden, "password_must_change", "请先修改初始密码") + c.Abort() + } +} diff --git a/backend/internal/middleware/permission.go b/backend/internal/middleware/permission.go index 0b39ecc..2075821 100644 --- a/backend/internal/middleware/permission.go +++ b/backend/internal/middleware/permission.go @@ -16,38 +16,56 @@ import ( // 超级管理员(拥有 super_admin 角色的管理员)自动放行。 func RequirePermission(permCode string, rdb *redis.Client) gin.HandlerFunc { return func(c *gin.Context) { - value, ok := c.Get(ContextAdminID) - if !ok { - response.Unauthorized(c, "缺少管理员上下文") - c.Abort() - return + if checkPermission(c, permCode, rdb) { + c.Next() } - adminID, ok := value.(uint64) - if !ok { - response.Unauthorized(c, "管理员上下文无效") - c.Abort() - return - } - - codes, err := getPermCodes(c, rdb, adminID) - if err != nil { - response.Error(c, http.StatusInternalServerError, "perm_check_failed", "权限校验服务暂时不可用") - c.Abort() - return - } - - for _, code := range codes { - if code == permCode || code == "*" { - c.Next() - return - } - } - - response.Error(c, http.StatusForbidden, "forbidden", "没有操作权限") - c.Abort() } } +func RequirePermissionIf(permCode string, rdb *redis.Client, predicate func(*gin.Context) bool) gin.HandlerFunc { + return func(c *gin.Context) { + if predicate == nil || !predicate(c) { + c.Next() + return + } + if checkPermission(c, permCode, rdb) { + c.Next() + } + } +} + +func checkPermission(c *gin.Context, permCode string, rdb *redis.Client) bool { + value, ok := c.Get(ContextAdminID) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + c.Abort() + return false + } + adminID, ok := value.(uint64) + if !ok { + response.Unauthorized(c, "管理员上下文无效") + c.Abort() + return false + } + + codes, err := getPermCodes(c, rdb, adminID) + if err != nil { + response.Error(c, http.StatusInternalServerError, "perm_check_failed", "权限校验服务暂时不可用") + c.Abort() + return false + } + + for _, code := range codes { + if code == permCode || code == "*" { + return true + } + } + + response.Error(c, http.StatusForbidden, "forbidden", "没有操作权限") + c.Abort() + return false +} + func getPermCodes(c *gin.Context, rdb *redis.Client, adminID uint64) ([]string, error) { if rdb == nil { return nil, errors.New("redis unavailable") diff --git a/backend/internal/middleware/rate_limit.go b/backend/internal/middleware/rate_limit.go index 1e71ff7..a5dfc9f 100644 --- a/backend/internal/middleware/rate_limit.go +++ b/backend/internal/middleware/rate_limit.go @@ -1,12 +1,14 @@ package middleware import ( + "context" "net/http" "strconv" "sync" "time" "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" ) type rateLimitBucket struct { @@ -22,7 +24,14 @@ type rateLimiter struct { buckets map[string]rateLimitBucket } -func RateLimitPerMinute(limit int) gin.HandlerFunc { +type redisRateLimiter struct { + redis *redis.Client + fallback *rateLimiter + limit int + window time.Duration +} + +func RateLimitPerMinute(limit int, rdb *redis.Client) gin.HandlerFunc { if limit <= 0 { return func(c *gin.Context) { c.Next() @@ -33,24 +42,57 @@ func RateLimitPerMinute(limit int) gin.HandlerFunc { window: time.Minute, buckets: make(map[string]rateLimitBucket), } + if rdb != nil { + return (&redisRateLimiter{ + redis: rdb, + fallback: limiter, + limit: limit, + window: time.Minute, + }).handle + } return limiter.handle } +func (l *redisRateLimiter) handle(c *gin.Context) { + now := time.Now() + key := c.ClientIP() + allowed, resetAt, err := l.allow(c.Request.Context(), key, now) + if err != nil { + allowed, resetAt = l.fallback.allow(key, now) + } + if !allowed { + writeRateLimited(c, now, resetAt) + return + } + c.Next() +} + func (l *rateLimiter) handle(c *gin.Context) { now := time.Now() key := c.ClientIP() allowed, resetAt := l.allow(key, now) if !allowed { - c.Header("Retry-After", retryAfterSeconds(now, resetAt)) - c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{ - "code": "rate_limited", - "message": "请求过于频繁,请稍后再试", - }) + writeRateLimited(c, now, resetAt) return } c.Next() } +func (l *redisRateLimiter) allow(ctx context.Context, key string, now time.Time) (bool, time.Time, error) { + windowSeconds := int64(l.window / time.Second) + windowID := now.Unix() / windowSeconds + redisKey := "rate_limit:" + key + ":" + strconv.FormatInt(windowID, 10) + count, err := l.redis.Incr(ctx, redisKey).Result() + if err != nil { + return false, time.Time{}, err + } + if count == 1 { + _ = l.redis.Expire(ctx, redisKey, 2*l.window).Err() + } + resetAt := time.Unix((windowID+1)*windowSeconds, 0) + return count <= int64(l.limit), resetAt, nil +} + func (l *rateLimiter) allow(key string, now time.Time) (bool, time.Time) { l.mu.Lock() defer l.mu.Unlock() @@ -81,3 +123,11 @@ func retryAfterSeconds(now time.Time, resetAt time.Time) string { } return strconv.Itoa(seconds) } + +func writeRateLimited(c *gin.Context, now time.Time, resetAt time.Time) { + c.Header("Retry-After", retryAfterSeconds(now, resetAt)) + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{ + "code": "rate_limited", + "message": "请求过于频繁,请稍后再试", + }) +} diff --git a/backend/internal/model/admin_user.go b/backend/internal/model/admin_user.go index b3e7502..7a089d1 100644 --- a/backend/internal/model/admin_user.go +++ b/backend/internal/model/admin_user.go @@ -3,15 +3,17 @@ package model import "time" type AdminUser struct { - ID uint64 `gorm:"primaryKey" json:"id"` - Username string `gorm:"size:64;not null;uniqueIndex" json:"username"` - PasswordHash string `gorm:"size:255;not null" json:"-"` - Nickname string `gorm:"size:64;not null;default:''" json:"nickname"` - Status string `gorm:"size:32;not null;default:'active'" json:"status"` - SupportStatus string `gorm:"size:16;not null;default:'offline';index" json:"support_status"` - LastLoginAt *time.Time `json:"last_login_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint64 `gorm:"primaryKey" json:"id"` + Username string `gorm:"size:64;not null;uniqueIndex" json:"username"` + PasswordHash string `gorm:"size:255;not null" json:"-"` + Nickname string `gorm:"size:64;not null;default:''" json:"nickname"` + Status string `gorm:"size:32;not null;default:'active'" json:"status"` + SupportStatus string `gorm:"size:16;not null;default:'offline';index" json:"support_status"` + TokenVersion int64 `gorm:"not null;default:1" json:"-"` + PasswordMustChange bool `gorm:"not null;default:false" json:"password_must_change"` + LastLoginAt *time.Time `json:"last_login_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func (AdminUser) TableName() string { diff --git a/backend/internal/modules/adminauth/bootstrap.go b/backend/internal/modules/adminauth/bootstrap.go new file mode 100644 index 0000000..0275fd7 --- /dev/null +++ b/backend/internal/modules/adminauth/bootstrap.go @@ -0,0 +1,101 @@ +package adminauth + +import ( + "context" + "errors" + "strings" + "unicode" + + "hfb_sys/backend/internal/model" + + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +type BootstrapConfig struct { + AppEnv string + Username string + Password string + Nickname string +} + +// BootstrapAdmin 在管理员表为空时按部署配置创建首个超级管理员。 +func BootstrapAdmin(ctx context.Context, db *gorm.DB, cfg BootstrapConfig) error { + if db == nil { + return nil + } + var count int64 + if err := db.WithContext(ctx).Model(&model.AdminUser{}).Count(&count).Error; err != nil { + if isAdminTableMissing(err) { + return nil + } + return err + } + if count > 0 { + return nil + } + + username := strings.TrimSpace(cfg.Username) + password := cfg.Password + if username == "" || password == "" { + if strings.EqualFold(strings.TrimSpace(cfg.AppEnv), "production") { + return errors.New("admin bootstrap credentials are required when no admin exists in production") + } + return nil + } + if strings.EqualFold(strings.TrimSpace(cfg.AppEnv), "production") && !bootstrapPasswordStrong(password) { + return errors.New("ADMIN_BOOTSTRAP_PASSWORD must be at least 12 chars and include letters and digits in production") + } + nickname := strings.TrimSpace(cfg.Nickname) + if nickname == "" { + nickname = username + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + admin := model.AdminUser{ + Username: username, + PasswordHash: string(hash), + Nickname: nickname, + Status: "active", + TokenVersion: 1, + PasswordMustChange: true, + } + if err := tx.Create(&admin).Error; err != nil { + return err + } + var superAdminRole model.Role + if err := tx.Where("code = ?", "super_admin").First(&superAdminRole).Error; err != nil { + return err + } + return tx.Create(&model.AdminUserRole{ + AdminID: admin.ID, + RoleID: superAdminRole.ID, + }).Error + }) +} + +func bootstrapPasswordStrong(value string) bool { + if len([]rune(value)) < 12 { + return false + } + hasLetter := false + hasDigit := false + for _, r := range value { + if unicode.IsLetter(r) { + hasLetter = true + } + if unicode.IsDigit(r) { + hasDigit = true + } + } + return hasLetter && hasDigit +} + +func isAdminTableMissing(err error) bool { + message := strings.ToLower(err.Error()) + return strings.Contains(message, "admin_users") && (strings.Contains(message, "doesn't exist") || strings.Contains(message, "no such table")) +} diff --git a/backend/internal/modules/adminauth/dto.go b/backend/internal/modules/adminauth/dto.go index 7ab7604..dafcc05 100644 --- a/backend/internal/modules/adminauth/dto.go +++ b/backend/internal/modules/adminauth/dto.go @@ -7,14 +7,15 @@ import ( ) type AdminDTO struct { - ID uint64 `json:"id"` - Username string `json:"username"` - Nickname string `json:"nickname"` - Status string `json:"status"` - SupportStatus string `json:"support_status"` - Roles []RoleDTO `json:"roles"` - Permissions []string `json:"permissions"` - LastLoginAt *time.Time `json:"last_login_at"` + ID uint64 `json:"id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Status string `json:"status"` + SupportStatus string `json:"support_status"` + PasswordMustChange bool `json:"password_must_change"` + Roles []RoleDTO `json:"roles"` + Permissions []string `json:"permissions"` + LastLoginAt *time.Time `json:"last_login_at"` } type RoleDTO struct { diff --git a/backend/internal/modules/adminauth/handler.go b/backend/internal/modules/adminauth/handler.go index c9ba282..daf2ba3 100644 --- a/backend/internal/modules/adminauth/handler.go +++ b/backend/internal/modules/adminauth/handler.go @@ -2,15 +2,23 @@ package adminauth import ( "errors" + "io" "net/http" "strings" "hfb_sys/backend/internal/middleware" + "hfb_sys/backend/internal/modules/auth" "hfb_sys/backend/pkg/response" "github.com/gin-gonic/gin" ) +const ( + adminAccessCookieName = middleware.AdminAccessCookieName + adminRefreshCookieName = "hfb_admin_refresh" + adminRefreshCookieMaxAge = 14 * 24 * 60 * 60 +) + type Handler struct { service *Service } @@ -34,12 +42,13 @@ func (h *Handler) Login(c *gin.Context) { response.BadRequest(c, "用户名、密码和验证码不能为空") return } - result, err := h.service.Login(c.Request.Context(), strings.TrimSpace(req.Username), req.Password, strings.TrimSpace(req.CaptchaID), strings.TrimSpace(req.CaptchaCode)) + result, err := h.service.Login(c.Request.Context(), strings.TrimSpace(req.Username), req.Password, strings.TrimSpace(req.CaptchaID), strings.TrimSpace(req.CaptchaCode), c.ClientIP()) if err != nil { writeAdminAuthError(c, err) return } - response.OK(c, result) + setAdminAuthCookies(c, result.Tokens) + response.OK(c, gin.H{"admin": result.Admin}) } func (h *Handler) Me(c *gin.Context) { @@ -62,6 +71,16 @@ func (h *Handler) Me(c *gin.Context) { } func (h *Handler) Logout(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + if err := h.service.Logout(c.Request.Context(), adminID); err != nil { + writeAdminAuthError(c, err) + return + } + clearAdminAuthCookies(c) response.OK(c, gin.H{"logged_out": true}) } @@ -93,21 +112,57 @@ func currentAdminID(c *gin.Context) (uint64, bool) { } type AdminRefreshRequest struct { - RefreshToken string `json:"refresh_token" binding:"required"` + RefreshToken string `json:"refresh_token"` } func (h *Handler) Refresh(c *gin.Context) { var req AdminRefreshRequest - if err := c.ShouldBindJSON(&req); err != nil { + if c.Request.Body != nil && c.Request.ContentLength != 0 { + if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) { + response.BadRequest(c, "refresh_token 格式不正确") + return + } + } + refreshToken := strings.TrimSpace(req.RefreshToken) + if refreshToken == "" { + if cookieValue, err := c.Cookie(adminRefreshCookieName); err == nil { + refreshToken = strings.TrimSpace(cookieValue) + } + } + if refreshToken == "" { response.BadRequest(c, "refresh_token 不能为空") return } - tokens, err := h.service.Refresh(c.Request.Context(), req.RefreshToken) + tokens, err := h.service.Refresh(c.Request.Context(), refreshToken) if err != nil { writeAdminAuthError(c, err) return } - response.OK(c, tokens) + setAdminAuthCookies(c, *tokens) + response.OK(c, gin.H{"refreshed": true, "expires_in": tokens.ExpiresInSeconds}) +} + +func setAdminAuthCookies(c *gin.Context, tokens auth.TokenPair) { + secure := isHTTPSRequest(c) + httpOnly := true + sameSite := http.SameSiteStrictMode + c.SetSameSite(sameSite) + c.SetCookie(adminAccessCookieName, tokens.AccessToken, int(tokens.ExpiresInSeconds), "/api/admin", "", secure, httpOnly) + c.SetCookie(adminRefreshCookieName, tokens.RefreshToken, adminRefreshCookieMaxAge, "/api/admin/auth/refresh", "", secure, httpOnly) +} + +func clearAdminAuthCookies(c *gin.Context) { + secure := isHTTPSRequest(c) + c.SetSameSite(http.SameSiteStrictMode) + c.SetCookie(adminAccessCookieName, "", -1, "/api/admin", "", secure, true) + c.SetCookie(adminRefreshCookieName, "", -1, "/api/admin/auth/refresh", "", secure, true) +} + +func isHTTPSRequest(c *gin.Context) bool { + if c.Request.TLS != nil { + return true + } + return strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") } func writeAdminAuthError(c *gin.Context, err error) { @@ -120,6 +175,8 @@ func writeAdminAuthError(c *gin.Context, err error) { response.BadRequest(c, "用户名或密码错误") case errors.Is(err, ErrCaptchaInvalid): response.BadRequest(c, "验证码错误或已过期") + case errors.Is(err, ErrLoginLocked): + response.Error(c, http.StatusTooManyRequests, "login_locked", "登录失败次数过多,请稍后再试") case errors.Is(err, ErrAdminDisabled): response.Error(c, http.StatusForbidden, "admin_disabled", "管理员已禁用") default: diff --git a/backend/internal/modules/adminauth/repository.go b/backend/internal/modules/adminauth/repository.go index f335d70..799b09c 100644 --- a/backend/internal/modules/adminauth/repository.go +++ b/backend/internal/modules/adminauth/repository.go @@ -21,9 +21,10 @@ import ( ) const ( - defaultAdminUsername = "admin" - defaultAdminPassword = "admin123456" captchaTTL = 3 * time.Minute + loginFailureTTL = 15 * time.Minute + loginLockTTL = 15 * time.Minute + loginMaxFailureCount = 5 ) type Repository struct { @@ -58,16 +59,17 @@ func (r *Repository) Captcha(ctx context.Context) (*CaptchaDTO, error) { }, nil } -func (r *Repository) Login(ctx context.Context, username string, password string, captchaID string, captchaCode string) (LoginResult, error) { +func (r *Repository) Login(ctx context.Context, username string, password string, captchaID string, captchaCode string, clientIP string) (LoginResult, error) { if err := r.verifyCaptcha(ctx, captchaID, captchaCode); err != nil { return LoginResult{}, err } - if err := r.ensureDefaultAdmin(ctx); err != nil { + if err := r.ensureLoginNotLocked(ctx, username, clientIP); err != nil { return LoginResult{}, err } var admin model.AdminUser if err := r.db.WithContext(ctx).Where("username = ?", username).First(&admin).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { + r.recordLoginFailure(ctx, username, clientIP) return LoginResult{}, ErrInvalidCredential } return LoginResult{}, err @@ -76,14 +78,19 @@ func (r *Repository) Login(ctx context.Context, username string, password string return LoginResult{}, ErrAdminDisabled } if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)); err != nil { + r.recordLoginFailure(ctx, username, clientIP) return LoginResult{}, ErrInvalidCredential } now := time.Now() admin.LastLoginAt = &now + if admin.TokenVersion <= 0 { + admin.TokenVersion = 1 + } if err := r.db.WithContext(ctx).Save(&admin).Error; err != nil { return LoginResult{}, err } - tokens, err := r.jwt.GenerateSubjectPair(admin.ID, admin.Username, "admin") + r.clearLoginFailures(ctx, username, clientIP) + tokens, err := r.jwt.GenerateSubjectPairWithVersion(admin.ID, admin.Username, "admin", admin.TokenVersion) if err != nil { return LoginResult{}, err } @@ -92,6 +99,26 @@ func (r *Repository) Login(ctx context.Context, username string, password string return LoginResult{Admin: dto, Tokens: tokens}, nil } +func (r *Repository) FindActiveForToken(ctx context.Context, id uint64, tokenVersion int64) (*model.AdminUser, error) { + var admin model.AdminUser + if err := r.db.WithContext(ctx).First(&admin, id).Error; err != nil { + return nil, err + } + if admin.Status != "active" { + return nil, ErrAdminDisabled + } + if admin.TokenVersion <= 0 || admin.TokenVersion != tokenVersion { + return nil, ErrInvalidRefreshToken + } + return &admin, nil +} + +func (r *Repository) RevokeTokens(ctx context.Context, adminID uint64) error { + return r.db.WithContext(ctx).Model(&model.AdminUser{}). + Where("id = ?", adminID). + UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error +} + func (r *Repository) verifyCaptcha(ctx context.Context, captchaID string, captchaCode string) error { if r.redis == nil { return ErrDependencyUnavailable @@ -136,48 +163,15 @@ func (r *Repository) UpdateSupportStatus(ctx context.Context, adminID uint64, st Update("support_status", status).Error } -func (r *Repository) ensureDefaultAdmin(ctx context.Context) error { - var count int64 - if err := r.db.WithContext(ctx).Model(&model.AdminUser{}).Count(&count).Error; err != nil { - return err - } - if count > 0 { - return nil - } - hash, err := bcrypt.GenerateFromPassword([]byte(defaultAdminPassword), bcrypt.DefaultCost) - if err != nil { - return err - } - admin := model.AdminUser{ - Username: defaultAdminUsername, - PasswordHash: string(hash), - Nickname: "超级管理员", - Status: "active", - } - if err := r.db.WithContext(ctx).Create(&admin).Error; err != nil { - return err - } - - // 自动关联 super_admin 角色 - var superAdminRole model.Role - if err := r.db.WithContext(ctx).Where("code = ?", "super_admin").First(&superAdminRole).Error; err == nil { - r.db.WithContext(ctx).Create(&model.AdminUserRole{ - AdminID: admin.ID, - RoleID: superAdminRole.ID, - }) - } - - return nil -} - func toDTO(admin model.AdminUser) AdminDTO { return AdminDTO{ - ID: admin.ID, - Username: admin.Username, - Nickname: admin.Nickname, - Status: admin.Status, - SupportStatus: admin.SupportStatus, - LastLoginAt: admin.LastLoginAt, + ID: admin.ID, + Username: admin.Username, + Nickname: admin.Nickname, + Status: admin.Status, + SupportStatus: admin.SupportStatus, + PasswordMustChange: admin.PasswordMustChange, + LastLoginAt: admin.LastLoginAt, } } @@ -224,6 +218,62 @@ func cachePermissions(ctx context.Context, r *Repository, adminID uint64, permCo r.redis.Set(ctx, key, string(raw), 2*time.Hour) } +func (r *Repository) ensureLoginNotLocked(ctx context.Context, username string, clientIP string) error { + if r.redis == nil { + return nil + } + locked, err := r.redis.Exists(ctx, loginLockKey(username, clientIP)).Result() + if err != nil { + return err + } + if locked > 0 { + return ErrLoginLocked + } + return nil +} + +func (r *Repository) recordLoginFailure(ctx context.Context, username string, clientIP string) { + if r.redis == nil { + return + } + key := loginFailureKey(username, clientIP) + count, err := r.redis.Incr(ctx, key).Result() + if err != nil { + return + } + if count == 1 { + _ = r.redis.Expire(ctx, key, loginFailureTTL).Err() + } + if count >= loginMaxFailureCount { + _ = r.redis.Set(ctx, loginLockKey(username, clientIP), "1", loginLockTTL).Err() + } +} + +func (r *Repository) clearLoginFailures(ctx context.Context, username string, clientIP string) { + if r.redis == nil { + return + } + _ = r.redis.Del(ctx, loginFailureKey(username, clientIP), loginLockKey(username, clientIP)).Err() +} + +func loginFailureKey(username string, clientIP string) string { + return "admin:login:fail:" + loginKeyPart(clientIP) + ":" + loginKeyPart(username) +} + +func loginLockKey(username string, clientIP string) string { + return "admin:login:lock:" + loginKeyPart(clientIP) + ":" + loginKeyPart(username) +} + +func loginKeyPart(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + value = strings.ReplaceAll(value, ":", "_") + value = strings.ReplaceAll(value, "/", "_") + if value == "" { + return "_" + } + return value +} + func captchaKey(id string) string { return "admin:captcha:" + id } diff --git a/backend/internal/modules/adminauth/service.go b/backend/internal/modules/adminauth/service.go index d1e2870..28b7773 100644 --- a/backend/internal/modules/adminauth/service.go +++ b/backend/internal/modules/adminauth/service.go @@ -13,6 +13,7 @@ var ( ErrCaptchaInvalid = errors.New("captcha invalid") ErrAdminDisabled = errors.New("admin disabled") ErrInvalidRefreshToken = errors.New("invalid refresh token") + ErrLoginLocked = errors.New("login locked") ) type Service struct { @@ -32,7 +33,17 @@ func (s *Service) Refresh(ctx context.Context, refreshToken string) (*auth.Token if err != nil { return nil, ErrInvalidRefreshToken } - pair, err := s.jwt.GenerateSubjectPair(claims.UserID, claims.Phone, "admin") + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + admin, err := s.repo.FindActiveForToken(ctx, claims.UserID, claims.TokenVersion) + if err != nil { + if errors.Is(err, ErrAdminDisabled) { + return nil, ErrAdminDisabled + } + return nil, ErrInvalidRefreshToken + } + pair, err := s.jwt.GenerateSubjectPairWithVersion(admin.ID, admin.Username, "admin", admin.TokenVersion) if err != nil { return nil, err } @@ -46,14 +57,14 @@ func (s *Service) Captcha(ctx context.Context) (*CaptchaDTO, error) { return s.repo.Captcha(ctx) } -func (s *Service) Login(ctx context.Context, username string, password string, captchaID string, captchaCode string) (LoginResult, error) { +func (s *Service) Login(ctx context.Context, username string, password string, captchaID string, captchaCode string, clientIP string) (LoginResult, error) { if s.repo == nil { return LoginResult{}, ErrDependencyUnavailable } if username == "" || password == "" || captchaID == "" || captchaCode == "" { return LoginResult{}, ErrInvalidCredential } - return s.repo.Login(ctx, username, password, captchaID, captchaCode) + return s.repo.Login(ctx, username, password, captchaID, captchaCode, clientIP) } func (s *Service) Me(ctx context.Context, adminID uint64) (*AdminDTO, error) { @@ -69,3 +80,10 @@ func (s *Service) UpdateSupportStatus(ctx context.Context, adminID uint64, statu } return s.repo.UpdateSupportStatus(ctx, adminID, status) } + +func (s *Service) Logout(ctx context.Context, adminID uint64) error { + if s.repo == nil { + return ErrDependencyUnavailable + } + return s.repo.RevokeTokens(ctx, adminID) +} diff --git a/backend/internal/modules/adminmgr/dto.go b/backend/internal/modules/adminmgr/dto.go index dde44d2..5092957 100644 --- a/backend/internal/modules/adminmgr/dto.go +++ b/backend/internal/modules/adminmgr/dto.go @@ -7,14 +7,14 @@ import ( ) type AdminUserDTO struct { - ID uint64 `json:"id"` - Username string `json:"username"` - Nickname string `json:"nickname"` - Status string `json:"status"` - Roles []adminrole.RoleDTO `json:"roles"` - LastLoginAt *time.Time `json:"last_login_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint64 `json:"id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Status string `json:"status"` + Roles []adminrole.RoleDTO `json:"roles"` + LastLoginAt *time.Time `json:"last_login_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type PaginatedResult struct { @@ -43,3 +43,7 @@ type ChangePasswordRequest struct { OldPassword string `json:"old_password" binding:"required"` NewPassword string `json:"new_password" binding:"required"` } + +type ResetPasswordRequest struct { + NewPassword string `json:"new_password" binding:"required"` +} diff --git a/backend/internal/modules/adminmgr/handler.go b/backend/internal/modules/adminmgr/handler.go index 1ca8b29..8499952 100644 --- a/backend/internal/modules/adminmgr/handler.go +++ b/backend/internal/modules/adminmgr/handler.go @@ -106,8 +106,13 @@ func (h *Handler) AssignRoles(c *gin.Context) { } func (h *Handler) ChangePassword(c *gin.Context) { - id, ok := parseID(c) + h.ChangeOwnPassword(c) +} + +func (h *Handler) ChangeOwnPassword(c *gin.Context) { + adminID, ok := currentAdminID(c) if !ok { + response.Unauthorized(c, "缺少管理员上下文") return } var req ChangePasswordRequest @@ -115,13 +120,39 @@ func (h *Handler) ChangePassword(c *gin.Context) { response.BadRequest(c, "密码不能为空") return } - if err := h.service.ChangePassword(c.Request.Context(), id, req); err != nil { + if err := h.service.ChangeOwnPassword(c.Request.Context(), adminID, req); err != nil { writeError(c, err) return } response.OK(c, gin.H{"updated": true}) } +func (h *Handler) ResetPassword(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + var req ResetPasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "密码不能为空") + return + } + if err := h.service.ResetPassword(c.Request.Context(), id, req); err != nil { + writeError(c, err) + return + } + response.OK(c, gin.H{"updated": true}) +} + +func currentAdminID(c *gin.Context) (uint64, bool) { + value, ok := c.Get(middleware.ContextAdminID) + if !ok { + return 0, false + } + adminID, ok := value.(uint64) + return adminID, ok +} + func parseID(c *gin.Context) (uint64, bool) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil || id == 0 { @@ -141,6 +172,8 @@ func writeError(c *gin.Context, err error) { response.BadRequest(c, "不能删除最后一个超级管理员") case errors.Is(err, ErrWrongPassword): response.BadRequest(c, "原密码错误") + case errors.Is(err, ErrWeakPassword): + response.BadRequest(c, "密码至少 8 位且需包含字母和数字") case IsNotFound(err): response.Error(c, http.StatusNotFound, "not_found", "管理员不存在") default: diff --git a/backend/internal/modules/adminmgr/repository.go b/backend/internal/modules/adminmgr/repository.go index a71ca93..a3ff463 100644 --- a/backend/internal/modules/adminmgr/repository.go +++ b/backend/internal/modules/adminmgr/repository.go @@ -76,10 +76,12 @@ func (r *Repository) Create(ctx context.Context, req CreateAdminRequest) (*Admin return nil, err } admin := model.AdminUser{ - Username: req.Username, - PasswordHash: string(hash), - Nickname: req.Nickname, - Status: "active", + Username: req.Username, + PasswordHash: string(hash), + Nickname: req.Nickname, + Status: "active", + TokenVersion: 1, + PasswordMustChange: true, } if err := r.db.WithContext(ctx).Create(&admin).Error; err != nil { return nil, err @@ -102,6 +104,12 @@ func (r *Repository) Update(ctx context.Context, id uint64, req UpdateAdminReque if err := db.Save(&admin).Error; err != nil { return nil, err } + if req.Status != "" { + if err := r.bumpTokenVersion(db, id); err != nil { + return nil, err + } + r.invalidatePermCache(ctx, id) + } return r.FindByID(ctx, id) } @@ -150,7 +158,7 @@ func (r *Repository) AssignRoles(ctx context.Context, adminID uint64, roleIDs [] return err } } - return nil + return r.bumpTokenVersion(tx, adminID) }) if err != nil { return err @@ -159,7 +167,7 @@ func (r *Repository) AssignRoles(ctx context.Context, adminID uint64, roleIDs [] return nil } -func (r *Repository) ChangePassword(ctx context.Context, id uint64, oldPwd, newPwd string) error { +func (r *Repository) ChangeOwnPassword(ctx context.Context, id uint64, oldPwd, newPwd string) error { var admin model.AdminUser if err := r.db.WithContext(ctx).First(&admin, id).Error; err != nil { return err @@ -171,7 +179,35 @@ func (r *Repository) ChangePassword(ctx context.Context, id uint64, oldPwd, newP if err != nil { return err } - return r.db.WithContext(ctx).Model(&admin).Update("password_hash", string(hash)).Error + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&admin).Updates(map[string]any{ + "password_hash": string(hash), + "password_must_change": false, + }).Error; err != nil { + return err + } + return r.bumpTokenVersion(tx, id) + }) +} + +func (r *Repository) ResetPassword(ctx context.Context, id uint64, newPwd string) error { + var admin model.AdminUser + if err := r.db.WithContext(ctx).First(&admin, id).Error; err != nil { + return err + } + hash, err := bcrypt.GenerateFromPassword([]byte(newPwd), bcrypt.DefaultCost) + if err != nil { + return err + } + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&admin).Updates(map[string]any{ + "password_hash": string(hash), + "password_must_change": true, + }).Error; err != nil { + return err + } + return r.bumpTokenVersion(tx, id) + }) } func (r *Repository) GetPermissionCodes(ctx context.Context, adminID uint64) ([]string, error) { @@ -223,6 +259,12 @@ func (r *Repository) invalidatePermCache(ctx context.Context, adminID uint64) { r.redis.Del(ctx, permCacheKey(adminID)) } +func (r *Repository) bumpTokenVersion(db *gorm.DB, adminID uint64) error { + return db.Model(&model.AdminUser{}). + Where("id = ?", adminID). + UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error +} + func permCacheKey(adminID uint64) string { return fmt.Sprintf("admin:perms:%d", adminID) } diff --git a/backend/internal/modules/adminmgr/service.go b/backend/internal/modules/adminmgr/service.go index 88c0760..45d3d2f 100644 --- a/backend/internal/modules/adminmgr/service.go +++ b/backend/internal/modules/adminmgr/service.go @@ -3,9 +3,13 @@ package adminmgr import ( "context" "errors" + "unicode" ) -var ErrDependencyUnavailable = errors.New("dependency unavailable") +var ( + ErrDependencyUnavailable = errors.New("dependency unavailable") + ErrWeakPassword = errors.New("weak password") +) type Service struct { repo *Repository @@ -39,8 +43,8 @@ func (s *Service) Create(ctx context.Context, req CreateAdminRequest) (*AdminUse if s.repo == nil { return nil, ErrDependencyUnavailable } - if len(req.Password) < 6 { - return nil, errors.New("password too short") + if !passwordStrongEnough(req.Password) { + return nil, ErrWeakPassword } return s.repo.Create(ctx, req) } @@ -66,12 +70,39 @@ func (s *Service) AssignRoles(ctx context.Context, adminID uint64, req AssignRol return s.repo.AssignRoles(ctx, adminID, req.RoleIDs) } -func (s *Service) ChangePassword(ctx context.Context, id uint64, req ChangePasswordRequest) error { +func (s *Service) ChangeOwnPassword(ctx context.Context, adminID uint64, req ChangePasswordRequest) error { if s.repo == nil { return ErrDependencyUnavailable } - if len(req.NewPassword) < 6 { - return errors.New("new password too short") + if !passwordStrongEnough(req.NewPassword) { + return ErrWeakPassword } - return s.repo.ChangePassword(ctx, id, req.OldPassword, req.NewPassword) + return s.repo.ChangeOwnPassword(ctx, adminID, req.OldPassword, req.NewPassword) +} + +func (s *Service) ResetPassword(ctx context.Context, id uint64, req ResetPasswordRequest) error { + if s.repo == nil { + return ErrDependencyUnavailable + } + if !passwordStrongEnough(req.NewPassword) { + return ErrWeakPassword + } + return s.repo.ResetPassword(ctx, id, req.NewPassword) +} + +func passwordStrongEnough(value string) bool { + if len([]rune(value)) < 8 { + return false + } + hasLetter := false + hasDigit := false + for _, r := range value { + if unicode.IsLetter(r) { + hasLetter = true + } + if unicode.IsDigit(r) { + hasDigit = true + } + } + return hasLetter && hasDigit } diff --git a/backend/internal/modules/adminrole/repository.go b/backend/internal/modules/adminrole/repository.go index 93c49e7..bd2db36 100644 --- a/backend/internal/modules/adminrole/repository.go +++ b/backend/internal/modules/adminrole/repository.go @@ -3,19 +3,22 @@ package adminrole import ( "context" "errors" + "fmt" "hfb_sys/backend/internal/model" + "github.com/redis/go-redis/v9" "gorm.io/gorm" "gorm.io/gorm/clause" ) type Repository struct { - db *gorm.DB + db *gorm.DB + redis *redis.Client } -func NewRepository(db *gorm.DB) *Repository { - return &Repository{db: db} +func NewRepository(db *gorm.DB, redis *redis.Client) *Repository { + return &Repository{db: db, redis: redis} } func (r *Repository) List(ctx context.Context) ([]RoleDTO, error) { @@ -108,15 +111,27 @@ func (r *Repository) Delete(ctx context.Context, id uint64) error { if role.Code == "super_admin" { return ErrProtectedRole } - return db.Transaction(func(tx *gorm.DB) error { + adminIDs, err := r.adminIDsByRole(ctx, id) + if err != nil { + return err + } + err = db.Transaction(func(tx *gorm.DB) error { if err := tx.Where("role_id = ?", id).Delete(&model.RolePermission{}).Error; err != nil { return err } if err := tx.Where("role_id = ?", id).Delete(&model.AdminUserRole{}).Error; err != nil { return err } - return tx.Delete(&model.Role{}, id).Error + if err := tx.Delete(&model.Role{}, id).Error; err != nil { + return err + } + return r.bumpAdminTokenVersions(tx, adminIDs) }) + if err != nil { + return err + } + r.invalidateAdminPermCaches(ctx, adminIDs) + return nil } func (r *Repository) AssignPermissions(ctx context.Context, roleID uint64, permIDs []uint64) error { @@ -125,7 +140,11 @@ func (r *Repository) AssignPermissions(ctx context.Context, roleID uint64, permI if err := db.First(&role, roleID).Error; err != nil { return err } - return db.Transaction(func(tx *gorm.DB) error { + adminIDs, err := r.adminIDsByRole(ctx, roleID) + if err != nil { + return err + } + err = db.Transaction(func(tx *gorm.DB) error { if role.Code == "super_admin" { var allPermIDs []uint64 if err := tx.Model(&model.Permission{}).Pluck("id", &allPermIDs).Error; err != nil { @@ -142,8 +161,13 @@ func (r *Repository) AssignPermissions(ctx context.Context, roleID uint64, permI return err } } - return nil + return r.bumpAdminTokenVersions(tx, adminIDs) }) + if err != nil { + return err + } + r.invalidateAdminPermCaches(ctx, adminIDs) + return nil } func (r *Repository) ListPermissions(ctx context.Context) ([]PermissionDTO, error) { @@ -186,6 +210,34 @@ func (r *Repository) getRolePermissions(ctx context.Context, roleID uint64) ([]P return result, nil } +func (r *Repository) adminIDsByRole(ctx context.Context, roleID uint64) ([]uint64, error) { + var ids []uint64 + err := r.db.WithContext(ctx).Model(&model.AdminUserRole{}). + Where("role_id = ?", roleID). + Pluck("admin_user_id", &ids).Error + return ids, err +} + +func (r *Repository) bumpAdminTokenVersions(tx *gorm.DB, adminIDs []uint64) error { + if len(adminIDs) == 0 { + return nil + } + return tx.Model(&model.AdminUser{}). + Where("id IN ?", adminIDs). + UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error +} + +func (r *Repository) invalidateAdminPermCaches(ctx context.Context, adminIDs []uint64) { + if r.redis == nil || len(adminIDs) == 0 { + return + } + keys := make([]string, 0, len(adminIDs)) + for _, id := range adminIDs { + keys = append(keys, fmt.Sprintf("admin:perms:%d", id)) + } + _ = r.redis.Del(ctx, keys...).Err() +} + var ErrProtectedRole = errors.New("protected role") func IsNotFound(err error) bool { diff --git a/backend/internal/modules/auth/jwt.go b/backend/internal/modules/auth/jwt.go index 0fe20bb..64f38f9 100644 --- a/backend/internal/modules/auth/jwt.go +++ b/backend/internal/modules/auth/jwt.go @@ -22,10 +22,11 @@ type JWTManager struct { } type Claims struct { - UserID uint64 `json:"uid"` - Phone string `json:"phone"` - TokenType string `json:"typ"` - SubjectType string `json:"sub_type"` + UserID uint64 `json:"uid"` + Phone string `json:"phone"` + TokenType string `json:"typ"` + SubjectType string `json:"sub_type"` + TokenVersion int64 `json:"ver,omitempty"` jwt.RegisteredClaims } @@ -50,11 +51,15 @@ func (m *JWTManager) GeneratePair(userID uint64, phone string) (TokenPair, error } func (m *JWTManager) GenerateSubjectPair(userID uint64, subject string, subjectType string) (TokenPair, error) { - accessToken, err := m.generate(userID, subject, subjectType, tokenTypeAccess, m.accessTTL) + return m.GenerateSubjectPairWithVersion(userID, subject, subjectType, 0) +} + +func (m *JWTManager) GenerateSubjectPairWithVersion(userID uint64, subject string, subjectType string, tokenVersion int64) (TokenPair, error) { + accessToken, err := m.generate(userID, subject, subjectType, tokenTypeAccess, tokenVersion, m.accessTTL) if err != nil { return TokenPair{}, err } - refreshToken, err := m.generate(userID, subject, subjectType, tokenTypeRefresh, m.refreshTTL) + refreshToken, err := m.generate(userID, subject, subjectType, tokenTypeRefresh, tokenVersion, m.refreshTTL) if err != nil { return TokenPair{}, err } @@ -90,13 +95,14 @@ func (m *JWTManager) ParseSubject(tokenText, expectedType string, expectedSubjec return claims, nil } -func (m *JWTManager) generate(userID uint64, subject string, subjectType string, tokenType string, ttl time.Duration) (string, error) { +func (m *JWTManager) generate(userID uint64, subject string, subjectType string, tokenType string, tokenVersion int64, ttl time.Duration) (string, error) { now := time.Now() claims := Claims{ - UserID: userID, - Phone: subject, - TokenType: tokenType, - SubjectType: subjectType, + UserID: userID, + Phone: subject, + TokenType: tokenType, + SubjectType: subjectType, + TokenVersion: tokenVersion, RegisteredClaims: jwt.RegisteredClaims{ Subject: subject, IssuedAt: jwt.NewNumericDate(now), diff --git a/backend/internal/modules/listing/handler.go b/backend/internal/modules/listing/handler.go index a9c44a1..5f6a467 100644 --- a/backend/internal/modules/listing/handler.go +++ b/backend/internal/modules/listing/handler.go @@ -5,12 +5,24 @@ import ( ) type Handler struct { - service *Service - storage *filemodule.Storage + service *Service + storage *filemodule.Storage + externalUploadSecret string + externalUploadAllowedIPs []string } const maxExternalUploadBodyBytes = 256 * 1024 -func NewHandler(service *Service, storage *filemodule.Storage) *Handler { - return &Handler{service: service, storage: storage} +type HandlerOptions struct { + ExternalUploadSecret string + ExternalUploadAllowedIPs []string +} + +func NewHandler(service *Service, storage *filemodule.Storage, opts ...HandlerOptions) *Handler { + handler := &Handler{service: service, storage: storage} + if len(opts) > 0 { + handler.externalUploadSecret = opts[0].ExternalUploadSecret + handler.externalUploadAllowedIPs = opts[0].ExternalUploadAllowedIPs + } + return handler } diff --git a/backend/internal/modules/listing/handler_external.go b/backend/internal/modules/listing/handler_external.go index b57561b..3a90e2b 100644 --- a/backend/internal/modules/listing/handler_external.go +++ b/backend/internal/modules/listing/handler_external.go @@ -1,9 +1,17 @@ package listing import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" "encoding/json" + "errors" "io" + "net" "net/http" + "strconv" + "strings" + "time" "hfb_sys/backend/pkg/response" @@ -17,6 +25,10 @@ func (h *Handler) ImportExternalUpload(c *gin.Context) { response.BadRequest(c, "上传内容过大或读取失败") return } + if err := h.verifyExternalUpload(c, raw); err != nil { + writeExternalUploadAuthError(c, err) + return + } var req ExternalUploadRequest if err := json.Unmarshal(raw, &req); err != nil { response.BadRequest(c, "上传 JSON 格式不正确") @@ -34,6 +46,82 @@ func (h *Handler) ImportExternalUpload(c *gin.Context) { response.Created(c, result) } +var ( + errExternalUploadSecretMissing = errors.New("external upload secret missing") + errExternalUploadForbiddenIP = errors.New("external upload forbidden ip") + errExternalUploadTimestamp = errors.New("external upload timestamp invalid") + errExternalUploadSignature = errors.New("external upload signature invalid") +) + +const externalUploadMaxClockSkew = 5 * time.Minute + +func (h *Handler) verifyExternalUpload(c *gin.Context, raw []byte) error { + secret := strings.TrimSpace(h.externalUploadSecret) + if secret == "" { + return errExternalUploadSecretMissing + } + if !externalUploadIPAllowed(c.ClientIP(), h.externalUploadAllowedIPs) { + return errExternalUploadForbiddenIP + } + timestamp := strings.TrimSpace(c.GetHeader("X-HFB-Timestamp")) + signature := strings.TrimSpace(c.GetHeader("X-HFB-Signature")) + if timestamp == "" || signature == "" { + return errExternalUploadSignature + } + ts, err := strconv.ParseInt(timestamp, 10, 64) + if err != nil { + return errExternalUploadTimestamp + } + requestTime := time.Unix(ts, 0) + if time.Since(requestTime) > externalUploadMaxClockSkew || time.Until(requestTime) > externalUploadMaxClockSkew { + return errExternalUploadTimestamp + } + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(timestamp)) + mac.Write([]byte(".")) + mac.Write(raw) + expected := hex.EncodeToString(mac.Sum(nil)) + if !hmac.Equal([]byte(strings.ToLower(signature)), []byte(expected)) { + return errExternalUploadSignature + } + return nil +} + +func externalUploadIPAllowed(clientIP string, allowed []string) bool { + if len(allowed) == 0 { + return true + } + parsedIP := net.ParseIP(clientIP) + for _, raw := range allowed { + item := strings.TrimSpace(raw) + if item == "" { + continue + } + if parsedIP != nil { + if _, network, err := net.ParseCIDR(item); err == nil && network.Contains(parsedIP) { + return true + } + } + if item == clientIP { + return true + } + } + return false +} + +func writeExternalUploadAuthError(c *gin.Context, err error) { + switch err { + case errExternalUploadSecretMissing: + response.ServiceUnavailable(c, "开放导入签名密钥未配置") + case errExternalUploadForbiddenIP: + response.Error(c, http.StatusForbidden, "forbidden", "来源 IP 不允许访问") + case errExternalUploadTimestamp: + response.Error(c, http.StatusUnauthorized, "invalid_timestamp", "请求时间戳无效") + default: + response.Error(c, http.StatusUnauthorized, "invalid_signature", "请求签名无效") + } +} + func (h *Handler) DefaultUploadScreenshot(c *gin.Context) { c.Header("Content-Type", "image/svg+xml; charset=utf-8") c.Header("Cache-Control", "public, max-age=86400") diff --git a/backend/internal/modules/listing/handler_external_test.go b/backend/internal/modules/listing/handler_external_test.go new file mode 100644 index 0000000..37645f4 --- /dev/null +++ b/backend/internal/modules/listing/handler_external_test.go @@ -0,0 +1,96 @@ +package listing + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "net/http/httptest" + "strconv" + "testing" + "time" + + "github.com/gin-gonic/gin" +) + +func TestVerifyExternalUploadSignature(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"data":{"loginMethod":"QQ账号密码"}}`) + secret := "test-upload-secret" + timestamp := time.Now().Unix() + + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + req := httptest.NewRequest("POST", "/api/open/listing-uploads", nil) + req.Header.Set("X-HFB-Timestamp", strconvFormatInt(timestamp)) + req.Header.Set("X-HFB-Signature", signExternalUploadForTest(secret, timestamp, body)) + c.Request = req + + handler := &Handler{externalUploadSecret: secret} + if err := handler.verifyExternalUpload(c, body); err != nil { + t.Fatalf("verifyExternalUpload() error = %v", err) + } +} + +func TestVerifyExternalUploadRejectsBadSignature(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + req := httptest.NewRequest("POST", "/api/open/listing-uploads", nil) + req.Header.Set("X-HFB-Timestamp", strconvFormatInt(time.Now().Unix())) + req.Header.Set("X-HFB-Signature", "bad-signature") + c.Request = req + + handler := &Handler{externalUploadSecret: "test-upload-secret"} + if err := handler.verifyExternalUpload(c, []byte(`{}`)); err != errExternalUploadSignature { + t.Fatalf("verifyExternalUpload() error = %v, want errExternalUploadSignature", err) + } +} + +func TestVerifyExternalUploadRejectsStaleTimestamp(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{}`) + secret := "test-upload-secret" + timestamp := time.Now().Add(-10 * time.Minute).Unix() + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + req := httptest.NewRequest("POST", "/api/open/listing-uploads", nil) + req.Header.Set("X-HFB-Timestamp", strconvFormatInt(timestamp)) + req.Header.Set("X-HFB-Signature", signExternalUploadForTest(secret, timestamp, body)) + c.Request = req + + handler := &Handler{externalUploadSecret: secret} + if err := handler.verifyExternalUpload(c, body); err != errExternalUploadTimestamp { + t.Fatalf("verifyExternalUpload() error = %v, want errExternalUploadTimestamp", err) + } +} + +func TestExternalUploadIPAllowed(t *testing.T) { + tests := []struct { + name string + ip string + allowed []string + want bool + }{ + {name: "empty whitelist", ip: "203.0.113.10", allowed: nil, want: true}, + {name: "exact match", ip: "203.0.113.10", allowed: []string{"203.0.113.10"}, want: true}, + {name: "cidr match", ip: "10.1.2.3", allowed: []string{"10.0.0.0/8"}, want: true}, + {name: "blocked", ip: "203.0.113.10", allowed: []string{"198.51.100.0/24"}, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := externalUploadIPAllowed(tt.ip, tt.allowed); got != tt.want { + t.Fatalf("externalUploadIPAllowed() = %v, want %v", got, tt.want) + } + }) + } +} + +func signExternalUploadForTest(secret string, timestamp int64, body []byte) string { + ts := strconvFormatInt(timestamp) + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(ts)) + mac.Write([]byte(".")) + mac.Write(body) + return hex.EncodeToString(mac.Sum(nil)) +} + +func strconvFormatInt(value int64) string { + return strconv.FormatInt(value, 10) +} diff --git a/backend/internal/modules/paymentconfig/handler.go b/backend/internal/modules/paymentconfig/handler.go index 9ba851f..657c0fe 100644 --- a/backend/internal/modules/paymentconfig/handler.go +++ b/backend/internal/modules/paymentconfig/handler.go @@ -61,8 +61,17 @@ func (h *Handler) Get(c *gin.Context) { } includeSecret := c.Query("include_secret") == "true" + var actorID uint64 + if includeSecret { + var ok bool + actorID, ok = currentAdminID(c) + if !ok { + response.Unauthorized(c, "未授权") + return + } + } - config, err := h.service.Get(c.Request.Context(), id, includeSecret) + config, err := h.service.Get(c.Request.Context(), id, includeSecret, actorID, auditMeta(c)) if err == ErrConfigNotFound { response.NotFound(c, "配置不存在") return diff --git a/backend/internal/modules/paymentconfig/query.go b/backend/internal/modules/paymentconfig/query.go index 3bbebe9..d7bb6c5 100644 --- a/backend/internal/modules/paymentconfig/query.go +++ b/backend/internal/modules/paymentconfig/query.go @@ -65,7 +65,7 @@ func (r *Repository) List(ctx context.Context, query ListQuery) ([]ConfigDTO, in // FindByID 根据 ID 查询配置 // FindByID 根据 ID 查询配置 -func (r *Repository) FindByID(ctx context.Context, id uint64, includeSecret bool) (*ConfigDTO, error) { +func (r *Repository) FindByID(ctx context.Context, id uint64, includeSecret bool, actorID uint64, meta AuditMeta) (*ConfigDTO, error) { var item model.PaymentMerchantConfig if err := r.db.WithContext(ctx).Where("id = ?", id).First(&item).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -77,6 +77,15 @@ func (r *Repository) FindByID(ctx context.Context, id uint64, includeSecret bool if err != nil { return nil, err } + if includeSecret { + if err := appendAuditLog(r.db.WithContext(ctx), actorID, "payment_config.view_secret", item.ID, meta, map[string]any{ + "name": item.Name, + "provider": item.Provider, + "merchant_id": item.MerchantID, + }); err != nil { + return nil, err + } + } return &dto, nil } diff --git a/backend/internal/modules/paymentconfig/service.go b/backend/internal/modules/paymentconfig/service.go index 0c7485a..37447d2 100644 --- a/backend/internal/modules/paymentconfig/service.go +++ b/backend/internal/modules/paymentconfig/service.go @@ -35,8 +35,8 @@ func (s *Service) List(ctx context.Context, query ListQuery) (*ListResponse, err } // Get 获取单个配置 -func (s *Service) Get(ctx context.Context, id uint64, includeSecret bool) (*ConfigDTO, error) { - return s.repo.FindByID(ctx, id, includeSecret) +func (s *Service) Get(ctx context.Context, id uint64, includeSecret bool, actorID uint64, meta AuditMeta) (*ConfigDTO, error) { + return s.repo.FindByID(ctx, id, includeSecret, actorID, meta) } // ExportBackup 导出支付配置备份。 diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index f1ccb91..35aba6a 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -2,7 +2,6 @@ package router import ( "context" - "os" "strings" "hfb_sys/backend/internal/config" @@ -63,7 +62,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { engine.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) } if cfg.RateLimit.Enabled { - engine.Use(middleware.RateLimitPerMinute(cfg.RateLimit.RequestsPerMinute)) + engine.Use(middleware.RateLimitPerMinute(cfg.RateLimit.RequestsPerMinute, deps.Redis)) } jwtManager := auth.NewJWTManager(cfg.JWTSecret) @@ -166,8 +165,8 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { var paymentConfigService *paymentconfig.Service var paymentConfigHandler *paymentconfig.Handler if deps.DB != nil { - // 从环境变量获取加密密钥,如果没有则使用 MockEncryptor - encryptionKey := os.Getenv("PAYMENT_CONFIG_ENCRYPTION_KEY") + // 生产环境必须配置有效加密密钥;开发/测试缺失时才降级 MockEncryptor。 + encryptionKey := cfg.PaymentConfigEncryptionKey var encryptor paymentconfig.Encryptor if encryptionKey != "" { if aesEncryptor, err := paymentconfig.NewAESEncryptor(encryptionKey); err == nil { @@ -175,6 +174,9 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { } } if encryptor == nil { + if cfg.AppEnv == "production" { + logger.Fatal("PAYMENT_CONFIG_ENCRYPTION_KEY not set or invalid") + } encryptor = &paymentconfig.MockEncryptor{} logger.Warn("PAYMENT_CONFIG_ENCRYPTION_KEY not set or invalid, using MockEncryptor") } @@ -225,7 +227,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { systemConfigHandler := systemconfig.NewHandler(systemConfigService) var adminRoleRepo *adminrole.Repository if deps.DB != nil { - adminRoleRepo = adminrole.NewRepository(deps.DB) + adminRoleRepo = adminrole.NewRepository(deps.DB, deps.Redis) } adminRoleService := adminrole.NewService(adminRoleRepo) adminRoleHandler := adminrole.NewHandler(adminRoleService) @@ -246,7 +248,10 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { fileService := filemodule.NewService(fileStorage) fileHandler := filemodule.NewHandler(fileService, fileStorage) listingService := listing.NewService(listingRepo, systemConfigRepo) - listingHandler := listing.NewHandler(listingService, fileStorage) + listingHandler := listing.NewHandler(listingService, fileStorage, listing.HandlerOptions{ + ExternalUploadSecret: cfg.ExternalUploadSecret, + ExternalUploadAllowedIPs: cfg.ExternalUploadAllowedIPs, + }) var announcementRepo *announcement.Repository if deps.DB != nil { announcementRepo = announcement.NewRepository(deps.DB) @@ -254,7 +259,20 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { announcementService := announcement.NewService(announcementRepo) announcementHandler := announcement.NewHandler(announcementService) requireAuth := middleware.Auth(jwtManager) - requireAdmin := middleware.AdminAuth(jwtManager) + var validateAdminToken middleware.AdminTokenValidatorFunc + if adminAuthRepo != nil { + validateAdminToken = func(ctx context.Context, adminID uint64, tokenVersion int64) (middleware.AdminTokenContext, error) { + admin, err := adminAuthRepo.FindActiveForToken(ctx, adminID, tokenVersion) + if err != nil { + return middleware.AdminTokenContext{}, err + } + return middleware.AdminTokenContext{ + Username: admin.Username, + PasswordMustChange: admin.PasswordMustChange, + }, nil + } + } + requireAdmin := middleware.AdminAuth(jwtManager, validateAdminToken) requireRealname := middleware.RequireRealname(userRepo) requirePerm := func(code string) gin.HandlerFunc { return middleware.RequirePermission(code, deps.Redis) @@ -410,7 +428,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminAuthRoutes.POST("/refresh", adminAuthHandler.Refresh) } - adminRoutes := api.Group("/admin", requireAdmin) + adminRoutes := api.Group("/admin", requireAdmin, middleware.RequireAdminPasswordChanged()) { adminRoutes.GET("/me", adminAuthHandler.Me) adminRoutes.PUT("/me/support-status", adminAuthHandler.UpdateSupportStatus) @@ -455,7 +473,9 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.GET("/payment-configs", requirePerm("payment_config:list"), paymentConfigHandler.List) adminRoutes.GET("/payment-configs/export", requirePerm("payment_config:view_secret"), paymentConfigHandler.ExportBackup) adminRoutes.POST("/payment-configs/import", requirePerm("payment_config:view_secret"), paymentConfigHandler.ImportBackup) - adminRoutes.GET("/payment-configs/:id", requirePerm("payment_config:list"), paymentConfigHandler.Get) + adminRoutes.GET("/payment-configs/:id", requirePerm("payment_config:list"), middleware.RequirePermissionIf("payment_config:view_secret", deps.Redis, func(c *gin.Context) bool { + return c.Query("include_secret") == "true" + }), paymentConfigHandler.Get) adminRoutes.POST("/payment-configs", requirePerm("payment_config:create"), paymentConfigHandler.Create) adminRoutes.PUT("/payment-configs/:id", requirePerm("payment_config:update"), paymentConfigHandler.Update) adminRoutes.DELETE("/payment-configs/:id", requirePerm("payment_config:delete"), paymentConfigHandler.Delete) @@ -498,7 +518,8 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.PUT("/admin-users/:id", requirePerm("admin_user:manage"), adminMgrHandler.Update) adminRoutes.DELETE("/admin-users/:id", requirePerm("admin_user:manage"), adminMgrHandler.Delete) adminRoutes.PUT("/admin-users/:id/roles", requirePerm("admin_user:manage"), adminMgrHandler.AssignRoles) - adminRoutes.PUT("/admin-users/:id/password", adminMgrHandler.ChangePassword) + adminRoutes.PUT("/admin-users/me/password", adminMgrHandler.ChangeOwnPassword) + adminRoutes.PUT("/admin-users/:id/password", requirePerm("admin_user:manage"), adminMgrHandler.ResetPassword) // 公告管理 adminRoutes.GET("/announcements", requirePerm("announcement:view"), announcementHandler.AdminList) diff --git a/backend/migrations/000001_init.sql b/backend/migrations/000001_init.sql index e2fc641..b1cd716 100644 --- a/backend/migrations/000001_init.sql +++ b/backend/migrations/000001_init.sql @@ -383,6 +383,8 @@ CREATE TABLE IF NOT EXISTS admin_users ( nickname VARCHAR(64) NOT NULL DEFAULT '' COMMENT '昵称', status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '状态: active正常, inactive停用', support_status VARCHAR(16) NOT NULL DEFAULT 'offline' COMMENT '客服状态: online在线, offline离线, busy忙碌', + token_version BIGINT NOT NULL DEFAULT 1 COMMENT '后台凭证版本,递增后旧 token 失效', + password_must_change TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否必须先修改密码', last_login_at DATETIME NULL COMMENT '最后登录时间', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, @@ -654,24 +656,8 @@ WHERE r.code = 'finance' AND p.code IN ( -- 管理员账号初始化 -- ------------------------------------------- --- 初始化管理员账号(密码: admin123456) -INSERT INTO admin_users (username, password_hash, nickname, status) VALUES -('admin', '$2a$10$vvDd.cYJCOBl9kr5jdRYXu9C/7GXcHmXsG17zJPHzPOZ0XGbqcWNS', '超级管理员', 'active'), -('kf1', '$2a$10$7cx3B4IWunLZO24o11YVGu4qY1J1naDdsTyvoCpcnOO.GV15qaf3C', '客服1', 'active'), -('kf2', '$2a$10$m6kLCXciFygpqIGUZ9LQtuEFAl40hb.S7zJmMiuC83WrU7OQM3TPC', '客服2', 'active') -ON DUPLICATE KEY UPDATE - nickname = VALUES(nickname), - status = VALUES(status); - --- admin 关联 super_admin 角色 -INSERT IGNORE INTO admin_user_roles (admin_user_id, role_id) -SELECT au.id, r.id FROM admin_users au, roles r -WHERE au.username = 'admin' AND r.code = 'super_admin'; - --- 客服关联 cs 角色 -INSERT IGNORE INTO admin_user_roles (admin_user_id, role_id) -SELECT au.id, r.id FROM admin_users au, roles r -WHERE au.username IN ('kf1', 'kf2') AND r.code = 'cs'; +-- 管理员账号不再由迁移创建,首个超级管理员由启动时的 +-- ADMIN_BOOTSTRAP_USERNAME / ADMIN_BOOTSTRAP_PASSWORD 一次性生成。 -- ------------------------------------------- -- 系统配置初始化 diff --git a/backend/migrations/000002_admin_security.sql b/backend/migrations/000002_admin_security.sql new file mode 100644 index 0000000..0d88d8b --- /dev/null +++ b/backend/migrations/000002_admin_security.sql @@ -0,0 +1,19 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE admin_users + ADD COLUMN IF NOT EXISTS token_version BIGINT NOT NULL DEFAULT 1 COMMENT '后台凭证版本,递增后旧 token 失效' + AFTER support_status; + +ALTER TABLE admin_users + ADD COLUMN IF NOT EXISTS password_must_change TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否必须先修改密码' + AFTER token_version; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE admin_users + DROP COLUMN IF EXISTS password_must_change; + +ALTER TABLE admin_users + DROP COLUMN IF EXISTS token_version; +-- +goose StatementEnd diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile index 274719f..2e7f2e8 100644 --- a/deploy/caddy/Caddyfile +++ b/deploy/caddy/Caddyfile @@ -17,6 +17,7 @@ X-Frame-Options "DENY" Referrer-Policy "strict-origin-when-cross-origin" Permissions-Policy "camera=(), microphone=(), geolocation=()" + Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'" } # 聊天 SSE 长连接:关闭缓冲,逐条推送。 diff --git a/frontend/src/features/admin/api/adminAuth.ts b/frontend/src/features/admin/api/adminAuth.ts index a22fef4..7269a39 100644 --- a/frontend/src/features/admin/api/adminAuth.ts +++ b/frontend/src/features/admin/api/adminAuth.ts @@ -2,7 +2,6 @@ import axios from 'axios' import { apiClient } from '@/shared/api/client' import type { ApiResponse } from '@/shared/types/types' -import { getRefreshToken, setAuthTokens } from '@/shared/utils/authStorage' import type { UserStatus } from '@/shared/types/status' export interface AdminRole { @@ -17,21 +16,19 @@ export interface AdminUser { nickname: string status: UserStatus support_status: 'online' | 'offline' | 'busy' + password_must_change: boolean roles: AdminRole[] permissions: string[] last_login_at?: string } -export interface AdminTokenPair { - access_token: string - refresh_token: string - token_type: string +export interface AdminRefreshData { + refreshed: boolean expires_in: number } export interface AdminLoginData { admin: AdminUser - tokens: AdminTokenPair } export interface AdminCaptcha { @@ -82,13 +79,10 @@ export async function updateSupportStatus(status: 'online' | 'offline' | 'busy') /** Manually refresh admin token (uses raw axios to avoid interceptor recursion) */ export async function refreshAdminSession() { - const refreshToken = getRefreshToken('admin') - if (!refreshToken) throw new Error('no refresh token') - const { data } = await axios.post>( + const { data } = await axios.post>( '/api/admin/auth/refresh', - { refresh_token: refreshToken }, - { timeout: 10000 } + {}, + { timeout: 10000, withCredentials: true } ) - setAuthTokens('admin', data.data) return data.data } diff --git a/frontend/src/features/admin/api/adminMgr.ts b/frontend/src/features/admin/api/adminMgr.ts index 12530a2..1a54167 100644 --- a/frontend/src/features/admin/api/adminMgr.ts +++ b/frontend/src/features/admin/api/adminMgr.ts @@ -36,6 +36,10 @@ export interface ChangePasswordRequest { new_password: string } +export interface ResetPasswordRequest { + new_password: string +} + export async function fetchAdminMgrUsers(page = 1, pageSize = 20) { const { data } = await apiClient.get>>( '/admin/admin-users', @@ -78,7 +82,15 @@ export async function assignAdminRoles(id: number, roleIds: number[]) { return data.data } -export async function changeAdminPassword(id: number, req: ChangePasswordRequest) { +export async function changeAdminPassword(req: ChangePasswordRequest) { + const { data } = await apiClient.put>( + '/admin/admin-users/me/password', + req + ) + return data.data +} + +export async function resetAdminPassword(id: number, req: ResetPasswordRequest) { const { data } = await apiClient.put>( `/admin/admin-users/${id}/password`, req diff --git a/frontend/src/features/admin/index.ts b/frontend/src/features/admin/index.ts index 102fc8a..edf0c00 100644 --- a/frontend/src/features/admin/index.ts +++ b/frontend/src/features/admin/index.ts @@ -19,7 +19,7 @@ export { fetchAdminMe, updateSupportStatus, type AdminUser, - type AdminTokenPair, + type AdminRefreshData, type AdminLoginData, type AdminCaptcha, } from './api/adminAuth' diff --git a/frontend/src/features/admin/views/AdminLoginView.vue b/frontend/src/features/admin/views/AdminLoginView.vue index 206a80e..78887d7 100644 --- a/frontend/src/features/admin/views/AdminLoginView.vue +++ b/frontend/src/features/admin/views/AdminLoginView.vue @@ -3,19 +3,20 @@ import { readError } from '@/shared/utils/error' import { ElMessage } from 'element-plus' import { Lock, User } from '@element-plus/icons-vue' import { onMounted, reactive, ref } from 'vue' -import { useRouter } from 'vue-router' +import { useRoute, useRouter } from 'vue-router' import { fetchAdminCaptcha, type AdminCaptcha } from '@/features/admin' import { useAdminSessionStore } from '@/stores/adminSession' const router = useRouter() +const route = useRoute() const adminSession = useAdminSessionStore() const loading = ref(false) const captchaLoading = ref(false) const captcha = ref(null) const form = reactive({ - username: 'admin', - password: 'admin123456', + username: '', + password: '', captchaCode: '', }) @@ -43,7 +44,8 @@ async function handleLogin() { form.captchaCode ) ElMessage.success('后台登录成功') - await router.push('/admin/dashboard') + const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '' + await router.push(redirect.startsWith('/admin') ? redirect : '/admin/dashboard') } catch (error) { ElMessage.error(readError(error, '后台登录失败')) await loadCaptcha() diff --git a/frontend/src/features/admin/views/AdminMgrUsersView.vue b/frontend/src/features/admin/views/AdminMgrUsersView.vue index 5ae9030..ddba506 100644 --- a/frontend/src/features/admin/views/AdminMgrUsersView.vue +++ b/frontend/src/features/admin/views/AdminMgrUsersView.vue @@ -6,7 +6,7 @@ import { ref } from 'vue' import { fetchAdminMgrUsers, deleteAdminMgrUser, - changeAdminPassword, + resetAdminPassword, type AdminMgrUser, } from '@/features/admin/api/adminMgr' import { useAdminPaginatedTable } from '@/features/admin/composables/useAdminPaginatedTable' @@ -22,7 +22,7 @@ const showRolesDialog = ref(false) const rolesAdmin = ref(null) const showPasswordDialog = ref(false) const passwordAdmin = ref(null) -const passwordForm = ref({ old_password: '', new_password: '' }) +const passwordForm = ref({ new_password: '' }) const passwordSubmitting = ref(false) const { @@ -53,7 +53,7 @@ function openRoles(row: AdminMgrUser) { function openPassword(row: AdminMgrUser) { passwordAdmin.value = row - passwordForm.value = { old_password: '', new_password: '' } + passwordForm.value = { new_password: '' } showPasswordDialog.value = true } @@ -78,17 +78,17 @@ async function handleDelete(row: AdminMgrUser) { async function handleChangePassword() { if (!passwordAdmin.value) return - if (!passwordForm.value.old_password || !passwordForm.value.new_password) { - ElMessage.warning('请填写完整') + if (!passwordForm.value.new_password) { + ElMessage.warning('请填写新密码') return } passwordSubmitting.value = true try { - await changeAdminPassword(passwordAdmin.value.id, passwordForm.value) - ElMessage.success('密码已修改') + await resetAdminPassword(passwordAdmin.value.id, passwordForm.value) + ElMessage.success('密码已重置,目标账号需要重新登录') showPasswordDialog.value = false } catch (error) { - ElMessage.error(readError(error, '修改失败')) + ElMessage.error(readError(error, '重置失败')) } finally { passwordSubmitting.value = false } @@ -169,35 +169,27 @@ const statusLabel: Record = { - +
- - -
diff --git a/frontend/src/layouts/AdminLayout.vue b/frontend/src/layouts/AdminLayout.vue index 962a8f7..fe9f650 100644 --- a/frontend/src/layouts/AdminLayout.vue +++ b/frontend/src/layouts/AdminLayout.vue @@ -27,17 +27,24 @@ import { } from '@element-plus/icons-vue' import { ElMessage, ElSubMenu } from 'element-plus' import type { Component } from 'vue' -import { computed, ref } from 'vue' +import { computed, reactive, ref } from 'vue' import { useRoute, useRouter } from 'vue-router' -import { logoutAdmin, updateSupportStatus } from '@/features/admin' +import { changeAdminPassword, logoutAdmin, updateSupportStatus } from '@/features/admin' import { useAdminSessionStore } from '@/stores/adminSession' +import { readError } from '@/shared/utils/error' const router = useRouter() const route = useRoute() const adminSession = useAdminSessionStore() const isCollapsed = ref(false) const updatingStatus = ref(false) +const passwordSubmitting = ref(false) +const passwordForm = reactive({ + old_password: '', + new_password: '', + confirm_password: '', +}) interface NavItem { label: string @@ -231,6 +238,39 @@ async function handleLogout() { ElMessage.success('已退出后台') await router.push('/admin/login') } + +function passwordStrongEnough(value: string) { + return value.length >= 8 && /[A-Za-z]/.test(value) && /\d/.test(value) +} + +async function handleForcedPasswordChange() { + if (!passwordForm.old_password || !passwordForm.new_password) { + ElMessage.error('请输入原密码和新密码') + return + } + if (!passwordStrongEnough(passwordForm.new_password)) { + ElMessage.error('新密码至少 8 位且需包含字母和数字') + return + } + if (passwordForm.new_password !== passwordForm.confirm_password) { + ElMessage.error('两次输入的新密码不一致') + return + } + passwordSubmitting.value = true + try { + await changeAdminPassword({ + old_password: passwordForm.old_password, + new_password: passwordForm.new_password, + }) + ElMessage.success('密码已修改,请重新登录') + adminSession.logout() + await router.replace('/admin/login') + } catch (error) { + ElMessage.error(readError(error, '密码修改失败')) + } finally { + passwordSubmitting.value = false + } +} @@ -572,4 +657,13 @@ async function handleLogout() { padding: 24px; overflow-y: auto; } + +:global(.force-password-dialog) { + max-width: calc(100vw - 32px); +} + +.force-password-form { + display: grid; + gap: 2px; +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 7401af9..851b8d7 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -77,19 +77,28 @@ router.beforeEach(async to => { const adminSession = useAdminSessionStore() adminSession.syncFromStorage() - if (to.path === '/admin/login' && adminSession.token) { - return '/admin/dashboard' + if (to.path === '/admin/login') { + if (!adminSession.hasSessionHint) return true + try { + await adminSession.loadMe() + return '/admin/dashboard' + } catch { + adminSession.logout() + return true + } } if (to.meta.requiresAdmin) { - if (!adminSession.token) { - return '/admin/login' - } - if (adminSession.permissions.length === 0) { - try { + try { + if ( + !adminSession.hasSessionHint || + adminSession.permissions.length === 0 || + adminSession.passwordMustChange + ) { await adminSession.loadMe() - } catch { - // 权限加载失败,仍然允许访问(降级为无权限状态) } + } catch { + adminSession.logout() + return { path: '/admin/login', query: { redirect: to.fullPath } } } } return true diff --git a/frontend/src/shared/api/client.ts b/frontend/src/shared/api/client.ts index 66c21b6..1002ce2 100644 --- a/frontend/src/shared/api/client.ts +++ b/frontend/src/shared/api/client.ts @@ -100,6 +100,7 @@ import type { ApiResponse } from '@/shared/types/types' export const apiClient = axios.create({ baseURL: '/api', timeout: 30000, // 增加到 30 秒,避免大文件上传超时 + withCredentials: true, headers: { 'Content-Type': 'application/json', }, @@ -149,11 +150,18 @@ function rejectPendingRequests(scope: AuthScope, error: unknown) { } export async function refreshAccessToken(scope: AuthScope): Promise { + if (scope === 'admin') { + await axios.post('/api/admin/auth/refresh', {}, { timeout: 10000, withCredentials: true }) + return '' + } const refreshToken = getRefreshToken(scope) if (!refreshToken) throw new Error('no refresh token') - const endpoint = scope === 'admin' ? '/api/admin/auth/refresh' : '/api/auth/refresh' - const { data } = await axios.post(endpoint, { refresh_token: refreshToken }, { timeout: 10000 }) + const { data } = await axios.post( + '/api/auth/refresh', + { refresh_token: refreshToken }, + { timeout: 10000 } + ) const tokens = { access_token: data.data.access_token, refresh_token: data.data.refresh_token, @@ -239,7 +247,9 @@ apiClient.interceptors.response.use( state.pendingRequests.push({ resolve, reject }) }).then(newToken => { originalRequest._retry = true - originalRequest.headers.Authorization = `Bearer ${newToken}` + if (newToken) { + originalRequest.headers.Authorization = `Bearer ${newToken}` + } return apiClient(originalRequest) }) } @@ -249,7 +259,9 @@ apiClient.interceptors.response.use( const newToken = await refreshAccessToken(scope) resolvePendingRequests(scope, newToken) originalRequest._retry = true - originalRequest.headers.Authorization = `Bearer ${newToken}` + if (newToken) { + originalRequest.headers.Authorization = `Bearer ${newToken}` + } return apiClient(originalRequest) } catch (refreshError) { rejectPendingRequests(scope, refreshError) diff --git a/frontend/src/shared/utils/authStorage.ts b/frontend/src/shared/utils/authStorage.ts index f63a0d0..f7e92c6 100644 --- a/frontend/src/shared/utils/authStorage.ts +++ b/frontend/src/shared/utils/authStorage.ts @@ -14,7 +14,7 @@ const userKeys = { const adminKeys = { accessToken: 'admin_access_token', refreshToken: 'admin_refresh_token', - profile: ['admin_id', 'admin_username'], + profile: ['admin_id', 'admin_username', 'admin_support_status', 'admin_password_must_change'], } function keysFor(scope: AuthScope) { @@ -22,15 +22,23 @@ function keysFor(scope: AuthScope) { } export function getAccessToken(scope: AuthScope) { + if (scope === 'admin') return '' return localStorage.getItem(keysFor(scope).accessToken) || '' } export function getRefreshToken(scope: AuthScope) { + if (scope === 'admin') return '' return localStorage.getItem(keysFor(scope).refreshToken) || '' } export function setAuthTokens(scope: AuthScope, tokens: AuthTokenPair) { const keys = keysFor(scope) + if (scope === 'admin') { + localStorage.removeItem(keys.accessToken) + localStorage.removeItem(keys.refreshToken) + notifyAuthStorageChanged(scope) + return + } localStorage.setItem(keys.accessToken, tokens.access_token) localStorage.setItem(keys.refreshToken, tokens.refresh_token) notifyAuthStorageChanged(scope) diff --git a/frontend/src/stores/adminSession.ts b/frontend/src/stores/adminSession.ts index 43ca537..3b4455e 100644 --- a/frontend/src/stores/adminSession.ts +++ b/frontend/src/stores/adminSession.ts @@ -6,17 +6,12 @@ import { type AdminRole, type AdminUser, } from '@/features/admin/api/adminAuth' -import { - clearAuthStorage, - getAccessToken, - getRefreshToken, - setAuthTokens, -} from '@/shared/utils/authStorage' +import { clearAuthStorage } from '@/shared/utils/authStorage' export const useAdminSessionStore = defineStore('adminSession', { state: () => ({ - token: getAccessToken('admin'), - refreshToken: getRefreshToken('admin'), + token: '', + refreshToken: '', adminId: Number(localStorage.getItem('admin_id') || 0), username: localStorage.getItem('admin_username') || '', nickname: '', @@ -24,10 +19,12 @@ export const useAdminSessionStore = defineStore('adminSession', { | 'online' | 'offline' | 'busy', + passwordMustChange: localStorage.getItem('admin_password_must_change') === 'true', roles: [] as AdminRole[], permissions: [] as string[], }), getters: { + hasSessionHint: state => state.adminId > 0 || state.username !== '', hasPermission: state => { return (code: string) => state.permissions.includes(code) || state.permissions.includes('*') }, @@ -41,7 +38,7 @@ export const useAdminSessionStore = defineStore('adminSession', { actions: { async login(username: string, password: string, captchaId: string, captchaCode: string) { const result = await loginAdmin(username, password, captchaId, captchaCode) - this.applySession(result.admin, result.tokens.access_token, result.tokens.refresh_token) + this.applySession(result.admin) return result }, async loadMe() { @@ -56,28 +53,27 @@ export const useAdminSessionStore = defineStore('adminSession', { this.username = '' this.nickname = '' this.supportStatus = 'offline' + this.passwordMustChange = false this.roles = [] this.permissions = [] clearAuthStorage('admin') localStorage.removeItem('admin_support_status') + localStorage.removeItem('admin_password_must_change') }, syncFromStorage() { - this.token = getAccessToken('admin') - this.refreshToken = getRefreshToken('admin') + this.token = '' + this.refreshToken = '' this.adminId = Number(localStorage.getItem('admin_id') || 0) this.username = localStorage.getItem('admin_username') || '' this.supportStatus = (localStorage.getItem('admin_support_status') || 'offline') as | 'online' | 'offline' | 'busy' + this.passwordMustChange = localStorage.getItem('admin_password_must_change') === 'true' }, - applySession(admin: AdminUser, accessToken: string, refreshToken: string) { - this.token = accessToken - this.refreshToken = refreshToken - setAuthTokens('admin', { - access_token: accessToken, - refresh_token: refreshToken, - }) + applySession(admin: AdminUser) { + this.token = '' + this.refreshToken = '' this.applyAdmin(admin) }, applyAdmin(admin: AdminUser) { @@ -85,11 +81,13 @@ export const useAdminSessionStore = defineStore('adminSession', { this.username = admin.username this.nickname = admin.nickname this.supportStatus = admin.support_status || 'offline' + this.passwordMustChange = admin.password_must_change this.roles = admin.roles || [] this.permissions = admin.permissions || [] localStorage.setItem('admin_id', String(admin.id)) localStorage.setItem('admin_username', admin.username) localStorage.setItem('admin_support_status', admin.support_status || 'offline') + localStorage.setItem('admin_password_must_change', String(admin.password_must_change)) }, setSupportStatus(status: 'online' | 'offline' | 'busy') { this.supportStatus = status diff --git a/scripts/dev.sh b/scripts/dev.sh index 21a68df..9cdfc35 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -588,7 +588,7 @@ main() { log_success "开发环境已启动" if [[ "${NO_FRONTEND}" == "0" ]]; then log "前台:http://localhost:5173" - log "后台:http://localhost:5173/admin/login(admin / admin123456)" + log "后台:http://localhost:5173/admin/login" fi log "按 Ctrl+C 停止前后端;Docker 依赖会保留运行" diff --git a/scripts/load_stress.go b/scripts/load_stress.go index 9d96a39..82f3092 100644 --- a/scripts/load_stress.go +++ b/scripts/load_stress.go @@ -8,6 +8,7 @@ import ( "io" "math/rand" "net/http" + "os" "sort" "sync" "sync/atomic" @@ -266,12 +267,19 @@ func initAdminToken(baseURL string) *AdminToken { return nil } - // 登录(使用默认管理员账号,验证码留空mock会通过) + adminUsername := os.Getenv("LOAD_STRESS_ADMIN_USERNAME") + adminPassword := os.Getenv("LOAD_STRESS_ADMIN_PASSWORD") + if adminUsername == "" || adminPassword == "" { + fmt.Println(" 跳过后台登录:请设置 LOAD_STRESS_ADMIN_USERNAME 和 LOAD_STRESS_ADMIN_PASSWORD") + return nil + } + + // 登录后台账号 loginPayload := map[string]string{ - "username": "admin", - "password": "admin123456", - "captcha_id": captchaResp.Data.CaptchaID, - "captcha": "1234", // mock模式会自动通过 + "username": adminUsername, + "password": adminPassword, + "captcha_id": captchaResp.Data.CaptchaID, + "captcha_code": "1234", } loginBody, _ := json.Marshal(loginPayload) @@ -434,11 +442,11 @@ func runGradualTest(config TestConfig, authPool *AuthPool, adminToken *AdminToke Duration time.Duration Concurrency int }{ - {30 * time.Second, 10}, // 预热 - {60 * time.Second, config.Concurrency / 4}, // 25%负载 - {60 * time.Second, config.Concurrency / 2}, // 50%负载 - {60 * time.Second, config.Concurrency}, // 100%负载 - {30 * time.Second, config.Concurrency * 2}, // 峰值负载 + {30 * time.Second, 10}, // 预热 + {60 * time.Second, config.Concurrency / 4}, // 25%负载 + {60 * time.Second, config.Concurrency / 2}, // 50%负载 + {60 * time.Second, config.Concurrency}, // 100%负载 + {30 * time.Second, config.Concurrency * 2}, // 峰值负载 } result := &TestResult{