验签中间件必须注册在gin.recovery()之后、gin.logger()之前,否则c.request.body被提前读取关闭;需用c.getrawdata()一次性读取并回填,签名原文拼接须严格对齐客户端,含method、content_md5、path、query_string(字典序+url转义),json标准化,密钥传[]byte,且须校验ts、nonce、sign长度及authorization头格式。

验签中间件必须注册在 gin.Recovery() 之后、gin.Logger() 之前
否则 c.Request.Body 会被 gin.Logger() 提前读取并关闭,导致后续 c.GetRawData() 或 c.ShouldBindJSON() 返回空或 panic。中间件注册顺序错一位,整个验签就失效。
推荐顺序:
gin.Recovery()- 你的验签中间件(
signVerifyMiddleware()) - JWT 校验等其他业务中间件
- 路由处理函数
别把验签写在某个 handler 里——那等于没做;也别用 router.Any("/*path", ...) 拦截却不调用 c.Next(),请求会直接终止。
必须用 c.GetRawData() 一次性读取并缓存请求体
c.ShouldBindJSON()、c.PostForm()、c.MultipartForm() 都会消费 c.Request.Body,且不可重放。验签时若先调用这些方法,body 就没了,签名原文拼错,必然失败。
正确做法:
- 统一在中间件开头调用
bodyBytes, err := c.GetRawData() - 校验失败时,用
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))回填,保证后续 handler 可正常绑定 - 空 body(如 GET 请求)也要显式参与签名,拼为
"",不能跳过
注意:c.GetRawData() 是 Gin v1.9+ 提供的安全封装,内部已处理了 ContentLength == 0 和 Body == nil 的边界情况。
签名原文拼接顺序和编码必须严格对齐客户端
服务端算出来的签名和客户端不一致?90% 出在这里。不是算法错了,是字符串构造不一致。
标准拼接格式(以 Python 兼容为例):
METHOD\nCONTENT_MD5\nPATH?QUERY_STRING
其中:
-
METHOD全大写("POST",不是"post") -
CONTENT_MD5是bodyBytes的 MD5 hex 小写值(空 body →"d41d8cd98f00b204e9800998ecf8427e") -
PATH去除末尾斜杠(/api/v1/user/→/api/v1/user),不带 host -
QUERY_STRING按 key 字典序排序,value 全部url.QueryEscape(),多值只取第一个 - JSON body 需标准化:去掉空格 + key 字母序重排(用
jsoniter.ConfigCompatibleWithStandardLibrary.Marshal()再解析)
密钥必须传 []byte(secretKey) 给 hmac.New(sha256.New, key),传 string(secretKey) 编译能过,但语义错误,签名永远不匹配。
时间戳、nonce、签名长度三者缺一不可
只比对签名,防不了重放。必须同步校验:
-
ts参数是否在当前时间 ±300 秒内(用time.Now().UTC().Unix()对比) -
nonce是否已在 Redis 中存在(TTL 设为 300 秒以上,原子 setnx + expire);单机 map 不可抗多实例部署 -
sign字段 Base64 解码后长度是否为 32 字节(SHA256 原始输出长度);不是则直接c.AbortWithStatusJSON(401, ...)
Header 中建议统一走 Authorization: Signature key=xxx,sign=yyy,ts=zzz,nonce=aaa 格式,避免 query 或 form 里混传,降低解析歧义。query 参数漏排序、body 多了个空格、timestamp 用本地时区而非 UTC——这些细节出错,验签就静默失败,排查起来最耗时间。











