加固后台管理安全
This commit is contained in:
@@ -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"))
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 导出支付配置备份。
|
||||
|
||||
Reference in New Issue
Block a user