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 }