Files
affiliate_dash/backend/internal/middleware/open_auth.go
T
yml2213 89cdd32181 对接皮肤源头开放接口:查询、发货推送与 HMAC 签名鉴权
- 新增订单查询与发货结果推送开放接口,支持 can_ship 与幂等
- 鉴权采用 X-Api-Key + Timestamp + Nonce + HMAC-SHA256 签名
- 订单扩展发货字段与 ship_logs,管理端增加发货记录与开放文档页
2026-07-20 16:06:44 +08:00

172 lines
3.9 KiB
Go

package middleware
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"strconv"
"strings"
"sync"
"time"
"affiliate_dash/internal/pkg/response"
"github.com/gin-gonic/gin"
)
// OpenAuthConfig 开放接口鉴权配置
type OpenAuthConfig struct {
APIKey string
APISecret string
// 允许的时间偏差(秒),默认 300
SkewSeconds int64
}
// nonce 防重放(进程内,重启清空;生产可换 Redis)
type nonceStore struct {
mu sync.Mutex
data map[string]int64 // nonce -> expire unix
}
func newNonceStore() *nonceStore {
return &nonceStore{data: make(map[string]int64)}
}
func (s *nonceStore) seen(nonce string, now, ttl int64) bool {
s.mu.Lock()
defer s.mu.Unlock()
// 清理过期
for k, exp := range s.data {
if exp < now {
delete(s.data, k)
}
}
if exp, ok := s.data[nonce]; ok && exp >= now {
return true
}
s.data[nonce] = now + ttl
return false
}
// OpenAuth 校验 X-Api-Key + 时间戳 + nonce + HMAC-SHA256 签名
//
// 待签名字符串(UTF-8,\n 换行):
//
// {apiKey}\n{timestamp}\n{nonce}\n{METHOD}\n{path}\n{body}
//
// path 为 URL.Path(不含 query),METHOD 大写;GET 时 body 为空字符串。
// sign = hex(hmac_sha256(apiSecret, stringToSign)),小写十六进制。
func OpenAuth(cfg OpenAuthConfig) gin.HandlerFunc {
if cfg.SkewSeconds <= 0 {
cfg.SkewSeconds = 300
}
store := newNonceStore()
return func(c *gin.Context) {
if cfg.APIKey == "" || cfg.APISecret == "" {
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 == "" {
response.Unauthorized(c, "缺少鉴权头:需要 X-Api-Key、X-Timestamp、X-Nonce、X-Sign")
c.Abort()
return
}
if apiKey != cfg.APIKey {
response.Unauthorized(c, "无效的 API Key")
c.Abort()
return
}
if len(nonce) < 8 || len(nonce) > 64 {
response.Unauthorized(c, "X-Nonce 长度需在 8~64 之间")
c.Abort()
return
}
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
response.Unauthorized(c, "X-Timestamp 格式错误,需为 Unix 秒级时间戳")
c.Abort()
return
}
now := time.Now().Unix()
if abs64(now-ts) > cfg.SkewSeconds {
response.Unauthorized(c, "请求已过期或时间偏差过大")
c.Abort()
return
}
// 防重放:同一 nonce 在时间窗口内只能用一次
if store.seen(apiKey+":"+nonce, now, cfg.SkewSeconds) {
response.Unauthorized(c, "重复的 X-Nonce(请勿重放请求)")
c.Abort()
return
}
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
response.BadRequest(c, "读取请求体失败")
c.Abort()
return
}
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
method := strings.ToUpper(c.Request.Method)
path := c.Request.URL.Path
body := string(bodyBytes)
stringToSign := strings.Join([]string{
apiKey,
timestamp,
nonce,
method,
path,
body,
}, "\n")
expected := hmacSHA256Hex(cfg.APISecret, stringToSign)
if !hmac.Equal([]byte(strings.ToLower(sign)), []byte(expected)) {
response.Unauthorized(c, "签名校验失败")
c.Abort()
return
}
c.Next()
}
}
func hmacSHA256Hex(secret, content string) string {
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(content))
return hex.EncodeToString(mac.Sum(nil))
}
func abs64(v int64) int64 {
if v < 0 {
return -v
}
return v
}
// BuildOpenSign 供测试或内部生成签名(与 OpenAuth 规则一致)
func BuildOpenSign(apiKey, apiSecret, timestamp, nonce, method, path, body string) string {
stringToSign := strings.Join([]string{
apiKey,
timestamp,
nonce,
strings.ToUpper(method),
path,
body,
}, "\n")
return hmacSHA256Hex(apiSecret, stringToSign)
}