防重放必须校验 timestamp 和 nonce:timestamp 用秒级时间戳并设 ±300 秒窗口,nonce 为 redis 原子去重的随机字符串;签名须用 hmac-sha256,密钥不硬编码,body 读取需重置,redis 校验用 set nx ex 防竞态。

防重放必须校验 timestamp 和 nonce
重放攻击的本质是:攻击者截获合法请求,稍后原样重发。只靠签名无法防御——签名本身是静态的,只要参数不变,签名就恒定。所以服务端必须拒绝“过期”或“已用过”的请求。
关键不是加不加这两个字段,而是怎么用:
-
timestamp应为秒级或毫秒级时间戳,服务端接受窗口建议设为±300秒(5 分钟),超出即拒;注意比对时用time.Now().Unix(),而非本地时钟,避免服务器时间偏差 -
nonce是一次性随机字符串(如 16 字符 hex),必须存 Redis 做去重,过期时间与 timestamp 窗口一致;不能只存在内存或数据库——高并发下查写非原子,易漏判 - 常见错误:前端传了
timestamp但服务端没校验;或用了nonce却没配 Redis,仅靠 map 缓存,重启即失效,还可能被并发击穿
签名算法必须用 HMAC-SHA256 而非 MD5
MD5 已被证明可碰撞,且无密钥参与,纯参数拼接 + MD5 的签名方式形同虚设。真实生产环境必须用带密钥的 HMAC。
服务端签名计算逻辑要严格对齐客户端(顺序、编码、空格):
- 参与签名的字段必须固定:推荐
method+path+sorted_query_string+body_hash+timestamp+nonce;其中body_hash用 SHA256(body) 截前 16 字节再 hex 编码,避免传输大 body - 密钥
secretKey绝不可硬编码,应从配置或 Vault 加载;每个accessKey对应独立密钥,避免单点泄露波及全部用户 - 校验时必须用
hmac.Equal()比较签名,防止时序攻击;直接用==会暴露字节差异
Gin 中间件里读取 Body 的坑必须绕开
c.Request.Body 是单次读取流,中间件里调一次 ioutil.ReadAll 或 gin.BindJSON 后,下游 handler 再读就是空的——这是 Gin 防重放中间件最常崩的原因。
正确做法只有两种:
- 在中间件开头用
body, _ := io.ReadAll(c.Request.Body)读一次,然后用c.Request.Body = io.NopCloser(bytes.NewBuffer(body))重置 Body,确保后续绑定可用 - 或者更稳妥:只对
Content-Type: application/json类请求做完整 body 签名校验;其他类型(如 form-data)只校验 query + header + timestamp + nonce,跳过 body 哈希 - 别信“读完再塞回去就没事”——如果中间件 panic,Body 重置可能失败;建议加 defer 或 recover 包裹读取逻辑
Redis 校验必须用 SET NX EX 原子命令
防重放的核心不是“存”,而是“存且判是否新增”。用 GET 再 SET 是经典竞态漏洞:两个并发请求同时 GET 返回 nil,接着都 SET 成功,重复放行。
必须一条命令完成判断+写入:
- 命令:
SET key value EX 300 NX,返回OK表示首次请求,(nil)表示已存在 - key 建议组合为
replay:<code>accessKey:nonce,避免跨用户冲突;value 可写入timestamp方便排查 - Redis 连接异常时不能 panic 整个请求,应降级为只校验 timestamp(允许少量重放),并打 warn 日志;否则缓存抖动会导致大面积 500











