Files
affiliate_dash/backend/internal/middleware/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

215 lines
7.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package middleware
import (
"bytes"
"io"
"strings"
"time"
"affiliate_dash/internal/model"
"affiliate_dash/internal/pkg/openlog"
"affiliate_dash/internal/pkg/response"
"affiliate_dash/internal/pkg/signutil"
"affiliate_dash/internal/service"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
CtxAPIClient = "api_client"
CtxAPIClientID = "api_client_id"
CtxMerchantID = "merchant_id"
)
// OpenAuthConfig 配置数据库化的开放接口认证。
type OpenAuthConfig struct {
DB *gorm.DB
Codec *service.SecretCodec
SkewSeconds int64
Debug bool
}
// OpenAuth 校验独立 API 客户端、时间戳、持久化 nonce 与 HMAC 签名。
// 客户侧接口使用 X-App-Key + 字典序 & 拼接签名(app_key/body_sha256/method/nonce/path/timestamp);
// 上游发货接口独立使用 SourceOpenAuth(api_key + 原始 body)。
func OpenAuth(cfg OpenAuthConfig) gin.HandlerFunc {
if cfg.SkewSeconds <= 0 {
cfg.SkewSeconds = 300
}
return func(c *gin.Context) {
reqID := openlog.EnsureReqID(c)
side, action := openlog.ScopeFromPath(openlog.SideClient, 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 || cfg.Codec == nil {
openlog.Warn(c, "open_auth uninitialized")
response.ServerError(c, "开放接口认证服务未初始化")
c.Abort()
return
}
appKey := c.GetHeader("X-App-Key")
timestamp := c.GetHeader("X-Timestamp")
nonce := c.GetHeader("X-Nonce")
sign := c.GetHeader("X-Sign")
if appKey == "" || timestamp == "" || nonce == "" || sign == "" {
openlog.Warn(c, "open_auth missing_headers app_key=%s", openlog.MaskKey(appKey))
response.Unauthorized(c, "缺少鉴权头:X-App-Key、X-Timestamp、X-Nonce、X-Sign")
c.Abort()
return
}
if len(nonce) < 8 || len(nonce) > 96 {
openlog.Warn(c, "open_auth bad_nonce_len len=%d app_key=%s", len(nonce), openlog.MaskKey(appKey))
response.Unauthorized(c, "X-Nonce 长度需在 8~96 之间")
c.Abort()
return
}
// 时间戳非法或超出偏差窗口都视为过期;解析与偏差判断统一由 signutil 负责。
if !signutil.TimestampWithinSkew(timestamp, time.Duration(cfg.SkewSeconds)*time.Second) {
openlog.Warn(c, "open_auth expired app_key=%s ts=%s skew=%d", openlog.MaskKey(appKey), timestamp, cfg.SkewSeconds)
response.Unauthorized(c, "请求已过期或 X-Timestamp 格式错误")
c.Abort()
return
}
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
openlog.Warn(c, "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))
var client model.APIClient
if err := cfg.DB.Where("app_key = ? AND status = ?", appKey, model.APIClientStatusActive).First(&client).Error; err != nil {
openlog.Warn(c, "open_auth invalid_key app_key=%s", openlog.MaskKey(appKey))
response.Unauthorized(c, "无效的 API Key")
c.Abort()
return
}
if client.ExpiresAt != nil && client.ExpiresAt.Before(time.Now()) {
openlog.Warn(c, "open_auth key_expired app_key=%s client_id=%d", openlog.MaskKey(appKey), client.ID)
response.Unauthorized(c, "API Key 已过期")
c.Abort()
return
}
secret, err := cfg.Codec.Decrypt(client.SecretCiphertext)
if err != nil {
openlog.Warn(c, "open_auth decrypt_fail app_key=%s client_id=%d err=%v", openlog.MaskKey(appKey), client.ID, err)
response.ServerError(c, "API 客户端密钥不可用")
c.Abort()
return
}
method := strings.ToUpper(c.Request.Method)
path := c.Request.URL.Path
expected := BuildOpenV1Sign(secret, appKey, timestamp, nonce, method, path, bodyBytes)
if !signutil.EqualHexSignatures(sign, expected) {
openlog.Warn(c, "open_auth sign_mismatch app_key=%s method=%s path=%s body_sha256=%s sign=%s expected=%s",
openlog.MaskKey(appKey), method, path, signutil.SHA256Hex(bodyBytes),
openlog.MaskSign(sign), openlog.MaskSign(expected))
response.Unauthorized(c, "签名校验失败")
c.Abort()
return
}
now := time.Now()
nonceTTL := time.Duration(cfg.SkewSeconds) * time.Second
_ = cfg.DB.Where("expires_at < ?", now).Delete(&model.APIRequestNonce{}).Error
nonceRow := model.APIRequestNonce{
APIClientID: client.ID,
Nonce: nonce,
ExpiresAt: now.Add(nonceTTL),
}
created := cfg.DB.Clauses(clause.OnConflict{DoNothing: true}).Create(&nonceRow)
if created.Error != nil {
openlog.Warn(c, "open_auth nonce_db_fail app_key=%s err=%v", openlog.MaskKey(appKey), created.Error)
response.ServerError(c, "记录请求 nonce 失败")
c.Abort()
return
}
if created.RowsAffected == 0 {
openlog.Warn(c, "open_auth nonce_replay app_key=%s nonce=%s", openlog.MaskKey(appKey), nonce)
response.Unauthorized(c, "重复的 X-Nonce(请勿重放请求)")
c.Abort()
return
}
c.Set(CtxAPIClient, &client)
c.Set(CtxAPIClientID, client.ID)
c.Set(CtxMerchantID, client.MerchantID)
c.Set(openlog.CtxAPIKey, appKey)
_ = cfg.DB.Model(&model.APIClient{}).Where("id = ?", client.ID).Update("last_used_at", now).Error
openlog.Debug(c, "open_auth ok app_key=%s merchant_id=%d method=%s path=%s body_size=%d",
openlog.MaskKey(appKey), client.MerchantID, method, path, len(bodyBytes))
c.Next()
}
}
// RequireAPIScope 要求客户端至少拥有一个指定权限。
func RequireAPIScope(scopes ...string) gin.HandlerFunc {
return func(c *gin.Context) {
client := GetAPIClient(c)
if client == nil || !service.HasAnyScope(client.Scopes, scopes...) {
response.Forbidden(c, "API 客户端权限不足")
c.Abort()
return
}
c.Next()
}
}
func GetAPIClient(c *gin.Context) *model.APIClient {
value, ok := c.Get(CtxAPIClient)
if !ok {
return nil
}
client, _ := value.(*model.APIClient)
return client
}
func GetMerchantID(c *gin.Context) uint {
value, _ := c.Get(CtxMerchantID)
merchantID, _ := value.(uint)
return merchantID
}
// BuildOpenV1Sign 生成客户侧开放接口签名:参数按 ASCII 字典序 + "&" 拼接,
// 与上游 BuildSignString 风格一致;body 以 SHA256 摘要参与签名(避免大 body 与特殊字符问题)。
// 参与签名的参数固定顺序为:app_key, body_sha256, method, nonce, path, timestamp。
func BuildOpenV1Sign(secret, appKey, timestamp, nonce, method, path string, body []byte) string {
content := strings.Join([]string{
"app_key=" + appKey,
"body_sha256=" + signutil.SHA256Hex(body),
"method=" + strings.ToUpper(method),
"nonce=" + nonce,
"path=" + path,
"timestamp=" + timestamp,
}, "&")
return signutil.HMACSHA256Hex(secret, content)
}
// BuildSignString 保留旧接口的字典序签名算法,供上游发货兼容客户端和测试使用。
func BuildSignString(apiKey, timestamp, nonce, method, path, body string) string {
return strings.Join([]string{
"api_key=" + apiKey,
"body=" + body,
"method=" + strings.ToUpper(method),
"nonce=" + nonce,
"path=" + path,
"timestamp=" + timestamp,
}, "&")
}
// BuildOpenSign 供旧发货系统兼容使用。
func BuildOpenSign(apiKey, apiSecret, timestamp, nonce, method, path, body string) string {
return signutil.HMACSHA256Hex(apiSecret, BuildSignString(apiKey, timestamp, nonce, method, path, body))
}