Files
affiliate_dash/backend/internal/middleware/source_open_auth.go
T
yml2213 a47b61a98d refactor(sign): 抽取公共签名原语 signutil,三套签名串格式保持不变
商户侧 /api/client/v1、源头侧 /api/open/v1 与乐玩开放平台各有一套签名实现,
其中语义完全一致的部分重复了多份,任一处修复都容易漏改另外两处。

- 新增 internal/pkg/signutil,只收敛四类一致性原语:HMAC-SHA256→hex、
  body SHA256 摘要、时间戳偏差校验、常数时间比较
- 中间件与 lewan 改为调用该包,删除 middleware 里重复的 hmacSHA256Hex 与 abs64
- 刻意不统一「签名串构造」:三者拼接方式是对外契约且互不相同
  (商户:字典序 & + body 摘要;源头:字典序 & + body 原文;乐玩:换行 6 行),
  强行合并会直接改变握手结果
- 保留各处原有细节语义:商户侧十六进制签名大小写不敏感、乐玩签名原样比较、
  VerifyWebhook 在 allowedSkew<=0 时仍要求时间戳可解析

验证:三套方案既有端到端测试全部通过(含乐玩官方测试向量、大写签名、重放拒绝);
新增 signutil 已知向量与偏差/比较边界测试,以及 VerifyWebhook 边界语义测试。
2026-10-06 19:53:55 +08:00

126 lines
4.0 KiB
Go

package middleware
import (
"bytes"
"io"
"time"
"affiliate_dash/internal/model"
"affiliate_dash/internal/pkg/openlog"
"affiliate_dash/internal/pkg/response"
"affiliate_dash/internal/pkg/signutil"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// SourceOpenAuthConfig 是原上游发货接口鉴权配置,保持 X-Api-Key 兼容。
type SourceOpenAuthConfig struct {
DB *gorm.DB
APIKey string
APISecret string
SkewSeconds int64
Debug bool
}
// SourceOpenAuth 保持现有上游对接签名算法不变:X-Api-Key + 字典序 HMAC。
func SourceOpenAuth(cfg SourceOpenAuthConfig) gin.HandlerFunc {
if cfg.SkewSeconds <= 0 {
cfg.SkewSeconds = 300
}
return func(c *gin.Context) {
reqID := openlog.EnsureReqID(c)
side, action := openlog.ScopeFromPath(openlog.SideSource, c.Request.Method, c.Request.URL.Path)
openlog.SetScope(c, side, action)
c.Set(openlog.CtxDebug, cfg.Debug)
c.Set(openlog.CtxStart, time.Now())
c.Header("X-Request-Id", reqID)
if cfg.DB == nil {
openlog.Warn(c, "source_open_auth uninitialized_db")
response.ServerError(c, "源头开放接口认证服务未初始化")
c.Abort()
return
}
if cfg.APIKey == "" || cfg.APISecret == "" {
openlog.Warn(c, "source_open_auth uninitialized")
response.ServerError(c, "服务端未配置 OPEN_API_KEY / OPEN_API_SECRET")
c.Abort()
return
}
apiKey := c.GetHeader("X-Api-Key")
timestamp := c.GetHeader("X-Timestamp")
nonce := c.GetHeader("X-Nonce")
sign := c.GetHeader("X-Sign")
if apiKey == "" || timestamp == "" || nonce == "" || sign == "" {
openlog.Warn(c, "source_open_auth missing_headers")
response.Unauthorized(c, "缺少鉴权头:需要 X-Api-Key、X-Timestamp、X-Nonce、X-Sign")
c.Abort()
return
}
if apiKey != cfg.APIKey {
openlog.Warn(c, "source_open_auth invalid_key api_key=%s", openlog.MaskKey(apiKey))
response.Unauthorized(c, "无效的 API Key")
c.Abort()
return
}
if len(nonce) < 8 || len(nonce) > 64 {
openlog.Warn(c, "source_open_auth bad_nonce_len len=%d", len(nonce))
response.Unauthorized(c, "X-Nonce 长度需在 8~64 之间")
c.Abort()
return
}
if !signutil.TimestampWithinSkew(timestamp, time.Duration(cfg.SkewSeconds)*time.Second) {
openlog.Warn(c, "source_open_auth expired ts=%s skew=%d", timestamp, cfg.SkewSeconds)
response.Unauthorized(c, "请求已过期或 X-Timestamp 格式错误")
c.Abort()
return
}
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
openlog.Warn(c, "source_open_auth read_body_fail err=%v", err)
response.BadRequest(c, "读取请求体失败")
c.Abort()
return
}
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
c.Set(openlog.CtxBody, string(bodyBytes))
expected := BuildOpenSign(apiKey, cfg.APISecret, timestamp, nonce, c.Request.Method, c.Request.URL.Path, string(bodyBytes))
if !signutil.EqualHexSignatures(sign, expected) {
openlog.Warn(c, "source_open_auth sign_mismatch method=%s path=%s body=%s sign=%s expected=%s",
c.Request.Method, c.Request.URL.Path, openlog.Truncate(string(bodyBytes), 200),
openlog.MaskSign(sign), openlog.MaskSign(expected))
response.Unauthorized(c, "签名校验失败")
c.Abort()
return
}
now := time.Now()
_ = cfg.DB.Where("expires_at < ?", now).Delete(&model.SourceAPINonce{}).Error
created := cfg.DB.Clauses(clause.OnConflict{DoNothing: true}).Create(&model.SourceAPINonce{
APIKey: apiKey,
Nonce: nonce,
ExpiresAt: now.Add(time.Duration(cfg.SkewSeconds) * time.Second),
})
if created.Error != nil {
openlog.Warn(c, "source_open_auth nonce_db_fail err=%v", created.Error)
response.ServerError(c, "记录请求 nonce 失败")
c.Abort()
return
}
if created.RowsAffected == 0 {
openlog.Warn(c, "source_open_auth nonce_replay nonce=%s", nonce)
response.Unauthorized(c, "重复的 X-Nonce(请勿重放请求)")
c.Abort()
return
}
openlog.Debug(c, "source_open_auth ok method=%s path=%s body_size=%d",
c.Request.Method, c.Request.URL.Path, len(bodyBytes))
c.Next()
}
}