加固后台管理安全

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