单纯校验x-timestamp或只做签名验证防不住重放攻击,必须将timestamp、nonce、用户身份、请求体四者绑定,并通过redis原子操作(如set key value ex 300 nx)确保nonce一次性消费,且key需含userid维度(如"nonce:uid:nonce"),签名原文须严格按method+"\n"+path+"\n"+timestamp+"\n"+nonce+"\n"+body顺序拼接。

单纯校验 X-Timestamp 或只做签名验证,防不住重放攻击——必须让 timestamp、nonce、用户身份、请求体四者绑定,并通过 Redis 原子操作确保 nonce 一次性消费。
为什么 Gin 中间件必须同时校验 timestamp 和 nonce
timestamp 只能拦住“过期”或“未来”的请求,但无法识别“同一份合法请求被原样重发”。比如攻击者抓包拿到一个含有效签名、X-Timestamp: 1724045160000、X-Nonce: a1b2c3... 的 POST 请求,在 5 分钟内重复发送,只要服务端没存过这个 nonce,它就会一路通关。
常见错误包括:
- 只从 query 取
timestamp,但在 JSON POST 场景下c.Request.URL.Query().Get("timestamp")拿不到值 - 用
time.Now().Unix()解析毫秒级时间戳,导致恒不匹配 - 没检查
X-Timestamp是否为整数,攻击者传字符串如"abc"可绕过解析逻辑 - 未拒绝未来时间戳(允许最多 ±30 秒误差即可,放宽到分钟级会放大时钟漂移风险)
如何用 Redis 原子操作校验 nonce 防重放
关键不是“存 nonce”,而是“存的同时确认它没被用过,且自动过期”。必须用 SET key value EX 300 NX 这类原子命令,不能拆成 GET + SET。
Redis key 必须带用户维度,否则多用户可能冲突。例如:
key := fmt.Sprintf("nonce:%s:%s", userID, req.Nonce)
其中 userID 必须来自可信上下文(如 JWT 解析后的 sub 字段),不能从 query 或 body 直接取。
校验逻辑要点:
-
rdb.SetNX(ctx, key, "1", 300*time.Second)返回redis.ErrNil→ 已存在,直接c.AbortWithStatus(403) - 其他 Redis 错误(如连接失败)应记录日志并降级:跳过 nonce 校验,仅保留 timestamp 检查
- TTL 设为 300 秒是常见选择,但实际应略大于客户端最大网络延迟 + 服务端平均处理耗时
- 本地内存模拟(如
map[string]bool+sync.RWMutex)在多实例部署下完全无效,切勿用于生产
签名原文拼接顺序与 body 缓存的硬性要求
签名不是可选增强项,而是 timestamp + nonce 起效的前提。如果签名原文里没包含这两项,或者拼接顺序不一致,验签必败,整个机制就形同虚设。
签名原文必须严格按固定顺序拼接,推荐格式:
method + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + body
其中 body 是原始字节(JSON 不缩进、不排序),空 body 也要保留末尾换行。常见坑点:
- 没提前读取并重置
c.Request.Body,导致后续c.ShouldBindJSON()失败 - 用
ioutil.ReadAll(c.Request.Body)后忘了重置:c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes)) - 前端用字典序排 JSON 字段,后端用默认
json.Unmarshal(不排序),结果哈希不一致 -
X-Signature放在 header,别塞进 body 或 query,避免缓存/日志泄露
客户端必须同步 NTP 且每次生成新 nonce
服务端再严密,客户端乱传也会让整套机制失效。最容易被忽略的是传输层约定松散。
强制要求:
-
X-Timestamp和X-Nonce必须作为独立 HTTP header 明文传递,禁用 URL query 或 form-data - 客户端必须开启 NTP 时间同步,偏差超过 ±30 秒就会频繁被拒;服务端校验时不要放宽到分钟级
- nonce 每次请求都必须重新生成,推荐用
crypto/rand.Read()生成 16 字节随机数,再转 hex:hex.EncodeToString(randBytes) - 严禁复用 nonce —— 即使是不同用户、不同接口,同一个 nonce 用两次就会被 Redis 拒绝
真正起作用的不是某个字段,而是 timestamp、nonce、userID、path、body、method 这六者在签名和 Redis key 中的强绑定关系。少一个环节,重放窗口就打开一道缝。











