只校验时间戳会失效,因为攻击者可在有效时间窗口内原样重放签名合法的完整请求,服务端缺乏“已消费”状态标记,无法识别重复请求。

防重放不是加个时间戳就能搞定的事,必须让每个请求在服务端有“已消费”状态标记,否则攻击者截获一次合法请求就能无限重放。
为什么只校验 timestamp 会失效
重放攻击的本质是:攻击者拿到一个签名合法、时间仍在窗口内的完整请求(含 timestamp、nonce、signature),原样再发一遍。服务端如果只做 abs(time.Now().Unix() - req.Timestamp) 判断,必然通过——因为时间没超、签名没改、参数没变。
真正要拦住的是“这个 timestamp+nonce 组合是否已被接受过”,这需要服务端记录并原子性校验。
- 常见错误:把
timestamp放进签名原文但不存、不查重 - 更错的:用
time.Now().UnixMilli()当唯一标识——高并发下毫秒级极易碰撞 - 别信“客户端生成 UUID 当 nonce”——没服务端查重,等于没防
Redis SET NX EX 是生产唯一靠谱方案
用 SET key value EX 300 NX 一条命令完成“写入 + 判断是否首次”,彻底规避竞态。失败即重放,直接返回 400 Bad Request 或自定义错误码 err: replay_detected。
key 必须带业务上下文,例如:
fmt.Sprintf("replay:%s:%s", userID, req.Nonce)
而不是简单用 "replay:" + req.Nonce —— 否则不同用户可能撞 nonce,导致误拒。
-
EX 300表示 5 分钟过期,应 ≥ 客户端最大网络延迟 + 服务处理耗时,但不宜超过业务允许的时钟偏差(比如 NTP 同步误差通常 - value 可填任意字符串(如
"1"),或存客户端传的req.Timestamp方便审计 - 绝对不要先
GET再SET—— 中间有毫秒级窗口,两个并发请求可能同时判定“未存在”,然后双双写入
签名原文必须明传 timestamp 和 nonce
这两项不能只塞进 HMAC 输入里“暗地里用”,必须作为独立 HTTP 参数(header 或 query)明文传输,否则服务端无法提取、无法校验、无法拼 key。
推荐格式:
-
X-Timestampheader:用time.Now().Unix()(秒级整数),避免浮点或毫秒引发解析歧义 -
X-Nonceheader:用crypto/rand.Read()生成 16 字节,再hex.EncodeToString()(固定长度,便于 Redis 索引) - 签名原文拼接顺序必须严格约定,例如:
method + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + body
body 为空也要保留换行;JSON body 禁用缩进,否则哈希不一致。
Gin 中间件里三步必须原子执行
验签 → 时间窗检查 → Redis nonce 校验,这三步不能拆成多个中间件或 handler,否则在验签成功后、查重前存在竞态窗口。
典型错误顺序:
if !verifySignature(...) { return }
if !inTimeWindow(...) { return }
if !checkNonceInRedis(...) { return }
正确做法是把 Redis 调用放在验签之后、业务逻辑之前,且整个流程不被 panic 中断(加 defer 日志兜底)。
- 中间件内读取
c.Request.Body后,需用io.ReadAll缓存并重置,否则下游c.ShouldBindJSON会失败 - Redis 连接异常时别 panic,降级为只记录告警日志,避免全量接口不可用
- GET /health 等无状态查询接口应白名单跳过,不参与防重放
最易被忽略的点:容器环境时钟漂移(K8s Pod 重启、Lambda 冷启动)、客户端与服务端时区/系统时间不同步、签名原文拼接空格或换行遗漏——这些都会导致验签失败或重放漏判,上线前必须用真实设备抓包验证全流程。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











