只校验timestamp无效,必须结合nonce+Redis原子写入+串行中间件执行:攻击者可在时间窗口内原样重放合法请求,因签名、参数、密钥均未变,仅靠时间戳无法识别重复提交。

直接说结论:只校验 timestamp 是无效防御,必须配合 nonce + Redis 原子写入,且三者(验签、时间差、nonce去重)必须在同一个中间件里串行执行,不能拆开。
为什么 timestamp 单独校验拦不住重放
攻击者截获一个合法请求后,在 5 分钟窗口内原样重发,timestamp 仍在有效期内,签名也能通过——因为密钥没泄露、参数没变、哈希结果一致。服务端只看时间,就等于默认允许“同一请求体被处理多次”。这正是重放攻击的定义:不是非法请求,而是合法请求被重复提交。
常见错误包括:
- 把
timestamp放在 query string 里但没参与签名原文拼接 - 服务端用
time.Now().Unix(),客户端却用本地时区或毫秒级时间,导致偏差超限 - 校验逻辑写在 JWT 解析之后、业务 handler 之前,但没锁住 nonce 校验环节,引发并发漏判
Gin 中间件里怎么原子校验 nonce 和 timestamp
关键不是“查一遍再写”,而是用一条 Redis 命令完成“判断是否首次出现 + 设置过期”:必须用 rdb.SetNX(ctx, key, "1", 60*time.Second),返回 false 或 redis.ErrNil 就立即 return c.AbortWithStatusJSON(http.StatusConflict, ...)。
立即学习“go语言免费学习笔记(深入)”;
key 构造必须带用户维度和秒级时间戳,例如:fmt.Sprintf("nonce:%s:%d:%s", userID, time.Now().Unix(), req.Nonce)。不加 userID 会导致跨用户碰撞;用毫秒会撑爆 Redis key 数量;不用 Unix() 而用字符串格式会破坏索引一致性。
中间件内顺序不可调换:
- 先解析 JWT,拿到
userID(不能在 JWT 前校验 nonce,否则无法构造带用户 ID 的 key) - 再提取 header 中的
X-Timestamp和X-Nonce,拒绝缺失或格式错误的请求(如X-Nonce长度 - 接着校验
abs(time.Now().Unix() - timestamp) (单位秒),超时直接 400 - 最后执行
SetNX,失败则 409
客户端传参和签名原文怎么对齐
服务端能提取 timestamp 和 nonce,前提是它们明文出现在请求中,且拼接进签名原文的顺序和服务端完全一致。Gin 中推荐统一走 header:
-
X-Timestamp:必须是time.Now().Unix()的十进制字符串,不能带小数点或单位 -
X-Nonce:16 字节随机数经hex.EncodeToString()得到的 32 字符字符串,前端用crypto/rand生成 - 签名原文拼接顺序示例:
method + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + body,其中body是原始字节(未 gzip、未缩进 JSON)
任何一项错位——比如客户端把 nonce 拼在 timestamp 前,或服务端漏读 header 直接从 query 取值——都会导致验签失败。
没有 Redis 怎么临时扛住?
仅限单机、低 QPS、内部工具类接口。不能用 sync.Map,它不支持按值遍历清理;必须用 sync.RWMutex 包裹的 map[string]time.Time,并起独立 goroutine 定期扫描删除过期项(比如每 30 秒删掉 time.Since(t) > 300 的 key)。
这个方案的硬伤是:实例重启后全量丢失,且多实例下完全失效。生产环境只要有一台 Redis,就别碰本地内存方案。
最容易被忽略的是时钟同步:哪怕 Redis key 过期设置得再准,如果客户端和服务端系统时间偏差超过窗口值,合法请求也会被拒。上线前必须确认 NTP 同步状态,而不是依赖“应该差不多”。


















