签名中间件必须放在gin.recovery()之后、gin.logger()之前,否则c.request.body被提前读空导致绑定失败;需用c.getrawdata()缓存并重置body,且签名原文拼接须严格遵循方法、路径、排序后查询参数、标准化body及utc时间戳等约定。

签名中间件必须放在 gin.Recovery() 之后、gin.Logger() 之前,否则 c.Request.Body 会被提前读空,导致后续 c.ShouldBindJSON() 或 c.PostForm() 失败。
签名中间件注册顺序为什么不能乱
中间件执行顺序直接影响请求体能否被多次读取。Gin 的 gin.Logger() 默认会调用 c.Request.Body 做日志记录(比如打印 raw body 或 form data),一旦读过就变为空;而你的验签逻辑又需要完整 body,两者冲突。
-
gin.Recovery()必须最先 —— 它负责 panic 捕获,不能被其他中间件干扰 - 你的签名中间件必须紧接其后 —— 此时
c.Request.Body还未被消费 -
gin.Logger()要放签名之后 —— 否则日志中间件会先读 body,你再读就是空字节 - JWT 等业务中间件放签名之后 —— 验签失败就该直接拦截,不该走到鉴权环节
如何安全读取并复用 request body
Go 的 http.Request.Body 是单次读取流,不手动重置就只能用一次。签名要读 body,业务 handler 还要读,必须缓存+回填。
- 统一用
c.GetRawData()一次性读取原始字节,它内部已做缓冲处理 - 读完后立刻用
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))重置 Body - 不要在中间件里调用
c.ShouldBindJSON()—— 它会再次读 Body 并可能 panic - 若 Content-Type 是
multipart/form-data,跳过 body 签名(或显式拒绝),因为文件上传无法无损标准化
签名原文拼接有哪些硬性约定
客户端和服务端对“哪些字段参与、顺序、编码”必须完全一致,否则 HMAC 一定不匹配。这不是算法问题,是协议对齐问题。
- 固定顺序拼接:
c.Request.Method + c.Request.URL.Path + c.Request.URL.RawQuery + bodyString + timestamp -
c.Request.URL.RawQuery要按 key 字典序排序后再拼 —— 直接用c.Request.URL.RawQuery不可靠,需解析后重排 - 所有 query 和 form value 必须
url.QueryEscape()编码,包括空字符串和特殊字符 - body 参与签名前要标准化:JSON 类型需 key 字母序 + 无空格(可用
jsoniter.ConfigCompatibleWithStandardLibrary.Marshal());非 JSON 类型(如application/x-www-form-urlencoded)需按相同规则解析后排序拼接 - timestamp 必须用 UTC 时间戳(
time.Now().UTC().Unix()),且校验窗口建议 ±300 秒,但生产环境必须开启 NTP 同步,否则时间偏差会导致合法请求被拒
防重放和密钥管理的落地要点
签名本身不防重放,必须配合 nonce + 时间戳;密钥硬编码等于裸奔。
- nonce 必须存 Redis(不是内存 map),设置 TTL 略大于时间窗口(如 310 秒),用
SETNX原子写入 - 密钥必须从环境变量加载:
os.Getenv("API_SECRET_KEY"),类型转成[]byte再传给hmac.Newsha256()——string(secret)会出错 - 客户端传来的
sign字段要 Base64 解码,解码后长度必须为 32 字节(SHA256 输出),否则直接 401 - 签名字段建议统一走
Authorizationheader,格式如Signature key=abc,sign=xxx,ts=1717023456,nonce=def,避免 query 或 body 里混传
最常被忽略的是 timestamp 的时区基准和 body 的标准化方式 —— 本地时间、未排序的 query、带空格的 JSON,任何一个不对,签名就永远验不过。别指望靠调试日志猜问题,得把拼接后的原文打出来两边比对。











