package middleware import ( "bytes" "crypto/hmac" "crypto/sha256" "encoding/hex" "io" "sort" "strconv" "strings" "sync" "time" "affiliate_dash/internal/pkg/openlog" "affiliate_dash/internal/pkg/response" "github.com/gin-gonic/gin" ) // OpenAuthConfig 开放接口鉴权配置 type OpenAuthConfig struct { APIKey string APISecret string // 允许的时间偏差(秒),默认 300 SkewSeconds int64 // Debug 详细日志 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)),小写十六进制。 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") 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)) 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 之间") c.Abort() return } ts, err := strconv.ParseInt(timestamp, 10, 64) 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, "请求已过期或时间偏差过大") 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) 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() // 请求结束后补一条总耗时(handler 里会打业务结果) openlog.Info(c, "request done status=%d cost=%s", c.Writer.Status(), openlog.Elapsed(c).Round(time.Microsecond)) } } // 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, "&") } 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 { return hmacSHA256Hex(apiSecret, BuildSignString(apiKey, timestamp, nonce, method, path, body)) }