只校验timestamp会失效,因为攻击者在±5分钟窗口内重发合法请求时,timestamp仍有效、签名仍通过,服务端无法识别重复提交;必须结合用户维度nonce与Redis原子校验(如SET key value EX 300 NX)确保请求唯一性。

为什么只校验 timestamp 会失效
因为攻击者截获一次合法请求后,只要在 ±5 分钟窗口内原样重发,timestamp 仍有效、签名仍能通过——服务端根本不知道这是第二次提交。防重放不是“时间没过期就行”,而是“这个请求组合(user+timestamp+nonce+body)只能被消费一次”。
常见错误包括:
• 把 timestamp 放在 header 里却不参与签名原文拼接
• 用 time.Now().UnixMilli() 生成毫秒级时间戳,但服务端用 Unix() 解析导致偏差
• 校验时未强制要求 X-Timestamp 和 X-Nonce 两个 header 必须存在且非空
nonce 必须绑定用户 + Redis 原子写入
key 格式必须带用户维度,例如 fmt.Sprintf("nonce:%s:%s", userID, req.Nonce),不能只用 req.Nonce 全局去重——否则不同用户的随机数撞了就直接拒绝合法请求。
写入必须用原子操作:
• 用 SET key "1" EX 300 NX(Redis CLI)或 rdb.SetNX(ctx, key, "1", 300*time.Second)(go-redis)
• 绝对禁止先 GET 再 SET,中间有竞态窗口
• 返回 redis.ErrNil 或 false 表示已存在 → 直接返回 401 Unauthorized
立即学习“go语言免费学习笔记(深入)”;
过期时间设为 300 秒(5 分钟)是常见选择,它应 ≥ 客户端最大网络延迟 + 服务端平均处理耗时,但不宜超过业务允许的最大时钟偏差(比如客户端和服务端时间差若可能达 2 分钟,那 300 秒就是安全下限)。
Go 配置库,使用 spf13/viper — 分层优先级(flag > env >file > KV > default),提供 BindPFlag/BindPFlags、SetEnvPrefix + SetEnvKeyReplace 等功能。
签名原文拼接顺序和 body 处理最容易出错
签名原文必须严格固定格式,例如:method + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + body
少一个换行、大小写不一致、路径带 query string,都会导致验签失败。
body 必须取原始字节:
• 服务端用 io.ReadAll(r.Body) 一次性读完,再解析 JSON
• 若 body 是 JSON,客户端必须禁用缩进(JSON.stringify(obj, null, 0)),服务端也别用 json.MarshalIndent 二次编码
• 空 body 也要保留换行,不能跳过
常见坑:
• Gin 中 c.Request.Body 被中间件(如 binding)提前读过,再读就是空的 → 需用 c.Request.GetBody() 或在最外层中间件缓存 body
• Postman 自动加空格/换行,前端调试时建议用 curl 手动构造请求验证
没有 Redis 时的本地降级方案要小心清理
仅适用于单机、QPS map[string]time.Time,用 sync.RWMutex 保护读写。
关键约束:
• 不能用 sync.Map —— 它不支持按值删除,无法实现“过期自动清理”
• 必须起独立 goroutine 定期(如每 30 秒)遍历 map,删掉 time.Since(v) > 300*time.Second 的项
• key 仍需含 userID 前缀,避免全局碰撞
• 并发高时 map 锁竞争明显,响应延迟会上升
真正复杂的地方不在逻辑,而在一致性:客户端生成的 timestamp 和 nonce 必须明文传给服务端,且必须和签名原文里用的完全一致——差一个字符、多一个空格、时间单位不统一,整个防重放就形同虚设。

















