加固后台管理安全

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,
+74 -19
View File
@@ -1,23 +1,31 @@
package config
import (
"errors"
"os"
"strconv"
"strings"
)
type Config struct {
AppEnv string
AppAddr string
MySQLDSN string
RedisAddr string
RedisPassword string
RedisDB int
JWTSecret string
Storage StorageConfig
SMS SMSConfig
Realname RealnameConfig
Log LogConfig
RateLimit RateLimitConfig
AppEnv string
AppAddr string
MySQLDSN string
RedisAddr string
RedisPassword string
RedisDB int
JWTSecret string
PaymentConfigEncryptionKey string
ExternalUploadSecret string
ExternalUploadAllowedIPs []string
BootstrapAdminUsername string
BootstrapAdminPassword string
BootstrapAdminNickname string
Storage StorageConfig
SMS SMSConfig
Realname RealnameConfig
Log LogConfig
RateLimit RateLimitConfig
}
type StorageConfig struct {
@@ -56,13 +64,19 @@ type RateLimitConfig struct {
func Load() Config {
return Config{
AppEnv: getEnv("APP_ENV", "development"),
AppAddr: getEnv("APP_ADDR", ":8080"),
MySQLDSN: getEnv("MYSQL_DSN", "hfb:secret@tcp(127.0.0.1:3306)/hfb_sys?charset=utf8mb4&parseTime=True&loc=Local"),
RedisAddr: getEnv("REDIS_ADDR", "127.0.0.1:6379"),
RedisPassword: getEnv("REDIS_PASSWORD", ""),
RedisDB: getEnvInt("REDIS_DB", 0),
JWTSecret: getEnv("JWT_SECRET", "change-me"),
AppEnv: getEnv("APP_ENV", "development"),
AppAddr: getEnv("APP_ADDR", ":8080"),
MySQLDSN: getEnv("MYSQL_DSN", "hfb:secret@tcp(127.0.0.1:3306)/hfb_sys?charset=utf8mb4&parseTime=True&loc=Local"),
RedisAddr: getEnv("REDIS_ADDR", "127.0.0.1:6379"),
RedisPassword: getEnv("REDIS_PASSWORD", ""),
RedisDB: getEnvInt("REDIS_DB", 0),
JWTSecret: getEnv("JWT_SECRET", "change-me"),
PaymentConfigEncryptionKey: getEnv("PAYMENT_CONFIG_ENCRYPTION_KEY", ""),
ExternalUploadSecret: getEnv("EXTERNAL_UPLOAD_SECRET", ""),
ExternalUploadAllowedIPs: getEnvList("EXTERNAL_UPLOAD_ALLOWED_IPS"),
BootstrapAdminUsername: getEnv("ADMIN_BOOTSTRAP_USERNAME", ""),
BootstrapAdminPassword: getEnv("ADMIN_BOOTSTRAP_PASSWORD", ""),
BootstrapAdminNickname: getEnv("ADMIN_BOOTSTRAP_NICKNAME", "超级管理员"),
Storage: StorageConfig{
Endpoint: getEnv("STORAGE_ENDPOINT", "http://localhost:9000"),
Bucket: getEnv("STORAGE_BUCKET", "hfb-sys"),
@@ -95,6 +109,26 @@ func Load() Config {
}
}
func (c Config) ValidateProductionSecurity() error {
if strings.ToLower(strings.TrimSpace(c.AppEnv)) != "production" {
return nil
}
if strings.TrimSpace(c.JWTSecret) == "" || isPlaceholder(c.JWTSecret) || len([]byte(c.JWTSecret)) < 32 {
return errors.New("JWT_SECRET must be a non-default random value of at least 32 bytes in production")
}
keyLen := len([]byte(c.PaymentConfigEncryptionKey))
if isPlaceholder(c.PaymentConfigEncryptionKey) || (keyLen != 16 && keyLen != 24 && keyLen != 32) {
return errors.New("PAYMENT_CONFIG_ENCRYPTION_KEY must be 16, 24, or 32 bytes in production")
}
if strings.TrimSpace(c.ExternalUploadSecret) == "" || isPlaceholder(c.ExternalUploadSecret) {
return errors.New("EXTERNAL_UPLOAD_SECRET is required in production")
}
if c.BootstrapAdminPassword != "" && isPlaceholder(c.BootstrapAdminPassword) {
return errors.New("ADMIN_BOOTSTRAP_PASSWORD must not use the example placeholder in production")
}
return nil
}
func getEnv(key, fallback string) string {
value := os.Getenv(key)
if value == "" {
@@ -126,3 +160,24 @@ func getEnvBool(key string, fallback bool) bool {
}
return parsed
}
func getEnvList(key string) []string {
raw := os.Getenv(key)
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
values := make([]string, 0, len(parts))
for _, part := range parts {
value := strings.TrimSpace(part)
if value != "" {
values = append(values, value)
}
}
return values
}
func isPlaceholder(value string) bool {
normalized := strings.ToLower(strings.TrimSpace(value))
return normalized == "change-me" || strings.HasPrefix(normalized, "change-") || strings.Contains(normalized, "change-to-")
}
+66 -8
View File
@@ -1,6 +1,8 @@
package middleware
import (
"context"
"net/http"
"strings"
"hfb_sys/backend/internal/modules/auth"
@@ -10,18 +12,34 @@ import (
)
const (
ContextUserID = "user_id"
ContextPhone = "phone"
ContextAdminID = "admin_id"
ContextUsername = "username"
ContextUserID = "user_id"
ContextPhone = "phone"
ContextAdminID = "admin_id"
ContextUsername = "username"
ContextPasswordMustChange = "password_must_change"
AdminAccessCookieName = "hfb_admin_access"
)
func extractToken(c *gin.Context) string {
type AdminTokenContext struct {
Username string
PasswordMustChange bool
}
type AdminTokenValidatorFunc func(ctx context.Context, adminID uint64, tokenVersion int64) (AdminTokenContext, error)
func extractBearerToken(c *gin.Context) string {
header := c.GetHeader("Authorization")
tokenText := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
if tokenText != "" && tokenText != header {
return tokenText
}
return ""
}
func extractToken(c *gin.Context) string {
if tokenText := extractBearerToken(c); tokenText != "" {
return tokenText
}
return c.Query("token")
}
@@ -47,9 +65,14 @@ func Auth(jwtManager *auth.JWTManager) gin.HandlerFunc {
}
}
func AdminAuth(jwtManager *auth.JWTManager) gin.HandlerFunc {
func AdminAuth(jwtManager *auth.JWTManager, validate AdminTokenValidatorFunc) gin.HandlerFunc {
return func(c *gin.Context) {
tokenText := extractToken(c)
tokenText := extractBearerToken(c)
if tokenText == "" {
if cookieToken, err := c.Cookie(AdminAccessCookieName); err == nil {
tokenText = strings.TrimSpace(cookieToken)
}
}
if tokenText == "" {
response.Unauthorized(c, "缺少后台访问令牌")
c.Abort()
@@ -62,9 +85,44 @@ func AdminAuth(jwtManager *auth.JWTManager) gin.HandlerFunc {
c.Abort()
return
}
username := claims.Phone
passwordMustChange := false
if validate != nil {
tokenContext, err := validate(c.Request.Context(), claims.UserID, claims.TokenVersion)
if err != nil {
response.Unauthorized(c, "后台访问令牌无效或已过期")
c.Abort()
return
}
username = tokenContext.Username
passwordMustChange = tokenContext.PasswordMustChange
}
c.Set(ContextAdminID, claims.UserID)
c.Set(ContextUsername, claims.Phone)
c.Set(ContextUsername, username)
c.Set(ContextPasswordMustChange, passwordMustChange)
c.Next()
}
}
func RequireAdminPasswordChanged() gin.HandlerFunc {
allowed := map[string]bool{
"/api/admin/me": true,
"/api/admin/auth/logout": true,
"/api/admin/admin-users/me/password": true,
}
return func(c *gin.Context) {
value, ok := c.Get(ContextPasswordMustChange)
if !ok {
c.Next()
return
}
mustChange, ok := value.(bool)
if !ok || !mustChange || allowed[c.FullPath()] {
c.Next()
return
}
response.Error(c, http.StatusForbidden, "password_must_change", "请先修改初始密码")
c.Abort()
}
}
+46 -28
View File
@@ -16,38 +16,56 @@ import (
// 超级管理员(拥有 super_admin 角色的管理员)自动放行。
func RequirePermission(permCode string, rdb *redis.Client) gin.HandlerFunc {
return func(c *gin.Context) {
value, ok := c.Get(ContextAdminID)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
c.Abort()
return
if checkPermission(c, permCode, rdb) {
c.Next()
}
adminID, ok := value.(uint64)
if !ok {
response.Unauthorized(c, "管理员上下文无效")
c.Abort()
return
}
codes, err := getPermCodes(c, rdb, adminID)
if err != nil {
response.Error(c, http.StatusInternalServerError, "perm_check_failed", "权限校验服务暂时不可用")
c.Abort()
return
}
for _, code := range codes {
if code == permCode || code == "*" {
c.Next()
return
}
}
response.Error(c, http.StatusForbidden, "forbidden", "没有操作权限")
c.Abort()
}
}
func RequirePermissionIf(permCode string, rdb *redis.Client, predicate func(*gin.Context) bool) gin.HandlerFunc {
return func(c *gin.Context) {
if predicate == nil || !predicate(c) {
c.Next()
return
}
if checkPermission(c, permCode, rdb) {
c.Next()
}
}
}
func checkPermission(c *gin.Context, permCode string, rdb *redis.Client) bool {
value, ok := c.Get(ContextAdminID)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
c.Abort()
return false
}
adminID, ok := value.(uint64)
if !ok {
response.Unauthorized(c, "管理员上下文无效")
c.Abort()
return false
}
codes, err := getPermCodes(c, rdb, adminID)
if err != nil {
response.Error(c, http.StatusInternalServerError, "perm_check_failed", "权限校验服务暂时不可用")
c.Abort()
return false
}
for _, code := range codes {
if code == permCode || code == "*" {
return true
}
}
response.Error(c, http.StatusForbidden, "forbidden", "没有操作权限")
c.Abort()
return false
}
func getPermCodes(c *gin.Context, rdb *redis.Client, adminID uint64) ([]string, error) {
if rdb == nil {
return nil, errors.New("redis unavailable")
+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": "请求过于频繁,请稍后再试",
})
}
+11 -9
View File
@@ -3,15 +3,17 @@ package model
import "time"
type AdminUser struct {
ID uint64 `gorm:"primaryKey" json:"id"`
Username string `gorm:"size:64;not null;uniqueIndex" json:"username"`
PasswordHash string `gorm:"size:255;not null" json:"-"`
Nickname string `gorm:"size:64;not null;default:''" json:"nickname"`
Status string `gorm:"size:32;not null;default:'active'" json:"status"`
SupportStatus string `gorm:"size:16;not null;default:'offline';index" json:"support_status"`
LastLoginAt *time.Time `json:"last_login_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint64 `gorm:"primaryKey" json:"id"`
Username string `gorm:"size:64;not null;uniqueIndex" json:"username"`
PasswordHash string `gorm:"size:255;not null" json:"-"`
Nickname string `gorm:"size:64;not null;default:''" json:"nickname"`
Status string `gorm:"size:32;not null;default:'active'" json:"status"`
SupportStatus string `gorm:"size:16;not null;default:'offline';index" json:"support_status"`
TokenVersion int64 `gorm:"not null;default:1" json:"-"`
PasswordMustChange bool `gorm:"not null;default:false" json:"password_must_change"`
LastLoginAt *time.Time `json:"last_login_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (AdminUser) TableName() string {
@@ -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 {
ID uint64 `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Status string `json:"status"`
SupportStatus string `json:"support_status"`
Roles []RoleDTO `json:"roles"`
Permissions []string `json:"permissions"`
LastLoginAt *time.Time `json:"last_login_at"`
ID uint64 `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Status string `json:"status"`
SupportStatus string `json:"support_status"`
PasswordMustChange bool `json:"password_must_change"`
Roles []RoleDTO `json:"roles"`
Permissions []string `json:"permissions"`
LastLoginAt *time.Time `json:"last_login_at"`
}
type RoleDTO struct {
+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,48 +163,15 @@ func (r *Repository) UpdateSupportStatus(ctx context.Context, adminID uint64, st
Update("support_status", status).Error
}
func (r *Repository) ensureDefaultAdmin(ctx context.Context) error {
var count int64
if err := r.db.WithContext(ctx).Model(&model.AdminUser{}).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
hash, err := bcrypt.GenerateFromPassword([]byte(defaultAdminPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
admin := model.AdminUser{
Username: defaultAdminUsername,
PasswordHash: string(hash),
Nickname: "超级管理员",
Status: "active",
}
if err := r.db.WithContext(ctx).Create(&admin).Error; err != nil {
return err
}
// 自动关联 super_admin 角色
var superAdminRole model.Role
if err := r.db.WithContext(ctx).Where("code = ?", "super_admin").First(&superAdminRole).Error; err == nil {
r.db.WithContext(ctx).Create(&model.AdminUserRole{
AdminID: admin.ID,
RoleID: superAdminRole.ID,
})
}
return nil
}
func toDTO(admin model.AdminUser) AdminDTO {
return AdminDTO{
ID: admin.ID,
Username: admin.Username,
Nickname: admin.Nickname,
Status: admin.Status,
SupportStatus: admin.SupportStatus,
LastLoginAt: admin.LastLoginAt,
ID: admin.ID,
Username: admin.Username,
Nickname: admin.Nickname,
Status: admin.Status,
SupportStatus: admin.SupportStatus,
PasswordMustChange: admin.PasswordMustChange,
LastLoginAt: admin.LastLoginAt,
}
}
@@ -224,6 +218,62 @@ func cachePermissions(ctx context.Context, r *Repository, adminID uint64, permCo
r.redis.Set(ctx, key, string(raw), 2*time.Hour)
}
func (r *Repository) ensureLoginNotLocked(ctx context.Context, username string, clientIP string) error {
if r.redis == nil {
return nil
}
locked, err := r.redis.Exists(ctx, loginLockKey(username, clientIP)).Result()
if err != nil {
return err
}
if locked > 0 {
return ErrLoginLocked
}
return nil
}
func (r *Repository) recordLoginFailure(ctx context.Context, username string, clientIP string) {
if r.redis == nil {
return
}
key := loginFailureKey(username, clientIP)
count, err := r.redis.Incr(ctx, key).Result()
if err != nil {
return
}
if count == 1 {
_ = r.redis.Expire(ctx, key, loginFailureTTL).Err()
}
if count >= loginMaxFailureCount {
_ = r.redis.Set(ctx, loginLockKey(username, clientIP), "1", loginLockTTL).Err()
}
}
func (r *Repository) clearLoginFailures(ctx context.Context, username string, clientIP string) {
if r.redis == nil {
return
}
_ = r.redis.Del(ctx, loginFailureKey(username, clientIP), loginLockKey(username, clientIP)).Err()
}
func loginFailureKey(username string, clientIP string) string {
return "admin:login:fail:" + loginKeyPart(clientIP) + ":" + loginKeyPart(username)
}
func loginLockKey(username string, clientIP string) string {
return "admin:login:lock:" + loginKeyPart(clientIP) + ":" + loginKeyPart(username)
}
func loginKeyPart(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
value = strings.ReplaceAll(value, ":", "_")
value = strings.ReplaceAll(value, "/", "_")
if value == "" {
return "_"
}
return value
}
func captchaKey(id string) string {
return "admin:captcha:" + id
}
+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)
}
+12 -8
View File
@@ -7,14 +7,14 @@ import (
)
type AdminUserDTO struct {
ID uint64 `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Status string `json:"status"`
Roles []adminrole.RoleDTO `json:"roles"`
LastLoginAt *time.Time `json:"last_login_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint64 `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Status string `json:"status"`
Roles []adminrole.RoleDTO `json:"roles"`
LastLoginAt *time.Time `json:"last_login_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type PaginatedResult struct {
@@ -43,3 +43,7 @@ type ChangePasswordRequest struct {
OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required"`
}
type ResetPasswordRequest struct {
NewPassword string `json:"new_password" binding:"required"`
}
+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:
@@ -76,10 +76,12 @@ func (r *Repository) Create(ctx context.Context, req CreateAdminRequest) (*Admin
return nil, err
}
admin := model.AdminUser{
Username: req.Username,
PasswordHash: string(hash),
Nickname: req.Nickname,
Status: "active",
Username: req.Username,
PasswordHash: string(hash),
Nickname: req.Nickname,
Status: "active",
TokenVersion: 1,
PasswordMustChange: true,
}
if err := r.db.WithContext(ctx).Create(&admin).Error; err != nil {
return nil, err
@@ -102,6 +104,12 @@ func (r *Repository) Update(ctx context.Context, id uint64, req UpdateAdminReque
if err := db.Save(&admin).Error; err != nil {
return nil, err
}
if req.Status != "" {
if err := r.bumpTokenVersion(db, id); err != nil {
return nil, err
}
r.invalidatePermCache(ctx, id)
}
return r.FindByID(ctx, id)
}
@@ -150,7 +158,7 @@ func (r *Repository) AssignRoles(ctx context.Context, adminID uint64, roleIDs []
return err
}
}
return nil
return r.bumpTokenVersion(tx, adminID)
})
if err != nil {
return err
@@ -159,7 +167,7 @@ func (r *Repository) AssignRoles(ctx context.Context, adminID uint64, roleIDs []
return nil
}
func (r *Repository) ChangePassword(ctx context.Context, id uint64, oldPwd, newPwd string) error {
func (r *Repository) ChangeOwnPassword(ctx context.Context, id uint64, oldPwd, newPwd string) error {
var admin model.AdminUser
if err := r.db.WithContext(ctx).First(&admin, id).Error; err != nil {
return err
@@ -171,7 +179,35 @@ func (r *Repository) ChangePassword(ctx context.Context, id uint64, oldPwd, newP
if err != nil {
return err
}
return r.db.WithContext(ctx).Model(&admin).Update("password_hash", string(hash)).Error
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&admin).Updates(map[string]any{
"password_hash": string(hash),
"password_must_change": false,
}).Error; err != nil {
return err
}
return r.bumpTokenVersion(tx, id)
})
}
func (r *Repository) ResetPassword(ctx context.Context, id uint64, newPwd string) error {
var admin model.AdminUser
if err := r.db.WithContext(ctx).First(&admin, id).Error; err != nil {
return err
}
hash, err := bcrypt.GenerateFromPassword([]byte(newPwd), bcrypt.DefaultCost)
if err != nil {
return err
}
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&admin).Updates(map[string]any{
"password_hash": string(hash),
"password_must_change": true,
}).Error; err != nil {
return err
}
return r.bumpTokenVersion(tx, id)
})
}
func (r *Repository) GetPermissionCodes(ctx context.Context, adminID uint64) ([]string, error) {
@@ -223,6 +259,12 @@ func (r *Repository) invalidatePermCache(ctx context.Context, adminID uint64) {
r.redis.Del(ctx, permCacheKey(adminID))
}
func (r *Repository) bumpTokenVersion(db *gorm.DB, adminID uint64) error {
return db.Model(&model.AdminUser{}).
Where("id = ?", adminID).
UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error
}
func permCacheKey(adminID uint64) string {
return fmt.Sprintf("admin:perms:%d", adminID)
}
+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
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 {
+17 -11
View File
@@ -22,10 +22,11 @@ type JWTManager struct {
}
type Claims struct {
UserID uint64 `json:"uid"`
Phone string `json:"phone"`
TokenType string `json:"typ"`
SubjectType string `json:"sub_type"`
UserID uint64 `json:"uid"`
Phone string `json:"phone"`
TokenType string `json:"typ"`
SubjectType string `json:"sub_type"`
TokenVersion int64 `json:"ver,omitempty"`
jwt.RegisteredClaims
}
@@ -50,11 +51,15 @@ func (m *JWTManager) GeneratePair(userID uint64, phone string) (TokenPair, error
}
func (m *JWTManager) GenerateSubjectPair(userID uint64, subject string, subjectType string) (TokenPair, error) {
accessToken, err := m.generate(userID, subject, subjectType, tokenTypeAccess, m.accessTTL)
return m.GenerateSubjectPairWithVersion(userID, subject, subjectType, 0)
}
func (m *JWTManager) GenerateSubjectPairWithVersion(userID uint64, subject string, subjectType string, tokenVersion int64) (TokenPair, error) {
accessToken, err := m.generate(userID, subject, subjectType, tokenTypeAccess, tokenVersion, m.accessTTL)
if err != nil {
return TokenPair{}, err
}
refreshToken, err := m.generate(userID, subject, subjectType, tokenTypeRefresh, m.refreshTTL)
refreshToken, err := m.generate(userID, subject, subjectType, tokenTypeRefresh, tokenVersion, m.refreshTTL)
if err != nil {
return TokenPair{}, err
}
@@ -90,13 +95,14 @@ func (m *JWTManager) ParseSubject(tokenText, expectedType string, expectedSubjec
return claims, nil
}
func (m *JWTManager) generate(userID uint64, subject string, subjectType string, tokenType string, ttl time.Duration) (string, error) {
func (m *JWTManager) generate(userID uint64, subject string, subjectType string, tokenType string, tokenVersion int64, ttl time.Duration) (string, error) {
now := time.Now()
claims := Claims{
UserID: userID,
Phone: subject,
TokenType: tokenType,
SubjectType: subjectType,
UserID: userID,
Phone: subject,
TokenType: tokenType,
SubjectType: subjectType,
TokenVersion: tokenVersion,
RegisteredClaims: jwt.RegisteredClaims{
Subject: subject,
IssuedAt: jwt.NewNumericDate(now),
+16 -4
View File
@@ -5,12 +5,24 @@ import (
)
type Handler struct {
service *Service
storage *filemodule.Storage
service *Service
storage *filemodule.Storage
externalUploadSecret string
externalUploadAllowedIPs []string
}
const maxExternalUploadBodyBytes = 256 * 1024
func NewHandler(service *Service, storage *filemodule.Storage) *Handler {
return &Handler{service: service, storage: storage}
type HandlerOptions struct {
ExternalUploadSecret string
ExternalUploadAllowedIPs []string
}
func NewHandler(service *Service, storage *filemodule.Storage, opts ...HandlerOptions) *Handler {
handler := &Handler{service: service, storage: storage}
if len(opts) > 0 {
handler.externalUploadSecret = opts[0].ExternalUploadSecret
handler.externalUploadAllowedIPs = opts[0].ExternalUploadAllowedIPs
}
return handler
}
@@ -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