签名验证前需缓存请求体以避免IO流消耗,JSON用raw_body验签,表单用PostForm.Encode(),Query用RawQuery,HMAC必须用hmac.Equal恒定时间比对,并严格校验timestamp和nonce防重放。

签名验证前必须确保请求体可重复读取
Gin 默认的 c.Request.Body 是一次性流,调用 c.PostForm 或 c.ShouldBind 后再读原始 body 会得到空内容,导致验签失败。这不是签名逻辑问题,而是 IO 流被消耗了。
解决方法是在中间件中提前缓存 body:
func BodyCacheMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
body, _ := io.ReadAll(c.Request.Body)
c.Request.Body = io.NopCloser(bytes.NewBuffer(body))
c.Set("raw_body", body) // 供后续验签使用
c.Next()
}
}
注意:io.NopCloser 必须显式包装,否则 Gin 会报 http: invalid ReadCloser type;raw_body 要在验签逻辑里通过 c.Get("raw_body") 取出。
验签时别直接用 c.PostForm 获取参数
c.PostForm 只能读表单数据(application/x-www-form-urlencoded),但签名通常要求对原始字节(如 JSON payload)做哈希。如果接口接收的是 JSON,用 c.PostForm 拿不到字段,更无法还原原始序列化结果。
立即学习“go语言免费学习笔记(深入)”;
正确做法分场景:
Go 配置库,使用 spf13/viper — 分层优先级(flag > env >file > KV > default),提供 BindPFlag/BindPFlags、SetEnvPrefix + SetEnvKeyReplace 等功能。
- JSON 请求:从
c.Get("raw_body")取原始字节,不要c.ShouldBindJSON后再拼字符串 —— 字段顺序、空格、浮点数格式差异都会让哈希不一致 - 表单请求:用
c.Request.PostForm.Encode()得到标准编码字符串(不是c.PostForm("k")拼接,它不保证键值顺序) - Query 参数参与签名:手动用
c.Request.URL.RawQuery,别依赖c.Query,因后者会解码 + 去重
Go 标准库 HMAC 验签要严格比对,不能用 ==
直接用 hmac.Sum(nil).String() == signature 是不安全的——存在时间侧信道攻击风险,且大小写、前导零等格式问题易导致误判。
必须用 hmac.Equal 做恒定时间比较:
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(rawBody)
expected := mac.Sum(nil)
if !hmac.Equal(expected, decodedSig) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid signature"})
return
}
注意:decodedSig 需是 []byte 类型(比如 base64.StdEncoding.DecodeString 后的结果),不能是 string;hmac.Equal 两个参数必须同为 []byte,否则 panic。
时间戳和 nonce 验证不能只查是否为空
签名常附带 timestamp 和 nonce 防重放,但仅检查 !c.Query("timestamp") 是否为空远远不够:
-
timestamp必须是 Unix 时间戳整数,且与服务器时间偏差 ≤ 300 秒(建议用time.Now().Unix()对比,而非本地 time.Local) -
nonce要存 Redis 做 5 分钟去重,不能只存在内存 map 里(多实例部署时失效) - 若用 query 传参,需确认签名是否包含 query string —— 大多数规范要求签名覆盖全部请求参数(含 query),漏掉就会验签失败
常见坑:前端传的 timestamp=1712345678.123 是 float,后端 c.Query("timestamp") 转 int64 会截断小数部分,导致哈希不匹配。

















