232 lines
7.5 KiB
Go
232 lines
7.5 KiB
Go
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);
|
||
// 上游发货接口独立使用 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
|
||
}
|
||
ts, err := strconv.ParseInt(timestamp, 10, 64)
|
||
if err != nil || abs64(time.Now().Unix()-ts) > cfg.SkewSeconds {
|
||
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))
|
||
|
||
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 !hmac.Equal([]byte(strings.ToLower(sign)), []byte(expected)) {
|
||
bodyHash := sha256.Sum256(bodyBytes)
|
||
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, hex.EncodeToString(bodyHash[:]),
|
||
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.Info(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 {
|
||
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
|
||
}
|