加固后台管理安全

This commit is contained in:
yml2213
2026-06-11 07:23:00 +08:00
parent 5255b21141
commit 88b1df64e7
41 changed files with 1276 additions and 293 deletions
+12
View File
@@ -14,6 +14,12 @@ REDIS_DB=0
JWT_SECRET=change-me JWT_SECRET=change-me
# 首次启动且管理员表为空时,按以下变量创建首个超级管理员。
# 本地开发可自行填写,生产环境请使用随机强密码,创建后建议从 .env 移除。
ADMIN_BOOTSTRAP_USERNAME=
ADMIN_BOOTSTRAP_PASSWORD=
ADMIN_BOOTSTRAP_NICKNAME=超级管理员
# API 限流:默认开启,每个 IP 每分钟 300 次。 # API 限流:默认开启,每个 IP 每分钟 300 次。
RATE_LIMIT_ENABLED=true RATE_LIMIT_ENABLED=true
RATE_LIMIT_REQUESTS_PER_MINUTE=300 RATE_LIMIT_REQUESTS_PER_MINUTE=300
@@ -49,3 +55,9 @@ REALNAME_CLOUDMARKET_APPCODE=
# 用于加密存储支付商户配置中的敏感信息(sign_key、notify_key # 用于加密存储支付商户配置中的敏感信息(sign_key、notify_key
# 生成方式:openssl rand -hex 16 # 生成方式:openssl rand -hex 16
PAYMENT_CONFIG_ENCRYPTION_KEY= 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=
+11
View File
@@ -19,6 +19,12 @@ REDIS_DB=0
JWT_SECRET=change-to-a-long-random-secret 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 次。 # API 限流:默认开启,每个 IP 每分钟 300 次。
RATE_LIMIT_ENABLED=true RATE_LIMIT_ENABLED=true
RATE_LIMIT_REQUESTS_PER_MINUTE=300 RATE_LIMIT_REQUESTS_PER_MINUTE=300
@@ -55,3 +61,8 @@ REALNAME_CLOUDMARKET_APPCODE=
# 生成方式:openssl rand -hex 16 # 生成方式:openssl rand -hex 16
# 警告:此密钥一旦设置不要更改,否则已有配置无法解密 # 警告:此密钥一旦设置不要更改,否则已有配置无法解密
PAYMENT_CONFIG_ENCRYPTION_KEY=change-to-32-byte-encryption-key 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=
+12
View File
@@ -12,6 +12,7 @@ import (
"hfb_sys/backend/internal/database" "hfb_sys/backend/internal/database"
"hfb_sys/backend/internal/jobs/ordertimeout" "hfb_sys/backend/internal/jobs/ordertimeout"
"hfb_sys/backend/internal/logging" "hfb_sys/backend/internal/logging"
"hfb_sys/backend/internal/modules/adminauth"
"hfb_sys/backend/internal/router" "hfb_sys/backend/internal/router"
"go.uber.org/zap" "go.uber.org/zap"
@@ -45,6 +46,9 @@ func main() {
defer func() { defer func() {
_ = logger.Sync() _ = logger.Sync()
}() }()
if err := cfg.ValidateProductionSecurity(); err != nil {
logger.Fatal("production security config invalid", zap.Error(err))
}
var deps router.Dependencies var deps router.Dependencies
db, err := database.OpenMySQL(cfg.MySQLDSN, cfg.Log.Level) 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)) logger.Warn("mysql unavailable; database-backed APIs will return 503", zap.Error(err))
} else { } else {
deps.DB = db 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{ redisClient, err := database.OpenRedis(context.Background(), database.RedisConfig{
Addr: cfg.RedisAddr, Addr: cfg.RedisAddr,
+74 -19
View File
@@ -1,23 +1,31 @@
package config package config
import ( import (
"errors"
"os" "os"
"strconv" "strconv"
"strings"
) )
type Config struct { type Config struct {
AppEnv string AppEnv string
AppAddr string AppAddr string
MySQLDSN string MySQLDSN string
RedisAddr string RedisAddr string
RedisPassword string RedisPassword string
RedisDB int RedisDB int
JWTSecret string JWTSecret string
Storage StorageConfig PaymentConfigEncryptionKey string
SMS SMSConfig ExternalUploadSecret string
Realname RealnameConfig ExternalUploadAllowedIPs []string
Log LogConfig BootstrapAdminUsername string
RateLimit RateLimitConfig BootstrapAdminPassword string
BootstrapAdminNickname string
Storage StorageConfig
SMS SMSConfig
Realname RealnameConfig
Log LogConfig
RateLimit RateLimitConfig
} }
type StorageConfig struct { type StorageConfig struct {
@@ -56,13 +64,19 @@ type RateLimitConfig struct {
func Load() Config { func Load() Config {
return Config{ return Config{
AppEnv: getEnv("APP_ENV", "development"), AppEnv: getEnv("APP_ENV", "development"),
AppAddr: getEnv("APP_ADDR", ":8080"), 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"), 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"), RedisAddr: getEnv("REDIS_ADDR", "127.0.0.1:6379"),
RedisPassword: getEnv("REDIS_PASSWORD", ""), RedisPassword: getEnv("REDIS_PASSWORD", ""),
RedisDB: getEnvInt("REDIS_DB", 0), RedisDB: getEnvInt("REDIS_DB", 0),
JWTSecret: getEnv("JWT_SECRET", "change-me"), 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{ Storage: StorageConfig{
Endpoint: getEnv("STORAGE_ENDPOINT", "http://localhost:9000"), Endpoint: getEnv("STORAGE_ENDPOINT", "http://localhost:9000"),
Bucket: getEnv("STORAGE_BUCKET", "hfb-sys"), 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 { func getEnv(key, fallback string) string {
value := os.Getenv(key) value := os.Getenv(key)
if value == "" { if value == "" {
@@ -126,3 +160,24 @@ func getEnvBool(key string, fallback bool) bool {
} }
return parsed 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-")
}
+66 -8
View File
@@ -1,6 +1,8 @@
package middleware package middleware
import ( import (
"context"
"net/http"
"strings" "strings"
"hfb_sys/backend/internal/modules/auth" "hfb_sys/backend/internal/modules/auth"
@@ -10,18 +12,34 @@ import (
) )
const ( const (
ContextUserID = "user_id" ContextUserID = "user_id"
ContextPhone = "phone" ContextPhone = "phone"
ContextAdminID = "admin_id" ContextAdminID = "admin_id"
ContextUsername = "username" 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") header := c.GetHeader("Authorization")
tokenText := strings.TrimSpace(strings.TrimPrefix(header, "Bearer ")) tokenText := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
if tokenText != "" && tokenText != header { if tokenText != "" && tokenText != header {
return tokenText return tokenText
} }
return ""
}
func extractToken(c *gin.Context) string {
if tokenText := extractBearerToken(c); tokenText != "" {
return tokenText
}
return c.Query("token") 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) { 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 == "" { if tokenText == "" {
response.Unauthorized(c, "缺少后台访问令牌") response.Unauthorized(c, "缺少后台访问令牌")
c.Abort() c.Abort()
@@ -62,9 +85,44 @@ func AdminAuth(jwtManager *auth.JWTManager) gin.HandlerFunc {
c.Abort() c.Abort()
return 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(ContextAdminID, claims.UserID)
c.Set(ContextUsername, claims.Phone) c.Set(ContextUsername, username)
c.Set(ContextPasswordMustChange, passwordMustChange)
c.Next() 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()
}
}
+46 -28
View File
@@ -16,38 +16,56 @@ import (
// 超级管理员(拥有 super_admin 角色的管理员)自动放行。 // 超级管理员(拥有 super_admin 角色的管理员)自动放行。
func RequirePermission(permCode string, rdb *redis.Client) gin.HandlerFunc { func RequirePermission(permCode string, rdb *redis.Client) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
value, ok := c.Get(ContextAdminID) if checkPermission(c, permCode, rdb) {
if !ok { c.Next()
response.Unauthorized(c, "缺少管理员上下文")
c.Abort()
return
} }
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) { func getPermCodes(c *gin.Context, rdb *redis.Client, adminID uint64) ([]string, error) {
if rdb == nil { if rdb == nil {
return nil, errors.New("redis unavailable") return nil, errors.New("redis unavailable")
+56 -6
View File
@@ -1,12 +1,14 @@
package middleware package middleware
import ( import (
"context"
"net/http" "net/http"
"strconv" "strconv"
"sync" "sync"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
) )
type rateLimitBucket struct { type rateLimitBucket struct {
@@ -22,7 +24,14 @@ type rateLimiter struct {
buckets map[string]rateLimitBucket 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 { if limit <= 0 {
return func(c *gin.Context) { return func(c *gin.Context) {
c.Next() c.Next()
@@ -33,24 +42,57 @@ func RateLimitPerMinute(limit int) gin.HandlerFunc {
window: time.Minute, window: time.Minute,
buckets: make(map[string]rateLimitBucket), buckets: make(map[string]rateLimitBucket),
} }
if rdb != nil {
return (&redisRateLimiter{
redis: rdb,
fallback: limiter,
limit: limit,
window: time.Minute,
}).handle
}
return limiter.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) { func (l *rateLimiter) handle(c *gin.Context) {
now := time.Now() now := time.Now()
key := c.ClientIP() key := c.ClientIP()
allowed, resetAt := l.allow(key, now) allowed, resetAt := l.allow(key, now)
if !allowed { if !allowed {
c.Header("Retry-After", retryAfterSeconds(now, resetAt)) writeRateLimited(c, now, resetAt)
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
"code": "rate_limited",
"message": "请求过于频繁,请稍后再试",
})
return return
} }
c.Next() 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) { func (l *rateLimiter) allow(key string, now time.Time) (bool, time.Time) {
l.mu.Lock() l.mu.Lock()
defer l.mu.Unlock() defer l.mu.Unlock()
@@ -81,3 +123,11 @@ func retryAfterSeconds(now time.Time, resetAt time.Time) string {
} }
return strconv.Itoa(seconds) 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": "请求过于频繁,请稍后再试",
})
}
+11 -9
View File
@@ -3,15 +3,17 @@ package model
import "time" import "time"
type AdminUser struct { type AdminUser struct {
ID uint64 `gorm:"primaryKey" json:"id"` ID uint64 `gorm:"primaryKey" json:"id"`
Username string `gorm:"size:64;not null;uniqueIndex" json:"username"` Username string `gorm:"size:64;not null;uniqueIndex" json:"username"`
PasswordHash string `gorm:"size:255;not null" json:"-"` PasswordHash string `gorm:"size:255;not null" json:"-"`
Nickname string `gorm:"size:64;not null;default:''" json:"nickname"` Nickname string `gorm:"size:64;not null;default:''" json:"nickname"`
Status string `gorm:"size:32;not null;default:'active'" json:"status"` Status string `gorm:"size:32;not null;default:'active'" json:"status"`
SupportStatus string `gorm:"size:16;not null;default:'offline';index" json:"support_status"` SupportStatus string `gorm:"size:16;not null;default:'offline';index" json:"support_status"`
LastLoginAt *time.Time `json:"last_login_at"` TokenVersion int64 `gorm:"not null;default:1" json:"-"`
CreatedAt time.Time `json:"created_at"` PasswordMustChange bool `gorm:"not null;default:false" json:"password_must_change"`
UpdatedAt time.Time `json:"updated_at"` LastLoginAt *time.Time `json:"last_login_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
} }
func (AdminUser) TableName() string { func (AdminUser) TableName() string {
@@ -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"))
}
+9 -8
View File
@@ -7,14 +7,15 @@ import (
) )
type AdminDTO struct { type AdminDTO struct {
ID uint64 `json:"id"` ID uint64 `json:"id"`
Username string `json:"username"` Username string `json:"username"`
Nickname string `json:"nickname"` Nickname string `json:"nickname"`
Status string `json:"status"` Status string `json:"status"`
SupportStatus string `json:"support_status"` SupportStatus string `json:"support_status"`
Roles []RoleDTO `json:"roles"` PasswordMustChange bool `json:"password_must_change"`
Permissions []string `json:"permissions"` Roles []RoleDTO `json:"roles"`
LastLoginAt *time.Time `json:"last_login_at"` Permissions []string `json:"permissions"`
LastLoginAt *time.Time `json:"last_login_at"`
} }
type RoleDTO struct { type RoleDTO struct {
+63 -6
View File
@@ -2,15 +2,23 @@ package adminauth
import ( import (
"errors" "errors"
"io"
"net/http" "net/http"
"strings" "strings"
"hfb_sys/backend/internal/middleware" "hfb_sys/backend/internal/middleware"
"hfb_sys/backend/internal/modules/auth"
"hfb_sys/backend/pkg/response" "hfb_sys/backend/pkg/response"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
const (
adminAccessCookieName = middleware.AdminAccessCookieName
adminRefreshCookieName = "hfb_admin_refresh"
adminRefreshCookieMaxAge = 14 * 24 * 60 * 60
)
type Handler struct { type Handler struct {
service *Service service *Service
} }
@@ -34,12 +42,13 @@ func (h *Handler) Login(c *gin.Context) {
response.BadRequest(c, "用户名、密码和验证码不能为空") response.BadRequest(c, "用户名、密码和验证码不能为空")
return 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 { if err != nil {
writeAdminAuthError(c, err) writeAdminAuthError(c, err)
return return
} }
response.OK(c, result) setAdminAuthCookies(c, result.Tokens)
response.OK(c, gin.H{"admin": result.Admin})
} }
func (h *Handler) Me(c *gin.Context) { 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) { 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}) response.OK(c, gin.H{"logged_out": true})
} }
@@ -93,21 +112,57 @@ func currentAdminID(c *gin.Context) (uint64, bool) {
} }
type AdminRefreshRequest struct { type AdminRefreshRequest struct {
RefreshToken string `json:"refresh_token" binding:"required"` RefreshToken string `json:"refresh_token"`
} }
func (h *Handler) Refresh(c *gin.Context) { func (h *Handler) Refresh(c *gin.Context) {
var req AdminRefreshRequest 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 不能为空") response.BadRequest(c, "refresh_token 不能为空")
return return
} }
tokens, err := h.service.Refresh(c.Request.Context(), req.RefreshToken) tokens, err := h.service.Refresh(c.Request.Context(), refreshToken)
if err != nil { if err != nil {
writeAdminAuthError(c, err) writeAdminAuthError(c, err)
return 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) { func writeAdminAuthError(c *gin.Context, err error) {
@@ -120,6 +175,8 @@ func writeAdminAuthError(c *gin.Context, err error) {
response.BadRequest(c, "用户名或密码错误") response.BadRequest(c, "用户名或密码错误")
case errors.Is(err, ErrCaptchaInvalid): case errors.Is(err, ErrCaptchaInvalid):
response.BadRequest(c, "验证码错误或已过期") response.BadRequest(c, "验证码错误或已过期")
case errors.Is(err, ErrLoginLocked):
response.Error(c, http.StatusTooManyRequests, "login_locked", "登录失败次数过多,请稍后再试")
case errors.Is(err, ErrAdminDisabled): case errors.Is(err, ErrAdminDisabled):
response.Error(c, http.StatusForbidden, "admin_disabled", "管理员已禁用") response.Error(c, http.StatusForbidden, "admin_disabled", "管理员已禁用")
default: default:
@@ -21,9 +21,10 @@ import (
) )
const ( const (
defaultAdminUsername = "admin"
defaultAdminPassword = "admin123456"
captchaTTL = 3 * time.Minute captchaTTL = 3 * time.Minute
loginFailureTTL = 15 * time.Minute
loginLockTTL = 15 * time.Minute
loginMaxFailureCount = 5
) )
type Repository struct { type Repository struct {
@@ -58,16 +59,17 @@ func (r *Repository) Captcha(ctx context.Context) (*CaptchaDTO, error) {
}, nil }, 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 { if err := r.verifyCaptcha(ctx, captchaID, captchaCode); err != nil {
return LoginResult{}, err return LoginResult{}, err
} }
if err := r.ensureDefaultAdmin(ctx); err != nil { if err := r.ensureLoginNotLocked(ctx, username, clientIP); err != nil {
return LoginResult{}, err return LoginResult{}, err
} }
var admin model.AdminUser var admin model.AdminUser
if err := r.db.WithContext(ctx).Where("username = ?", username).First(&admin).Error; err != nil { if err := r.db.WithContext(ctx).Where("username = ?", username).First(&admin).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
r.recordLoginFailure(ctx, username, clientIP)
return LoginResult{}, ErrInvalidCredential return LoginResult{}, ErrInvalidCredential
} }
return LoginResult{}, err return LoginResult{}, err
@@ -76,14 +78,19 @@ func (r *Repository) Login(ctx context.Context, username string, password string
return LoginResult{}, ErrAdminDisabled return LoginResult{}, ErrAdminDisabled
} }
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)); err != nil { if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)); err != nil {
r.recordLoginFailure(ctx, username, clientIP)
return LoginResult{}, ErrInvalidCredential return LoginResult{}, ErrInvalidCredential
} }
now := time.Now() now := time.Now()
admin.LastLoginAt = &now admin.LastLoginAt = &now
if admin.TokenVersion <= 0 {
admin.TokenVersion = 1
}
if err := r.db.WithContext(ctx).Save(&admin).Error; err != nil { if err := r.db.WithContext(ctx).Save(&admin).Error; err != nil {
return LoginResult{}, err 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 { if err != nil {
return LoginResult{}, err 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 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 { func (r *Repository) verifyCaptcha(ctx context.Context, captchaID string, captchaCode string) error {
if r.redis == nil { if r.redis == nil {
return ErrDependencyUnavailable return ErrDependencyUnavailable
@@ -136,48 +163,15 @@ func (r *Repository) UpdateSupportStatus(ctx context.Context, adminID uint64, st
Update("support_status", status).Error 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 { func toDTO(admin model.AdminUser) AdminDTO {
return AdminDTO{ return AdminDTO{
ID: admin.ID, ID: admin.ID,
Username: admin.Username, Username: admin.Username,
Nickname: admin.Nickname, Nickname: admin.Nickname,
Status: admin.Status, Status: admin.Status,
SupportStatus: admin.SupportStatus, SupportStatus: admin.SupportStatus,
LastLoginAt: admin.LastLoginAt, 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) 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 { func captchaKey(id string) string {
return "admin:captcha:" + id return "admin:captcha:" + id
} }
+21 -3
View File
@@ -13,6 +13,7 @@ var (
ErrCaptchaInvalid = errors.New("captcha invalid") ErrCaptchaInvalid = errors.New("captcha invalid")
ErrAdminDisabled = errors.New("admin disabled") ErrAdminDisabled = errors.New("admin disabled")
ErrInvalidRefreshToken = errors.New("invalid refresh token") ErrInvalidRefreshToken = errors.New("invalid refresh token")
ErrLoginLocked = errors.New("login locked")
) )
type Service struct { type Service struct {
@@ -32,7 +33,17 @@ func (s *Service) Refresh(ctx context.Context, refreshToken string) (*auth.Token
if err != nil { if err != nil {
return nil, ErrInvalidRefreshToken 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 { if err != nil {
return nil, err return nil, err
} }
@@ -46,14 +57,14 @@ func (s *Service) Captcha(ctx context.Context) (*CaptchaDTO, error) {
return s.repo.Captcha(ctx) 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 { if s.repo == nil {
return LoginResult{}, ErrDependencyUnavailable return LoginResult{}, ErrDependencyUnavailable
} }
if username == "" || password == "" || captchaID == "" || captchaCode == "" { if username == "" || password == "" || captchaID == "" || captchaCode == "" {
return LoginResult{}, ErrInvalidCredential 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) { 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) 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)
}
+12 -8
View File
@@ -7,14 +7,14 @@ import (
) )
type AdminUserDTO struct { type AdminUserDTO struct {
ID uint64 `json:"id"` ID uint64 `json:"id"`
Username string `json:"username"` Username string `json:"username"`
Nickname string `json:"nickname"` Nickname string `json:"nickname"`
Status string `json:"status"` Status string `json:"status"`
Roles []adminrole.RoleDTO `json:"roles"` Roles []adminrole.RoleDTO `json:"roles"`
LastLoginAt *time.Time `json:"last_login_at"` LastLoginAt *time.Time `json:"last_login_at"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
type PaginatedResult struct { type PaginatedResult struct {
@@ -43,3 +43,7 @@ type ChangePasswordRequest struct {
OldPassword string `json:"old_password" binding:"required"` OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required"` NewPassword string `json:"new_password" binding:"required"`
} }
type ResetPasswordRequest struct {
NewPassword string `json:"new_password" binding:"required"`
}
+35 -2
View File
@@ -106,8 +106,13 @@ func (h *Handler) AssignRoles(c *gin.Context) {
} }
func (h *Handler) ChangePassword(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 { if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return return
} }
var req ChangePasswordRequest var req ChangePasswordRequest
@@ -115,13 +120,39 @@ func (h *Handler) ChangePassword(c *gin.Context) {
response.BadRequest(c, "密码不能为空") response.BadRequest(c, "密码不能为空")
return 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) writeError(c, err)
return return
} }
response.OK(c, gin.H{"updated": true}) 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) { func parseID(c *gin.Context) (uint64, bool) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 { if err != nil || id == 0 {
@@ -141,6 +172,8 @@ func writeError(c *gin.Context, err error) {
response.BadRequest(c, "不能删除最后一个超级管理员") response.BadRequest(c, "不能删除最后一个超级管理员")
case errors.Is(err, ErrWrongPassword): case errors.Is(err, ErrWrongPassword):
response.BadRequest(c, "原密码错误") response.BadRequest(c, "原密码错误")
case errors.Is(err, ErrWeakPassword):
response.BadRequest(c, "密码至少 8 位且需包含字母和数字")
case IsNotFound(err): case IsNotFound(err):
response.Error(c, http.StatusNotFound, "not_found", "管理员不存在") response.Error(c, http.StatusNotFound, "not_found", "管理员不存在")
default: default:
@@ -76,10 +76,12 @@ func (r *Repository) Create(ctx context.Context, req CreateAdminRequest) (*Admin
return nil, err return nil, err
} }
admin := model.AdminUser{ admin := model.AdminUser{
Username: req.Username, Username: req.Username,
PasswordHash: string(hash), PasswordHash: string(hash),
Nickname: req.Nickname, Nickname: req.Nickname,
Status: "active", Status: "active",
TokenVersion: 1,
PasswordMustChange: true,
} }
if err := r.db.WithContext(ctx).Create(&admin).Error; err != nil { if err := r.db.WithContext(ctx).Create(&admin).Error; err != nil {
return nil, err 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 { if err := db.Save(&admin).Error; err != nil {
return nil, err 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) return r.FindByID(ctx, id)
} }
@@ -150,7 +158,7 @@ func (r *Repository) AssignRoles(ctx context.Context, adminID uint64, roleIDs []
return err return err
} }
} }
return nil return r.bumpTokenVersion(tx, adminID)
}) })
if err != nil { if err != nil {
return err return err
@@ -159,7 +167,7 @@ func (r *Repository) AssignRoles(ctx context.Context, adminID uint64, roleIDs []
return nil 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 var admin model.AdminUser
if err := r.db.WithContext(ctx).First(&admin, id).Error; err != nil { if err := r.db.WithContext(ctx).First(&admin, id).Error; err != nil {
return err return err
@@ -171,7 +179,35 @@ func (r *Repository) ChangePassword(ctx context.Context, id uint64, oldPwd, newP
if err != nil { if err != nil {
return err 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) { 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)) 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 { func permCacheKey(adminID uint64) string {
return fmt.Sprintf("admin:perms:%d", adminID) return fmt.Sprintf("admin:perms:%d", adminID)
} }
+38 -7
View File
@@ -3,9 +3,13 @@ package adminmgr
import ( import (
"context" "context"
"errors" "errors"
"unicode"
) )
var ErrDependencyUnavailable = errors.New("dependency unavailable") var (
ErrDependencyUnavailable = errors.New("dependency unavailable")
ErrWeakPassword = errors.New("weak password")
)
type Service struct { type Service struct {
repo *Repository repo *Repository
@@ -39,8 +43,8 @@ func (s *Service) Create(ctx context.Context, req CreateAdminRequest) (*AdminUse
if s.repo == nil { if s.repo == nil {
return nil, ErrDependencyUnavailable return nil, ErrDependencyUnavailable
} }
if len(req.Password) < 6 { if !passwordStrongEnough(req.Password) {
return nil, errors.New("password too short") return nil, ErrWeakPassword
} }
return s.repo.Create(ctx, req) 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) 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 { if s.repo == nil {
return ErrDependencyUnavailable return ErrDependencyUnavailable
} }
if len(req.NewPassword) < 6 { if !passwordStrongEnough(req.NewPassword) {
return errors.New("new password too short") 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
} }
@@ -3,19 +3,22 @@ package adminrole
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"hfb_sys/backend/internal/model" "hfb_sys/backend/internal/model"
"github.com/redis/go-redis/v9"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
) )
type Repository struct { type Repository struct {
db *gorm.DB db *gorm.DB
redis *redis.Client
} }
func NewRepository(db *gorm.DB) *Repository { func NewRepository(db *gorm.DB, redis *redis.Client) *Repository {
return &Repository{db: db} return &Repository{db: db, redis: redis}
} }
func (r *Repository) List(ctx context.Context) ([]RoleDTO, error) { 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" { if role.Code == "super_admin" {
return ErrProtectedRole 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 { if err := tx.Where("role_id = ?", id).Delete(&model.RolePermission{}).Error; err != nil {
return err return err
} }
if err := tx.Where("role_id = ?", id).Delete(&model.AdminUserRole{}).Error; err != nil { if err := tx.Where("role_id = ?", id).Delete(&model.AdminUserRole{}).Error; err != nil {
return err 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 { 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 { if err := db.First(&role, roleID).Error; err != nil {
return err 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" { if role.Code == "super_admin" {
var allPermIDs []uint64 var allPermIDs []uint64
if err := tx.Model(&model.Permission{}).Pluck("id", &allPermIDs).Error; err != nil { 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 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) { 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 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") var ErrProtectedRole = errors.New("protected role")
func IsNotFound(err error) bool { func IsNotFound(err error) bool {
+17 -11
View File
@@ -22,10 +22,11 @@ type JWTManager struct {
} }
type Claims struct { type Claims struct {
UserID uint64 `json:"uid"` UserID uint64 `json:"uid"`
Phone string `json:"phone"` Phone string `json:"phone"`
TokenType string `json:"typ"` TokenType string `json:"typ"`
SubjectType string `json:"sub_type"` SubjectType string `json:"sub_type"`
TokenVersion int64 `json:"ver,omitempty"`
jwt.RegisteredClaims 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) { 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 { if err != nil {
return TokenPair{}, err 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 { if err != nil {
return TokenPair{}, err return TokenPair{}, err
} }
@@ -90,13 +95,14 @@ func (m *JWTManager) ParseSubject(tokenText, expectedType string, expectedSubjec
return claims, nil 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() now := time.Now()
claims := Claims{ claims := Claims{
UserID: userID, UserID: userID,
Phone: subject, Phone: subject,
TokenType: tokenType, TokenType: tokenType,
SubjectType: subjectType, SubjectType: subjectType,
TokenVersion: tokenVersion,
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
Subject: subject, Subject: subject,
IssuedAt: jwt.NewNumericDate(now), IssuedAt: jwt.NewNumericDate(now),
+16 -4
View File
@@ -5,12 +5,24 @@ import (
) )
type Handler struct { type Handler struct {
service *Service service *Service
storage *filemodule.Storage storage *filemodule.Storage
externalUploadSecret string
externalUploadAllowedIPs []string
} }
const maxExternalUploadBodyBytes = 256 * 1024 const maxExternalUploadBodyBytes = 256 * 1024
func NewHandler(service *Service, storage *filemodule.Storage) *Handler { type HandlerOptions struct {
return &Handler{service: service, storage: storage} 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
} }
@@ -1,9 +1,17 @@
package listing package listing
import ( import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"errors"
"io" "io"
"net"
"net/http" "net/http"
"strconv"
"strings"
"time"
"hfb_sys/backend/pkg/response" "hfb_sys/backend/pkg/response"
@@ -17,6 +25,10 @@ func (h *Handler) ImportExternalUpload(c *gin.Context) {
response.BadRequest(c, "上传内容过大或读取失败") response.BadRequest(c, "上传内容过大或读取失败")
return return
} }
if err := h.verifyExternalUpload(c, raw); err != nil {
writeExternalUploadAuthError(c, err)
return
}
var req ExternalUploadRequest var req ExternalUploadRequest
if err := json.Unmarshal(raw, &req); err != nil { if err := json.Unmarshal(raw, &req); err != nil {
response.BadRequest(c, "上传 JSON 格式不正确") response.BadRequest(c, "上传 JSON 格式不正确")
@@ -34,6 +46,82 @@ func (h *Handler) ImportExternalUpload(c *gin.Context) {
response.Created(c, result) 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) { func (h *Handler) DefaultUploadScreenshot(c *gin.Context) {
c.Header("Content-Type", "image/svg+xml; charset=utf-8") c.Header("Content-Type", "image/svg+xml; charset=utf-8")
c.Header("Cache-Control", "public, max-age=86400") c.Header("Cache-Control", "public, max-age=86400")
@@ -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)
}
@@ -61,8 +61,17 @@ func (h *Handler) Get(c *gin.Context) {
} }
includeSecret := c.Query("include_secret") == "true" 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 { if err == ErrConfigNotFound {
response.NotFound(c, "配置不存在") response.NotFound(c, "配置不存在")
return return
@@ -65,7 +65,7 @@ func (r *Repository) List(ctx context.Context, query ListQuery) ([]ConfigDTO, in
// FindByID 根据 ID 查询配置 // FindByID 根据 ID 查询配置
// 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 var item model.PaymentMerchantConfig
if err := r.db.WithContext(ctx).Where("id = ?", id).First(&item).Error; err != nil { if err := r.db.WithContext(ctx).Where("id = ?", id).First(&item).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -77,6 +77,15 @@ func (r *Repository) FindByID(ctx context.Context, id uint64, includeSecret bool
if err != nil { if err != nil {
return nil, err 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 return &dto, nil
} }
@@ -35,8 +35,8 @@ func (s *Service) List(ctx context.Context, query ListQuery) (*ListResponse, err
} }
// Get 获取单个配置 // Get 获取单个配置
func (s *Service) Get(ctx context.Context, id uint64, includeSecret bool) (*ConfigDTO, error) { func (s *Service) Get(ctx context.Context, id uint64, includeSecret bool, actorID uint64, meta AuditMeta) (*ConfigDTO, error) {
return s.repo.FindByID(ctx, id, includeSecret) return s.repo.FindByID(ctx, id, includeSecret, actorID, meta)
} }
// ExportBackup 导出支付配置备份。 // ExportBackup 导出支付配置备份。
+31 -10
View File
@@ -2,7 +2,6 @@ package router
import ( import (
"context" "context"
"os"
"strings" "strings"
"hfb_sys/backend/internal/config" "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)) engine.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
} }
if cfg.RateLimit.Enabled { 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) 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 paymentConfigService *paymentconfig.Service
var paymentConfigHandler *paymentconfig.Handler var paymentConfigHandler *paymentconfig.Handler
if deps.DB != nil { if deps.DB != nil {
// 从环境变量获取加密密钥,如果没有则使用 MockEncryptor // 生产环境必须配置有效加密密钥;开发/测试缺失时才降级 MockEncryptor
encryptionKey := os.Getenv("PAYMENT_CONFIG_ENCRYPTION_KEY") encryptionKey := cfg.PaymentConfigEncryptionKey
var encryptor paymentconfig.Encryptor var encryptor paymentconfig.Encryptor
if encryptionKey != "" { if encryptionKey != "" {
if aesEncryptor, err := paymentconfig.NewAESEncryptor(encryptionKey); err == nil { 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 encryptor == nil {
if cfg.AppEnv == "production" {
logger.Fatal("PAYMENT_CONFIG_ENCRYPTION_KEY not set or invalid")
}
encryptor = &paymentconfig.MockEncryptor{} encryptor = &paymentconfig.MockEncryptor{}
logger.Warn("PAYMENT_CONFIG_ENCRYPTION_KEY not set or invalid, using 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) systemConfigHandler := systemconfig.NewHandler(systemConfigService)
var adminRoleRepo *adminrole.Repository var adminRoleRepo *adminrole.Repository
if deps.DB != nil { if deps.DB != nil {
adminRoleRepo = adminrole.NewRepository(deps.DB) adminRoleRepo = adminrole.NewRepository(deps.DB, deps.Redis)
} }
adminRoleService := adminrole.NewService(adminRoleRepo) adminRoleService := adminrole.NewService(adminRoleRepo)
adminRoleHandler := adminrole.NewHandler(adminRoleService) adminRoleHandler := adminrole.NewHandler(adminRoleService)
@@ -246,7 +248,10 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
fileService := filemodule.NewService(fileStorage) fileService := filemodule.NewService(fileStorage)
fileHandler := filemodule.NewHandler(fileService, fileStorage) fileHandler := filemodule.NewHandler(fileService, fileStorage)
listingService := listing.NewService(listingRepo, systemConfigRepo) 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 var announcementRepo *announcement.Repository
if deps.DB != nil { if deps.DB != nil {
announcementRepo = announcement.NewRepository(deps.DB) 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) announcementService := announcement.NewService(announcementRepo)
announcementHandler := announcement.NewHandler(announcementService) announcementHandler := announcement.NewHandler(announcementService)
requireAuth := middleware.Auth(jwtManager) 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) requireRealname := middleware.RequireRealname(userRepo)
requirePerm := func(code string) gin.HandlerFunc { requirePerm := func(code string) gin.HandlerFunc {
return middleware.RequirePermission(code, deps.Redis) 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) adminAuthRoutes.POST("/refresh", adminAuthHandler.Refresh)
} }
adminRoutes := api.Group("/admin", requireAdmin) adminRoutes := api.Group("/admin", requireAdmin, middleware.RequireAdminPasswordChanged())
{ {
adminRoutes.GET("/me", adminAuthHandler.Me) adminRoutes.GET("/me", adminAuthHandler.Me)
adminRoutes.PUT("/me/support-status", adminAuthHandler.UpdateSupportStatus) 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", requirePerm("payment_config:list"), paymentConfigHandler.List)
adminRoutes.GET("/payment-configs/export", requirePerm("payment_config:view_secret"), paymentConfigHandler.ExportBackup) 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.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.POST("/payment-configs", requirePerm("payment_config:create"), paymentConfigHandler.Create)
adminRoutes.PUT("/payment-configs/:id", requirePerm("payment_config:update"), paymentConfigHandler.Update) adminRoutes.PUT("/payment-configs/:id", requirePerm("payment_config:update"), paymentConfigHandler.Update)
adminRoutes.DELETE("/payment-configs/:id", requirePerm("payment_config:delete"), paymentConfigHandler.Delete) 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.PUT("/admin-users/:id", requirePerm("admin_user:manage"), adminMgrHandler.Update)
adminRoutes.DELETE("/admin-users/:id", requirePerm("admin_user:manage"), adminMgrHandler.Delete) 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/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) adminRoutes.GET("/announcements", requirePerm("announcement:view"), announcementHandler.AdminList)
+4 -18
View File
@@ -383,6 +383,8 @@ CREATE TABLE IF NOT EXISTS admin_users (
nickname VARCHAR(64) NOT NULL DEFAULT '' COMMENT '昵称', nickname VARCHAR(64) NOT NULL DEFAULT '' COMMENT '昵称',
status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '状态: active正常, inactive停用', status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '状态: active正常, inactive停用',
support_status VARCHAR(16) NOT NULL DEFAULT 'offline' COMMENT '客服状态: online在线, offline离线, busy忙碌', 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 '最后登录时间', last_login_at DATETIME NULL COMMENT '最后登录时间',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE 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_BOOTSTRAP_USERNAME / ADMIN_BOOTSTRAP_PASSWORD 一次性生成。
('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';
-- ------------------------------------------- -- -------------------------------------------
-- 系统配置初始化 -- 系统配置初始化
@@ -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
+1
View File
@@ -17,6 +17,7 @@
X-Frame-Options "DENY" X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin" Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "camera=(), microphone=(), geolocation=()" 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 长连接:关闭缓冲,逐条推送。 # 聊天 SSE 长连接:关闭缓冲,逐条推送。
+6 -12
View File
@@ -2,7 +2,6 @@ import axios from 'axios'
import { apiClient } from '@/shared/api/client' import { apiClient } from '@/shared/api/client'
import type { ApiResponse } from '@/shared/types/types' import type { ApiResponse } from '@/shared/types/types'
import { getRefreshToken, setAuthTokens } from '@/shared/utils/authStorage'
import type { UserStatus } from '@/shared/types/status' import type { UserStatus } from '@/shared/types/status'
export interface AdminRole { export interface AdminRole {
@@ -17,21 +16,19 @@ export interface AdminUser {
nickname: string nickname: string
status: UserStatus status: UserStatus
support_status: 'online' | 'offline' | 'busy' support_status: 'online' | 'offline' | 'busy'
password_must_change: boolean
roles: AdminRole[] roles: AdminRole[]
permissions: string[] permissions: string[]
last_login_at?: string last_login_at?: string
} }
export interface AdminTokenPair { export interface AdminRefreshData {
access_token: string refreshed: boolean
refresh_token: string
token_type: string
expires_in: number expires_in: number
} }
export interface AdminLoginData { export interface AdminLoginData {
admin: AdminUser admin: AdminUser
tokens: AdminTokenPair
} }
export interface AdminCaptcha { 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) */ /** Manually refresh admin token (uses raw axios to avoid interceptor recursion) */
export async function refreshAdminSession() { export async function refreshAdminSession() {
const refreshToken = getRefreshToken('admin') const { data } = await axios.post<ApiResponse<AdminRefreshData>>(
if (!refreshToken) throw new Error('no refresh token')
const { data } = await axios.post<ApiResponse<AdminTokenPair>>(
'/api/admin/auth/refresh', '/api/admin/auth/refresh',
{ refresh_token: refreshToken }, {},
{ timeout: 10000 } { timeout: 10000, withCredentials: true }
) )
setAuthTokens('admin', data.data)
return data.data return data.data
} }
+13 -1
View File
@@ -36,6 +36,10 @@ export interface ChangePasswordRequest {
new_password: string new_password: string
} }
export interface ResetPasswordRequest {
new_password: string
}
export async function fetchAdminMgrUsers(page = 1, pageSize = 20) { export async function fetchAdminMgrUsers(page = 1, pageSize = 20) {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminMgrUser>>>( const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminMgrUser>>>(
'/admin/admin-users', '/admin/admin-users',
@@ -78,7 +82,15 @@ export async function assignAdminRoles(id: number, roleIds: number[]) {
return data.data return data.data
} }
export async function changeAdminPassword(id: number, req: ChangePasswordRequest) { export async function changeAdminPassword(req: ChangePasswordRequest) {
const { data } = await apiClient.put<ApiResponse<{ updated: boolean }>>(
'/admin/admin-users/me/password',
req
)
return data.data
}
export async function resetAdminPassword(id: number, req: ResetPasswordRequest) {
const { data } = await apiClient.put<ApiResponse<{ updated: boolean }>>( const { data } = await apiClient.put<ApiResponse<{ updated: boolean }>>(
`/admin/admin-users/${id}/password`, `/admin/admin-users/${id}/password`,
req req
+1 -1
View File
@@ -19,7 +19,7 @@ export {
fetchAdminMe, fetchAdminMe,
updateSupportStatus, updateSupportStatus,
type AdminUser, type AdminUser,
type AdminTokenPair, type AdminRefreshData,
type AdminLoginData, type AdminLoginData,
type AdminCaptcha, type AdminCaptcha,
} from './api/adminAuth' } from './api/adminAuth'
@@ -3,19 +3,20 @@ import { readError } from '@/shared/utils/error'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Lock, User } from '@element-plus/icons-vue' import { Lock, User } from '@element-plus/icons-vue'
import { onMounted, reactive, ref } from '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 { fetchAdminCaptcha, type AdminCaptcha } from '@/features/admin'
import { useAdminSessionStore } from '@/stores/adminSession' import { useAdminSessionStore } from '@/stores/adminSession'
const router = useRouter() const router = useRouter()
const route = useRoute()
const adminSession = useAdminSessionStore() const adminSession = useAdminSessionStore()
const loading = ref(false) const loading = ref(false)
const captchaLoading = ref(false) const captchaLoading = ref(false)
const captcha = ref<AdminCaptcha | null>(null) const captcha = ref<AdminCaptcha | null>(null)
const form = reactive({ const form = reactive({
username: 'admin', username: '',
password: 'admin123456', password: '',
captchaCode: '', captchaCode: '',
}) })
@@ -43,7 +44,8 @@ async function handleLogin() {
form.captchaCode form.captchaCode
) )
ElMessage.success('后台登录成功') 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) { } catch (error) {
ElMessage.error(readError(error, '后台登录失败')) ElMessage.error(readError(error, '后台登录失败'))
await loadCaptcha() await loadCaptcha()
@@ -6,7 +6,7 @@ import { ref } from 'vue'
import { import {
fetchAdminMgrUsers, fetchAdminMgrUsers,
deleteAdminMgrUser, deleteAdminMgrUser,
changeAdminPassword, resetAdminPassword,
type AdminMgrUser, type AdminMgrUser,
} from '@/features/admin/api/adminMgr' } from '@/features/admin/api/adminMgr'
import { useAdminPaginatedTable } from '@/features/admin/composables/useAdminPaginatedTable' import { useAdminPaginatedTable } from '@/features/admin/composables/useAdminPaginatedTable'
@@ -22,7 +22,7 @@ const showRolesDialog = ref(false)
const rolesAdmin = ref<AdminMgrUser | null>(null) const rolesAdmin = ref<AdminMgrUser | null>(null)
const showPasswordDialog = ref(false) const showPasswordDialog = ref(false)
const passwordAdmin = ref<AdminMgrUser | null>(null) const passwordAdmin = ref<AdminMgrUser | null>(null)
const passwordForm = ref({ old_password: '', new_password: '' }) const passwordForm = ref({ new_password: '' })
const passwordSubmitting = ref(false) const passwordSubmitting = ref(false)
const { const {
@@ -53,7 +53,7 @@ function openRoles(row: AdminMgrUser) {
function openPassword(row: AdminMgrUser) { function openPassword(row: AdminMgrUser) {
passwordAdmin.value = row passwordAdmin.value = row
passwordForm.value = { old_password: '', new_password: '' } passwordForm.value = { new_password: '' }
showPasswordDialog.value = true showPasswordDialog.value = true
} }
@@ -78,17 +78,17 @@ async function handleDelete(row: AdminMgrUser) {
async function handleChangePassword() { async function handleChangePassword() {
if (!passwordAdmin.value) return if (!passwordAdmin.value) return
if (!passwordForm.value.old_password || !passwordForm.value.new_password) { if (!passwordForm.value.new_password) {
ElMessage.warning('请填写完整') ElMessage.warning('请填写新密码')
return return
} }
passwordSubmitting.value = true passwordSubmitting.value = true
try { try {
await changeAdminPassword(passwordAdmin.value.id, passwordForm.value) await resetAdminPassword(passwordAdmin.value.id, passwordForm.value)
ElMessage.success('密码已修改') ElMessage.success('密码已重置,目标账号需要重新登录')
showPasswordDialog.value = false showPasswordDialog.value = false
} catch (error) { } catch (error) {
ElMessage.error(readError(error, '修改失败')) ElMessage.error(readError(error, '重置失败'))
} finally { } finally {
passwordSubmitting.value = false passwordSubmitting.value = false
} }
@@ -169,35 +169,27 @@ const statusLabel: Record<string, string> = {
<!-- 角色分配对话框 --> <!-- 角色分配对话框 -->
<AssignRolesDialog v-model="showRolesDialog" :admin="rolesAdmin" @saved="loadAdmins" /> <AssignRolesDialog v-model="showRolesDialog" :admin="rolesAdmin" @saved="loadAdmins" />
<!-- 修改密码对话框 --> <!-- 重置密码对话框 -->
<el-dialog <el-dialog
:model-value="showPasswordDialog" :model-value="showPasswordDialog"
:title="`修改密码 - ${passwordAdmin?.username || ''}`" :title="`重置密码 - ${passwordAdmin?.username || ''}`"
width="460px" width="460px"
@update:model-value="showPasswordDialog = $event" @update:model-value="showPasswordDialog = $event"
> >
<div class="dialog-body"> <div class="dialog-body">
<el-form-item label="原密码" class="full-control">
<el-input
v-model="passwordForm.old_password"
type="password"
show-password
placeholder="请输入原密码"
/>
</el-form-item>
<el-form-item label="新密码" class="full-control"> <el-form-item label="新密码" class="full-control">
<el-input <el-input
v-model="passwordForm.new_password" v-model="passwordForm.new_password"
type="password" type="password"
show-password show-password
placeholder="请输入新密码(至少6位)" placeholder="至少 8 位,包含字母和数字"
/> />
</el-form-item> </el-form-item>
</div> </div>
<template #footer> <template #footer>
<el-button @click="showPasswordDialog = false">取消</el-button> <el-button @click="showPasswordDialog = false">取消</el-button>
<el-button type="primary" :loading="passwordSubmitting" @click="handleChangePassword" <el-button type="primary" :loading="passwordSubmitting" @click="handleChangePassword"
>确认修改</el-button >确认重置</el-button
> >
</template> </template>
</el-dialog> </el-dialog>
+96 -2
View File
@@ -27,17 +27,24 @@ import {
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import { ElMessage, ElSubMenu } from 'element-plus' import { ElMessage, ElSubMenu } from 'element-plus'
import type { Component } from 'vue' import type { Component } from 'vue'
import { computed, ref } from 'vue' import { computed, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' 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 { useAdminSessionStore } from '@/stores/adminSession'
import { readError } from '@/shared/utils/error'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const adminSession = useAdminSessionStore() const adminSession = useAdminSessionStore()
const isCollapsed = ref(false) const isCollapsed = ref(false)
const updatingStatus = ref(false) const updatingStatus = ref(false)
const passwordSubmitting = ref(false)
const passwordForm = reactive({
old_password: '',
new_password: '',
confirm_password: '',
})
interface NavItem { interface NavItem {
label: string label: string
@@ -231,6 +238,39 @@ async function handleLogout() {
ElMessage.success('已退出后台') ElMessage.success('已退出后台')
await router.push('/admin/login') 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
}
}
</script> </script>
<template> <template>
@@ -332,6 +372,51 @@ async function handleLogout() {
<slot /> <slot />
</main> </main>
</section> </section>
<el-dialog
:model-value="adminSession.passwordMustChange"
title="修改初始密码"
width="420px"
:close-on-click-modal="false"
:close-on-press-escape="false"
:show-close="false"
append-to-body
class="force-password-dialog"
>
<el-form class="force-password-form" label-position="top" @submit.prevent>
<el-form-item label="原密码">
<el-input
v-model="passwordForm.old_password"
type="password"
show-password
autocomplete="current-password"
/>
</el-form-item>
<el-form-item label="新密码">
<el-input
v-model="passwordForm.new_password"
type="password"
show-password
autocomplete="new-password"
/>
</el-form-item>
<el-form-item label="确认新密码">
<el-input
v-model="passwordForm.confirm_password"
type="password"
show-password
autocomplete="new-password"
@keyup.enter="handleForcedPasswordChange"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button :disabled="passwordSubmitting" @click="handleLogout">退出登录</el-button>
<el-button type="primary" :loading="passwordSubmitting" @click="handleForcedPasswordChange">
确认修改
</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>
@@ -572,4 +657,13 @@ async function handleLogout() {
padding: 24px; padding: 24px;
overflow-y: auto; overflow-y: auto;
} }
:global(.force-password-dialog) {
max-width: calc(100vw - 32px);
}
.force-password-form {
display: grid;
gap: 2px;
}
</style> </style>
+18 -9
View File
@@ -77,19 +77,28 @@ router.beforeEach(async to => {
const adminSession = useAdminSessionStore() const adminSession = useAdminSessionStore()
adminSession.syncFromStorage() adminSession.syncFromStorage()
if (to.path === '/admin/login' && adminSession.token) { if (to.path === '/admin/login') {
return '/admin/dashboard' if (!adminSession.hasSessionHint) return true
try {
await adminSession.loadMe()
return '/admin/dashboard'
} catch {
adminSession.logout()
return true
}
} }
if (to.meta.requiresAdmin) { if (to.meta.requiresAdmin) {
if (!adminSession.token) { try {
return '/admin/login' if (
} !adminSession.hasSessionHint ||
if (adminSession.permissions.length === 0) { adminSession.permissions.length === 0 ||
try { adminSession.passwordMustChange
) {
await adminSession.loadMe() await adminSession.loadMe()
} catch {
// 权限加载失败,仍然允许访问(降级为无权限状态)
} }
} catch {
adminSession.logout()
return { path: '/admin/login', query: { redirect: to.fullPath } }
} }
} }
return true return true
+16 -4
View File
@@ -100,6 +100,7 @@ import type { ApiResponse } from '@/shared/types/types'
export const apiClient = axios.create({ export const apiClient = axios.create({
baseURL: '/api', baseURL: '/api',
timeout: 30000, // 增加到 30 秒,避免大文件上传超时 timeout: 30000, // 增加到 30 秒,避免大文件上传超时
withCredentials: true,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
@@ -149,11 +150,18 @@ function rejectPendingRequests(scope: AuthScope, error: unknown) {
} }
export async function refreshAccessToken(scope: AuthScope): Promise<string> { export async function refreshAccessToken(scope: AuthScope): Promise<string> {
if (scope === 'admin') {
await axios.post('/api/admin/auth/refresh', {}, { timeout: 10000, withCredentials: true })
return ''
}
const refreshToken = getRefreshToken(scope) const refreshToken = getRefreshToken(scope)
if (!refreshToken) throw new Error('no refresh token') if (!refreshToken) throw new Error('no refresh token')
const endpoint = scope === 'admin' ? '/api/admin/auth/refresh' : '/api/auth/refresh' const { data } = await axios.post(
const { data } = await axios.post(endpoint, { refresh_token: refreshToken }, { timeout: 10000 }) '/api/auth/refresh',
{ refresh_token: refreshToken },
{ timeout: 10000 }
)
const tokens = { const tokens = {
access_token: data.data.access_token, access_token: data.data.access_token,
refresh_token: data.data.refresh_token, refresh_token: data.data.refresh_token,
@@ -239,7 +247,9 @@ apiClient.interceptors.response.use(
state.pendingRequests.push({ resolve, reject }) state.pendingRequests.push({ resolve, reject })
}).then(newToken => { }).then(newToken => {
originalRequest._retry = true originalRequest._retry = true
originalRequest.headers.Authorization = `Bearer ${newToken}` if (newToken) {
originalRequest.headers.Authorization = `Bearer ${newToken}`
}
return apiClient(originalRequest) return apiClient(originalRequest)
}) })
} }
@@ -249,7 +259,9 @@ apiClient.interceptors.response.use(
const newToken = await refreshAccessToken(scope) const newToken = await refreshAccessToken(scope)
resolvePendingRequests(scope, newToken) resolvePendingRequests(scope, newToken)
originalRequest._retry = true originalRequest._retry = true
originalRequest.headers.Authorization = `Bearer ${newToken}` if (newToken) {
originalRequest.headers.Authorization = `Bearer ${newToken}`
}
return apiClient(originalRequest) return apiClient(originalRequest)
} catch (refreshError) { } catch (refreshError) {
rejectPendingRequests(scope, refreshError) rejectPendingRequests(scope, refreshError)
+9 -1
View File
@@ -14,7 +14,7 @@ const userKeys = {
const adminKeys = { const adminKeys = {
accessToken: 'admin_access_token', accessToken: 'admin_access_token',
refreshToken: 'admin_refresh_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) { function keysFor(scope: AuthScope) {
@@ -22,15 +22,23 @@ function keysFor(scope: AuthScope) {
} }
export function getAccessToken(scope: AuthScope) { export function getAccessToken(scope: AuthScope) {
if (scope === 'admin') return ''
return localStorage.getItem(keysFor(scope).accessToken) || '' return localStorage.getItem(keysFor(scope).accessToken) || ''
} }
export function getRefreshToken(scope: AuthScope) { export function getRefreshToken(scope: AuthScope) {
if (scope === 'admin') return ''
return localStorage.getItem(keysFor(scope).refreshToken) || '' return localStorage.getItem(keysFor(scope).refreshToken) || ''
} }
export function setAuthTokens(scope: AuthScope, tokens: AuthTokenPair) { export function setAuthTokens(scope: AuthScope, tokens: AuthTokenPair) {
const keys = keysFor(scope) 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.accessToken, tokens.access_token)
localStorage.setItem(keys.refreshToken, tokens.refresh_token) localStorage.setItem(keys.refreshToken, tokens.refresh_token)
notifyAuthStorageChanged(scope) notifyAuthStorageChanged(scope)
+16 -18
View File
@@ -6,17 +6,12 @@ import {
type AdminRole, type AdminRole,
type AdminUser, type AdminUser,
} from '@/features/admin/api/adminAuth' } from '@/features/admin/api/adminAuth'
import { import { clearAuthStorage } from '@/shared/utils/authStorage'
clearAuthStorage,
getAccessToken,
getRefreshToken,
setAuthTokens,
} from '@/shared/utils/authStorage'
export const useAdminSessionStore = defineStore('adminSession', { export const useAdminSessionStore = defineStore('adminSession', {
state: () => ({ state: () => ({
token: getAccessToken('admin'), token: '',
refreshToken: getRefreshToken('admin'), refreshToken: '',
adminId: Number(localStorage.getItem('admin_id') || 0), adminId: Number(localStorage.getItem('admin_id') || 0),
username: localStorage.getItem('admin_username') || '', username: localStorage.getItem('admin_username') || '',
nickname: '', nickname: '',
@@ -24,10 +19,12 @@ export const useAdminSessionStore = defineStore('adminSession', {
| 'online' | 'online'
| 'offline' | 'offline'
| 'busy', | 'busy',
passwordMustChange: localStorage.getItem('admin_password_must_change') === 'true',
roles: [] as AdminRole[], roles: [] as AdminRole[],
permissions: [] as string[], permissions: [] as string[],
}), }),
getters: { getters: {
hasSessionHint: state => state.adminId > 0 || state.username !== '',
hasPermission: state => { hasPermission: state => {
return (code: string) => state.permissions.includes(code) || state.permissions.includes('*') return (code: string) => state.permissions.includes(code) || state.permissions.includes('*')
}, },
@@ -41,7 +38,7 @@ export const useAdminSessionStore = defineStore('adminSession', {
actions: { actions: {
async login(username: string, password: string, captchaId: string, captchaCode: string) { async login(username: string, password: string, captchaId: string, captchaCode: string) {
const result = await loginAdmin(username, password, captchaId, captchaCode) 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 return result
}, },
async loadMe() { async loadMe() {
@@ -56,28 +53,27 @@ export const useAdminSessionStore = defineStore('adminSession', {
this.username = '' this.username = ''
this.nickname = '' this.nickname = ''
this.supportStatus = 'offline' this.supportStatus = 'offline'
this.passwordMustChange = false
this.roles = [] this.roles = []
this.permissions = [] this.permissions = []
clearAuthStorage('admin') clearAuthStorage('admin')
localStorage.removeItem('admin_support_status') localStorage.removeItem('admin_support_status')
localStorage.removeItem('admin_password_must_change')
}, },
syncFromStorage() { syncFromStorage() {
this.token = getAccessToken('admin') this.token = ''
this.refreshToken = getRefreshToken('admin') this.refreshToken = ''
this.adminId = Number(localStorage.getItem('admin_id') || 0) this.adminId = Number(localStorage.getItem('admin_id') || 0)
this.username = localStorage.getItem('admin_username') || '' this.username = localStorage.getItem('admin_username') || ''
this.supportStatus = (localStorage.getItem('admin_support_status') || 'offline') as this.supportStatus = (localStorage.getItem('admin_support_status') || 'offline') as
| 'online' | 'online'
| 'offline' | 'offline'
| 'busy' | 'busy'
this.passwordMustChange = localStorage.getItem('admin_password_must_change') === 'true'
}, },
applySession(admin: AdminUser, accessToken: string, refreshToken: string) { applySession(admin: AdminUser) {
this.token = accessToken this.token = ''
this.refreshToken = refreshToken this.refreshToken = ''
setAuthTokens('admin', {
access_token: accessToken,
refresh_token: refreshToken,
})
this.applyAdmin(admin) this.applyAdmin(admin)
}, },
applyAdmin(admin: AdminUser) { applyAdmin(admin: AdminUser) {
@@ -85,11 +81,13 @@ export const useAdminSessionStore = defineStore('adminSession', {
this.username = admin.username this.username = admin.username
this.nickname = admin.nickname this.nickname = admin.nickname
this.supportStatus = admin.support_status || 'offline' this.supportStatus = admin.support_status || 'offline'
this.passwordMustChange = admin.password_must_change
this.roles = admin.roles || [] this.roles = admin.roles || []
this.permissions = admin.permissions || [] this.permissions = admin.permissions || []
localStorage.setItem('admin_id', String(admin.id)) localStorage.setItem('admin_id', String(admin.id))
localStorage.setItem('admin_username', admin.username) localStorage.setItem('admin_username', admin.username)
localStorage.setItem('admin_support_status', admin.support_status || 'offline') 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') { setSupportStatus(status: 'online' | 'offline' | 'busy') {
this.supportStatus = status this.supportStatus = status
+1 -1
View File
@@ -588,7 +588,7 @@ main() {
log_success "开发环境已启动" log_success "开发环境已启动"
if [[ "${NO_FRONTEND}" == "0" ]]; then if [[ "${NO_FRONTEND}" == "0" ]]; then
log "前台:http://localhost:5173" log "前台:http://localhost:5173"
log "后台:http://localhost:5173/admin/loginadmin / admin123456" log "后台:http://localhost:5173/admin/login"
fi fi
log "按 Ctrl+C 停止前后端;Docker 依赖会保留运行" log "按 Ctrl+C 停止前后端;Docker 依赖会保留运行"
+18 -10
View File
@@ -8,6 +8,7 @@ import (
"io" "io"
"math/rand" "math/rand"
"net/http" "net/http"
"os"
"sort" "sort"
"sync" "sync"
"sync/atomic" "sync/atomic"
@@ -266,12 +267,19 @@ func initAdminToken(baseURL string) *AdminToken {
return nil 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{ loginPayload := map[string]string{
"username": "admin", "username": adminUsername,
"password": "admin123456", "password": adminPassword,
"captcha_id": captchaResp.Data.CaptchaID, "captcha_id": captchaResp.Data.CaptchaID,
"captcha": "1234", // mock模式会自动通过 "captcha_code": "1234",
} }
loginBody, _ := json.Marshal(loginPayload) loginBody, _ := json.Marshal(loginPayload)
@@ -434,11 +442,11 @@ func runGradualTest(config TestConfig, authPool *AuthPool, adminToken *AdminToke
Duration time.Duration Duration time.Duration
Concurrency int Concurrency int
}{ }{
{30 * time.Second, 10}, // 预热 {30 * time.Second, 10}, // 预热
{60 * time.Second, config.Concurrency / 4}, // 25%负载 {60 * time.Second, config.Concurrency / 4}, // 25%负载
{60 * time.Second, config.Concurrency / 2}, // 50%负载 {60 * time.Second, config.Concurrency / 2}, // 50%负载
{60 * time.Second, config.Concurrency}, // 100%负载 {60 * time.Second, config.Concurrency}, // 100%负载
{30 * time.Second, config.Concurrency * 2}, // 峰值负载 {30 * time.Second, config.Concurrency * 2}, // 峰值负载
} }
result := &TestResult{ result := &TestResult{