必须用Redis SETNX原子校验nonce,因单独校验timestamp无法防重放;key需含用户ID,TTL应匹配时钟偏差与网络延迟,校验须在Gin中间件中与验签、时间窗检查组成原子链。

只校验 timestamp 的 ±5 分钟窗口,完全拦不住重放——攻击者把抓到的合法请求(含签名、timestamp、nonce)原样重发一次就成功了。真正起作用的是 nonce + Redis 原子写入,且必须在 Gin 中间件里完成「验签 → 时间窗检查 → Redis 去重」这一整条原子链。
为什么 Gin 中间件里不能分开写 timestamp 和 nonce 校验
常见错误是把时间戳检查和 nonce 去重拆成两个独立中间件,或者用 if !exists { set } else { reject } 这种非原子逻辑。结果就是两个相同 nonce 请求几乎同时进来:都查 Redis 返回不存在,然后都写入成功,防重放彻底失效。
- 必须用 Redis 的
SET key value EX 60 NX单命令完成“判断+写入+过期”,Go 客户端推荐调用rdb.SetNX(ctx, key, "1", 60*time.Second) - 返回
redis.ErrNil或false表示 key 已存在 → 直接c.AbortWithStatusJSON(409, "replay detected") - key 必须带用户维度,例如
fmt.Sprintf("nonce:%s:%s", userID, req.Nonce),不能只用req.Nonce - 别用
GET+SET组合,也别用INCR或 Lua 脚本依赖外部状态——除非你清楚每行脚本的执行边界
Gin 中提取 X-Timestamp 和 X-Nonce 的安全姿势
这两个 header 不是可选字段,而是强制校验入口。Gin 中必须先确认它们存在、格式合法,再进入后续流程;否则连 JWT 解析或签名验证都不该触发。
-
X-Timestamp必须是秒级 Unix 时间戳(time.Now().Unix()),不是毫秒;服务端用abs(time.Now().Unix() - reqTimestamp) > 300判超时 -
X-Nonce必须 ≥16 字符、只含字母数字,前端用crypto/rand.Read生成 16 字节再 hex 编码;Gin 中用c.Request.Header.Get("X-Nonce")读取,禁止从 query 或 body 解析 - 若缺失任一 header,直接
c.AbortWithStatus(400),不往下走 - 别把
nonce存进 JWT payload——那等于放弃无状态优势,还增加 Redis 清理负担
签名原文拼接顺序不一致会导致验签失败
Gin 中验签失败,80% 是因为客户端和服务端对签名原文的拼接规则不一致。哪怕多一个空格、少一个换行、大小写错一个字母,HMAC 就对不上。
立即学习“go语言免费学习笔记(深入)”;
- 标准拼法(推荐):
method + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + bodyHash -
method必须大写("POST"),path必须是原始路径(不含 query string),bodyHash推荐用sha256.Sum256(bodyBytes).Hex() - 如果请求是 GET,
body为空,则bodyHash固定为sha256.Sum256([]byte{}).Hex();不能跳过这一项 - 客户端和服务端都禁用 JSON 序列化时的缩进(
json.MarshalIndent),统一用json.Marshal
最容易被忽略的是时钟同步和 TTL 匹配:线上 K8s Pod 可能因 NTP 未开启导致 time.Now().Unix() 漂移 ±2 秒,而 Redis 的 EX 60 若设得太短(比如 10 秒),高延迟链路下的合法请求就会被误拒;设得太长(比如 3600 秒),旧 nonce 长期占内存,还可能被缓存击穿绕过。实际应按「最大时钟偏差 + 网络 RTT + 后端处理耗时 + 30 秒缓冲」来算 TTL,而不是拍脑袋填 300。


















