实现多商户履约平台基础
This commit is contained in:
@@ -5,14 +5,16 @@ import (
|
||||
|
||||
"affiliate_dash/internal/pkg/jwt"
|
||||
"affiliate_dash/internal/pkg/response"
|
||||
"affiliate_dash/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
CtxUserID = "user_id"
|
||||
CtxUsername = "username"
|
||||
CtxRole = "role"
|
||||
CtxUserID = "user_id"
|
||||
CtxUsername = "username"
|
||||
CtxRole = "role"
|
||||
CtxMerchantRole = "merchant_role"
|
||||
)
|
||||
|
||||
func Auth(jm *jwt.Manager) gin.HandlerFunc {
|
||||
@@ -70,3 +72,42 @@ func GetRole(c *gin.Context) string {
|
||||
role, _ := v.(string)
|
||||
return role
|
||||
}
|
||||
|
||||
// Tenant 根据 X-Merchant-ID(商户 ID 或编码)解析当前后台请求所属商户。
|
||||
// 未指定时选择该账号的默认商户,确保旧后台继续落到“自营商户”。
|
||||
func Tenant(tenantSvc *service.TenantService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
member, err := tenantSvc.ResolveMember(GetUserID(c), c.GetHeader("X-Merchant-ID"))
|
||||
if err != nil {
|
||||
response.Forbidden(c, err.Error())
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set(CtxMerchantID, member.MerchantID)
|
||||
c.Set(CtxMerchantRole, member.Role)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func RequireMerchantRole(roles ...string) gin.HandlerFunc {
|
||||
allowed := make(map[string]struct{}, len(roles))
|
||||
for _, role := range roles {
|
||||
allowed[role] = struct{}{}
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
value, _ := c.Get(CtxMerchantRole)
|
||||
role, _ := value.(string)
|
||||
if _, ok := allowed[role]; !ok {
|
||||
response.Forbidden(c, "商户权限不足")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func GetMerchantRole(c *gin.Context) string {
|
||||
value, _ := c.Get(CtxMerchantRole)
|
||||
role, _ := value.(string)
|
||||
return role
|
||||
}
|
||||
|
||||
@@ -6,191 +6,192 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"affiliate_dash/internal/model"
|
||||
"affiliate_dash/internal/pkg/openlog"
|
||||
"affiliate_dash/internal/pkg/response"
|
||||
"affiliate_dash/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// OpenAuthConfig 开放接口鉴权配置
|
||||
const (
|
||||
CtxAPIClient = "api_client"
|
||||
CtxAPIClientID = "api_client_id"
|
||||
CtxMerchantID = "merchant_id"
|
||||
)
|
||||
|
||||
// OpenAuthConfig 配置数据库化的开放接口认证。
|
||||
type OpenAuthConfig struct {
|
||||
APIKey string
|
||||
APISecret string
|
||||
// 允许的时间偏差(秒),默认 300
|
||||
DB *gorm.DB
|
||||
Codec *service.SecretCodec
|
||||
SkewSeconds int64
|
||||
// Debug 详细日志
|
||||
Debug bool
|
||||
Debug bool
|
||||
}
|
||||
|
||||
// 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 签名
|
||||
//
|
||||
// 待签名参数(value 原样,不做 URL encode):
|
||||
//
|
||||
// api_key, body, method, nonce, path, timestamp
|
||||
//
|
||||
// 按 key 字典序排序后拼接 k1=v1&k2=v2&...
|
||||
// method 大写;path 为 URL.Path(不含 query);GET 时 body 为空串。
|
||||
// sign = hex(hmac_sha256(apiSecret, stringToSign)),小写十六进制。
|
||||
// OpenAuth 校验独立 API 客户端、时间戳、持久化 nonce 与 HMAC 签名。
|
||||
// 客户侧新接口只使用 X-App-Key 和 body SHA256;上游发货接口独立使用 SourceOpenAuth。
|
||||
func OpenAuth(cfg OpenAuthConfig) gin.HandlerFunc {
|
||||
if cfg.SkewSeconds <= 0 {
|
||||
cfg.SkewSeconds = 300
|
||||
}
|
||||
store := newNonceStore()
|
||||
|
||||
return func(c *gin.Context) {
|
||||
reqID := openlog.EnsureReqID(c)
|
||||
c.Set(openlog.CtxDebug, cfg.Debug)
|
||||
c.Set(openlog.CtxStart, time.Now())
|
||||
c.Header("X-Request-Id", reqID)
|
||||
|
||||
if cfg.APIKey == "" || cfg.APISecret == "" {
|
||||
openlog.Warn(c, "auth_fail reason=server_not_configured")
|
||||
response.ServerError(c, "服务端未配置 OPEN_API_KEY / OPEN_API_SECRET")
|
||||
if cfg.DB == nil || cfg.Codec == nil {
|
||||
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 == "" {
|
||||
response.Unauthorized(c, "缺少鉴权头:X-App-Key、X-Timestamp、X-Nonce、X-Sign")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if len(nonce) < 8 || len(nonce) > 96 {
|
||||
response.Unauthorized(c, "X-Nonce 长度需在 8~96 之间")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
ts, err := strconv.ParseInt(timestamp, 10, 64)
|
||||
if err != nil || abs64(time.Now().Unix()-ts) > cfg.SkewSeconds {
|
||||
response.Unauthorized(c, "请求已过期或 X-Timestamp 格式错误")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
apiKey := c.GetHeader("X-Api-Key")
|
||||
timestamp := c.GetHeader("X-Timestamp")
|
||||
nonce := c.GetHeader("X-Nonce")
|
||||
sign := c.GetHeader("X-Sign")
|
||||
clientIP := c.ClientIP()
|
||||
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
openlog.Warn(c, "auth_fail reason=read_body_error err=%v ip=%s", err, clientIP)
|
||||
response.BadRequest(c, "读取请求体失败")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||
body := string(bodyBytes)
|
||||
c.Set(openlog.CtxBody, body)
|
||||
|
||||
method := strings.ToUpper(c.Request.Method)
|
||||
path := c.Request.URL.Path
|
||||
|
||||
openlog.Info(c, "request in method=%s path=%s ip=%s key=%s ts=%s nonce=%s sign=%s body=%q body_len=%d",
|
||||
method, path, clientIP,
|
||||
openlog.MaskKey(apiKey), timestamp, nonce, openlog.MaskSign(sign),
|
||||
openlog.Truncate(body, 500), len(bodyBytes),
|
||||
)
|
||||
|
||||
if apiKey == "" || timestamp == "" || nonce == "" || sign == "" {
|
||||
openlog.Warn(c, "auth_fail reason=missing_headers key=%s ts=%s nonce=%s has_sign=%v",
|
||||
openlog.MaskKey(apiKey), timestamp, nonce, sign != "")
|
||||
response.Unauthorized(c, "缺少鉴权头:需要 X-Api-Key、X-Timestamp、X-Nonce、X-Sign")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if apiKey != cfg.APIKey {
|
||||
openlog.Warn(c, "auth_fail reason=invalid_api_key key=%s", openlog.MaskKey(apiKey))
|
||||
var client model.APIClient
|
||||
if err := cfg.DB.Where("app_key = ? AND status = ?", appKey, model.APIClientStatusActive).First(&client).Error; err != nil {
|
||||
response.Unauthorized(c, "无效的 API Key")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if len(nonce) < 8 || len(nonce) > 64 {
|
||||
openlog.Warn(c, "auth_fail reason=bad_nonce_len len=%d", len(nonce))
|
||||
response.Unauthorized(c, "X-Nonce 长度需在 8~64 之间")
|
||||
if client.ExpiresAt != nil && client.ExpiresAt.Before(time.Now()) {
|
||||
response.Unauthorized(c, "API Key 已过期")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
ts, err := strconv.ParseInt(timestamp, 10, 64)
|
||||
secret, err := cfg.Codec.Decrypt(client.SecretCiphertext)
|
||||
if err != nil {
|
||||
openlog.Warn(c, "auth_fail reason=bad_timestamp raw=%s", timestamp)
|
||||
response.Unauthorized(c, "X-Timestamp 格式错误,需为 Unix 秒级时间戳")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
skew := now - ts
|
||||
if abs64(skew) > cfg.SkewSeconds {
|
||||
openlog.Warn(c, "auth_fail reason=timestamp_skew server_now=%d ts=%d skew=%ds limit=%ds",
|
||||
now, ts, skew, cfg.SkewSeconds)
|
||||
response.Unauthorized(c, "请求已过期或时间偏差过大")
|
||||
response.ServerError(c, "API 客户端密钥不可用")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if store.seen(apiKey+":"+nonce, now, cfg.SkewSeconds) {
|
||||
openlog.Warn(c, "auth_fail reason=replay_nonce nonce=%s", nonce)
|
||||
response.Unauthorized(c, "重复的 X-Nonce(请勿重放请求)")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
stringToSign := BuildSignString(apiKey, timestamp, nonce, method, path, body)
|
||||
expected := hmacSHA256Hex(cfg.APISecret, stringToSign)
|
||||
method := strings.ToUpper(c.Request.Method)
|
||||
path := c.Request.URL.Path
|
||||
expected := BuildOpenV1Sign(secret, timestamp, nonce, method, path, bodyBytes)
|
||||
if !hmac.Equal([]byte(strings.ToLower(sign)), []byte(expected)) {
|
||||
// 调试时输出待签名串(不含 secret),便于源头对齐
|
||||
openlog.Warn(c, "auth_fail reason=sign_mismatch sign=%s expected_prefix=%s string_to_sign=%q",
|
||||
openlog.MaskSign(sign), openlog.MaskSign(expected), openlog.Truncate(stringToSign, 800))
|
||||
response.Unauthorized(c, "签名校验失败")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set(openlog.CtxAPIKey, apiKey)
|
||||
openlog.Info(c, "auth_ok skew=%ds string_to_sign_len=%d", skew, len(stringToSign))
|
||||
c.Next()
|
||||
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 {
|
||||
response.ServerError(c, "记录请求 nonce 失败")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if created.RowsAffected == 0 {
|
||||
response.Unauthorized(c, "重复的 X-Nonce(请勿重放请求)")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 请求结束后补一条总耗时(handler 里会打业务结果)
|
||||
openlog.Info(c, "request done status=%d cost=%s", c.Writer.Status(), openlog.Elapsed(c).Round(time.Microsecond))
|
||||
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
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// BuildSignString 生成待签名字符串:参数字典序 + & 拼接
|
||||
// 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 生成新开放接口签名:timestamp、nonce、method、path 与 body SHA256。
|
||||
func BuildOpenV1Sign(secret, timestamp, nonce, method, path string, body []byte) string {
|
||||
bodyHash := sha256.Sum256(body)
|
||||
content := strings.Join([]string{
|
||||
timestamp,
|
||||
nonce,
|
||||
strings.ToUpper(method),
|
||||
path,
|
||||
hex.EncodeToString(bodyHash[:]),
|
||||
}, "\n")
|
||||
return hmacSHA256Hex(secret, content)
|
||||
}
|
||||
|
||||
// BuildSignString 保留旧接口的字典序签名算法,供兼容客户端和测试使用。
|
||||
func BuildSignString(apiKey, timestamp, nonce, method, path, body string) string {
|
||||
params := map[string]string{
|
||||
"api_key": apiKey,
|
||||
"body": body,
|
||||
"method": strings.ToUpper(method),
|
||||
"nonce": nonce,
|
||||
"path": path,
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
keys := make([]string, 0, len(params))
|
||||
for k := range params {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
parts = append(parts, k+"="+params[k])
|
||||
}
|
||||
return strings.Join(parts, "&")
|
||||
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 hmacSHA256Hex(apiSecret, BuildSignString(apiKey, timestamp, nonce, method, path, body))
|
||||
}
|
||||
|
||||
func hmacSHA256Hex(secret, content string) string {
|
||||
@@ -199,14 +200,9 @@ func hmacSHA256Hex(secret, content string) string {
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func abs64(v int64) int64 {
|
||||
if v < 0 {
|
||||
return -v
|
||||
func abs64(value int64) int64 {
|
||||
if value < 0 {
|
||||
return -value
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// BuildOpenSign 供测试或内部生成签名(与 OpenAuth 规则一致)
|
||||
func BuildOpenSign(apiKey, apiSecret, timestamp, nonce, method, path, body string) string {
|
||||
return hmacSHA256Hex(apiSecret, BuildSignString(apiKey, timestamp, nonce, method, path, body))
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"affiliate_dash/internal/model"
|
||||
"affiliate_dash/internal/service"
|
||||
"affiliate_dash/internal/testdb"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func newOpenAuthTestServer(t *testing.T, signatureVersion string) (*gin.Engine, string, string) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := testdb.New(t, &model.Merchant{}, &model.APIClient{}, &model.APIRequestNonce{})
|
||||
codec, err := service.NewSecretCodec("test-master-key")
|
||||
if err != nil {
|
||||
t.Fatalf("codec: %v", err)
|
||||
}
|
||||
merchant := model.Merchant{Code: "merchant-open", Name: "开放测试商户", Status: model.MerchantStatusActive}
|
||||
if err := db.Create(&merchant).Error; err != nil {
|
||||
t.Fatalf("create merchant: %v", err)
|
||||
}
|
||||
secret := "client-secret"
|
||||
ciphertext, err := codec.Encrypt(secret)
|
||||
if err != nil {
|
||||
t.Fatalf("encrypt secret: %v", err)
|
||||
}
|
||||
client := model.APIClient{
|
||||
MerchantID: merchant.ID,
|
||||
Name: "测试客户端",
|
||||
AppKey: "ak_test",
|
||||
SecretCiphertext: ciphertext,
|
||||
SignatureVersion: signatureVersion,
|
||||
Scopes: "*",
|
||||
Status: model.APIClientStatusActive,
|
||||
}
|
||||
if err := db.Create(&client).Error; err != nil {
|
||||
t.Fatalf("create client: %v", err)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.Use(OpenAuth(OpenAuthConfig{
|
||||
DB: db,
|
||||
Codec: codec,
|
||||
SkewSeconds: 300,
|
||||
}))
|
||||
r.POST("/api/client/v1/orders", RequireAPIScope("orders:write"), func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"merchant_id": GetMerchantID(c),
|
||||
"client_id": GetAPIClient(c).ID,
|
||||
})
|
||||
})
|
||||
return r, client.AppKey, secret
|
||||
}
|
||||
|
||||
func TestOpenAuthV1AcceptsSignedRequestAndRejectsReplay(t *testing.T) {
|
||||
r, appKey, secret := newOpenAuthTestServer(t, "v1")
|
||||
body := `{"client_order_no":"client-001","sku":"sku-basic"}`
|
||||
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
nonce := "nonce-123456"
|
||||
path := "/api/client/v1/orders"
|
||||
sign := BuildOpenV1Sign(secret, ts, nonce, http.MethodPost, path, []byte(body))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
|
||||
req.Header.Set("X-App-Key", appKey)
|
||||
req.Header.Set("X-Timestamp", ts)
|
||||
req.Header.Set("X-Nonce", nonce)
|
||||
req.Header.Set("X-Sign", sign)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("signed request should pass, code=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
replay := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
|
||||
replay.Header = req.Header.Clone()
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, replay)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("replay should be rejected, code=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAuthRejectsSignatureMismatch(t *testing.T) {
|
||||
r, appKey, _ := newOpenAuthTestServer(t, "v1")
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/client/v1/orders", strings.NewReader(`{"a":1}`))
|
||||
req.Header.Set("X-App-Key", appKey)
|
||||
req.Header.Set("X-Timestamp", strconv.FormatInt(time.Now().Unix(), 10))
|
||||
req.Header.Set("X-Nonce", "nonce-bad-sign")
|
||||
req.Header.Set("X-Sign", "bad-sign")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("bad signature should be rejected, code=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceOpenAuthKeepsLegacyUpstreamSignature(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
appKey := "source-key"
|
||||
secret := "source-secret"
|
||||
r := gin.New()
|
||||
r.Use(SourceOpenAuth(SourceOpenAuthConfig{
|
||||
APIKey: appKey,
|
||||
APISecret: secret,
|
||||
SkewSeconds: 300,
|
||||
}))
|
||||
r.POST("/api/open/v1/orders/ship-notify", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
body := `{"client_order_no":"client-legacy","sku":"sku-basic"}`
|
||||
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
nonce := "legacy-nonce-123"
|
||||
path := "/api/open/v1/orders/ship-notify"
|
||||
sign := BuildOpenSign(appKey, secret, ts, nonce, http.MethodPost, path, body)
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
|
||||
req.Header.Set("X-Api-Key", appKey)
|
||||
req.Header.Set("X-Timestamp", ts)
|
||||
req.Header.Set("X-Nonce", nonce)
|
||||
req.Header.Set("X-Sign", sign)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("legacy signed request should pass, code=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceOpenAuthAcceptsUppercaseLegacySignature(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
appKey := "source-key"
|
||||
secret := "source-secret"
|
||||
r := gin.New()
|
||||
r.Use(SourceOpenAuth(SourceOpenAuthConfig{
|
||||
APIKey: appKey,
|
||||
APISecret: secret,
|
||||
SkewSeconds: 300,
|
||||
}))
|
||||
r.GET("/api/open/v1/orders/O123", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
nonce := "legacy-nonce-upper"
|
||||
path := "/api/open/v1/orders/O123"
|
||||
sign := strings.ToUpper(BuildOpenSign(appKey, secret, ts, nonce, http.MethodGet, path, ""))
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.Header.Set("X-Api-Key", appKey)
|
||||
req.Header.Set("X-Timestamp", ts)
|
||||
req.Header.Set("X-Nonce", nonce)
|
||||
req.Header.Set("X-Sign", sign)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("uppercase legacy signature should pass, code=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"affiliate_dash/internal/pkg/openlog"
|
||||
"affiliate_dash/internal/pkg/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SourceOpenAuthConfig 是原上游发货接口鉴权配置,保持 X-Api-Key 兼容。
|
||||
type SourceOpenAuthConfig struct {
|
||||
APIKey string
|
||||
APISecret string
|
||||
SkewSeconds int64
|
||||
Debug bool
|
||||
}
|
||||
|
||||
type sourceNonceStore struct {
|
||||
mu sync.Mutex
|
||||
data map[string]int64
|
||||
}
|
||||
|
||||
func newSourceNonceStore() *sourceNonceStore {
|
||||
return &sourceNonceStore{data: make(map[string]int64)}
|
||||
}
|
||||
|
||||
func (s *sourceNonceStore) seen(nonce string, now, ttl int64) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for key, expiresAt := range s.data {
|
||||
if expiresAt < now {
|
||||
delete(s.data, key)
|
||||
}
|
||||
}
|
||||
if expiresAt, ok := s.data[nonce]; ok && expiresAt >= now {
|
||||
return true
|
||||
}
|
||||
s.data[nonce] = now + ttl
|
||||
return false
|
||||
}
|
||||
|
||||
// SourceOpenAuth 保持现有上游对接签名算法不变:X-Api-Key + 字典序 HMAC。
|
||||
func SourceOpenAuth(cfg SourceOpenAuthConfig) gin.HandlerFunc {
|
||||
if cfg.SkewSeconds <= 0 {
|
||||
cfg.SkewSeconds = 300
|
||||
}
|
||||
store := newSourceNonceStore()
|
||||
return func(c *gin.Context) {
|
||||
reqID := openlog.EnsureReqID(c)
|
||||
c.Set(openlog.CtxDebug, cfg.Debug)
|
||||
c.Set(openlog.CtxStart, time.Now())
|
||||
c.Header("X-Request-Id", reqID)
|
||||
|
||||
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 || abs64(time.Now().Unix()-ts) > cfg.SkewSeconds {
|
||||
response.Unauthorized(c, "请求已过期或 X-Timestamp 格式错误")
|
||||
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))
|
||||
|
||||
if store.seen(apiKey+":"+nonce, time.Now().Unix(), cfg.SkewSeconds) {
|
||||
response.Unauthorized(c, "重复的 X-Nonce(请勿重放请求)")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
expected := BuildOpenSign(apiKey, cfg.APISecret, timestamp, nonce, c.Request.Method, c.Request.URL.Path, string(bodyBytes))
|
||||
if !hmac.Equal([]byte(strings.ToLower(sign)), []byte(expected)) {
|
||||
response.Unauthorized(c, "签名校验失败")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user