Files
affiliate_dash/backend/internal/middleware/open_auth.go
T
yml2213 881ef40fb7 refactor(openapi): 客户端签名统一为字典序+&拼接风格
- BuildOpenV1Sign 改为 app_key/body_sha256/method/nonce/path/timestamp
  按 ASCII 字典序用 & 拼接,与上游 BuildSignString 风格一致
- body 以 SHA256 摘要参与签名,避免大 body 与特殊字符问题
- 同步更新中间件调用点、测试用例与前端鉴权文档
2026-07-30 15:53:09 +08:00

213 lines
6.1 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"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"strconv"
"strings"
"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"
)
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);
// 上游发货接口独立使用 SourceOpenAuthapi_key + 原始 body)。
func OpenAuth(cfg OpenAuthConfig) gin.HandlerFunc {
if cfg.SkewSeconds <= 0 {
cfg.SkewSeconds = 300
}
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.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
}
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
response.BadRequest(c, "读取请求体失败")
c.Abort()
return
}
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
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 client.ExpiresAt != nil && client.ExpiresAt.Before(time.Now()) {
response.Unauthorized(c, "API Key 已过期")
c.Abort()
return
}
secret, err := cfg.Codec.Decrypt(client.SecretCiphertext)
if err != nil {
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 !hmac.Equal([]byte(strings.ToLower(sign)), []byte(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 {
response.ServerError(c, "记录请求 nonce 失败")
c.Abort()
return
}
if created.RowsAffected == 0 {
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
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 {
bodyHash := sha256.Sum256(body)
content := strings.Join([]string{
"app_key=" + appKey,
"body_sha256=" + hex.EncodeToString(bodyHash[:]),
"method=" + strings.ToUpper(method),
"nonce=" + nonce,
"path=" + path,
"timestamp=" + timestamp,
}, "&")
return 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 hmacSHA256Hex(apiSecret, BuildSignString(apiKey, timestamp, nonce, method, path, body))
}
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(value int64) int64 {
if value < 0 {
return -value
}
return value
}