加固后台管理安全

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
@@ -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 导出支付配置备份。