外观
接口防重放避坑实录:直连场景的时间窗签名校验
问题背景:只做接口签名,为什么仍会被重放
很多后端系统在开放 API、服务端直连回调、内网管理接口中都会使用 AppKey、AppSecret 和接口签名。签名能证明请求没有被篡改,也能校验调用方身份,但它默认解决的是“请求是否可信”,并不天然解决“同一个可信请求是否被重复提交”。
典型攻击或故障场景是:攻击者通过代理日志、网关访问记录、异常堆栈拿到一次完整请求;客户端超时后自动重试;用户连续点击提交按钮;消息队列重复投递。此时请求体、timestamp、nonce、sign 都是合法的,如果服务端只验签、不做防重放,就可能造成重复扣款、重复发货、重复创建单据。
直连场景通常指调用方与服务端直接通过 HTTPS 通信,中间没有复杂 OAuth 票据交换,也没有用户级短期 Token。它适合使用经典三件套:
timestamp:客户端发起请求的 Unix 毫秒时间;nonce:每次请求唯一的随机字符串;sign:使用 AppSecret 对规范化请求内容计算 HMAC。
服务端先校验时间窗,再校验 nonce 是否在窗口内出现过,最后校验签名。三者缺一不可。
方案对比:时间窗、nonce 与幂等键如何分工
只校验 timestamp 不够,因为攻击者在时间窗内可以立刻重放;只存 nonce 也不够,因为 nonce 集合会无限增长,且旧请求永久有效;只依赖业务幂等键同样不足,因为查询类接口可能没有天然业务键,而非幂等接口需要在进入业务逻辑前拦截重复流量。
推荐组合如下:timestamp 把可重放窗口压缩到例如 5 分钟;nonce 在这个短窗口内做一次性凭证;签名保证 timestamp、nonce、请求体和关键请求头不可被替换。对于下单、支付、退款等强一致业务,仍应在业务层增加幂等键或唯一约束,防重放中间件不能替代最终业务幂等。
签名规范设计:先确定参与签名的字符串
接口防重放最容易埋雷的地方不是算法,而是双方对“签了什么”理解不一致。建议固定以下请求头:
X-Access-Key:调用方标识;X-Timestamp:Unix 毫秒时间;X-Nonce:16 到 64 位随机串;X-Signature:十六进制或 Base64 编码后的 HMAC-SHA256。
待签名串建议使用清晰、稳定、可复现的格式:
text
POST
/api/v1/orders
20260101T120000Z
1735704000000
9f2e7c1a8b6d4e01
3f2c...请求体SHA256摘要对应含义为 HTTP 方法、路径、时间戳、随机串、请求体摘要。不要把完整 URL query 随意拼接后直接签名,因为网关注解、参数顺序、表单编码、尾部斜杠都可能导致服务端重算结果不同。更稳妥的方式是 query 参数按 key 排序后再规范化,或者要求关键参数全部放入 JSON Body。
Go 可运行示例:中间件实现 timestamp、nonce 与签名校验
下面示例只依赖 Go 标准库和 Redis。示例中的 nonce 存储使用 SET key value NX PX ttl,利用原子操作避免并发请求同时穿透。生产环境建议 Redis 开启高可用,并为防重放逻辑设置较短超时,避免缓存故障拖垮主链路。
go
package main
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"log"
"net/http"
"strconv"
"strings"
"time"
)
// NonceStore 抽象 nonce 存储,方便单元测试使用内存实现,生产环境替换为 Redis。
type NonceStore interface {
// SetNX 只有 key 不存在时才写入成功;ttl 必须略大于请求允许的时间窗。
SetNX(key string, value string, ttl time.Duration) (bool, error)
}
type memoryNonceStore struct {
data map[string]time.Time
}
func newMemoryNonceStore() *memoryNonceStore {
return &memoryNonceStore{data: make(map[string]time.Time)}
}
func (m *memoryNonceStore) SetNX(key string, value string, ttl time.Duration) (bool, error) {
now := time.Now()
if expireAt, ok := m.data[key]; ok && expireAt.After(now) {
return false, nil
}
m.data[key] = now.Add(ttl)
return true, nil
}
type ReplayProtector struct {
// 实际项目中可按 accessKey 查询对应 secret,不要把所有调用方共用一个密钥。
secrets map[string]string
nonces NonceStore
window time.Duration
}
func NewReplayProtector(store NonceStore) *ReplayProtector {
return &ReplayProtector{
secrets: map[string]string{
"demo-access-key": "demo-secret",
},
nonces: store,
window: 5 * time.Minute,
}
}
func writeError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_, _ = w.Write([]byte(fmt.Sprintf(`{"code":"%d","message":%q}`, status, msg)))
}
func sha256Hex(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
func hmacSHA256(secret string, content string) string {
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(content))
return hex.EncodeToString(mac.Sum(nil))
}
func (p *ReplayProtector) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
accessKey := r.Header.Get("X-Access-Key")
timestampText := r.Header.Get("X-Timestamp")
nonce := strings.TrimSpace(r.Header.Get("X-Nonce"))
signature := r.Header.Get("X-Signature")
if accessKey == "" || timestampText == "" || nonce == "" || signature == "" {
writeError(w, http.StatusUnauthorized, "缺少鉴权或防重放请求头")
return
}
secret, ok := p.secrets[accessKey]
if !ok {
writeError(w, http.StatusUnauthorized, "未知 AccessKey")
return
}
// 1. 校验 timestamp,防止历史请求永久有效。
millis, err := strconv.ParseInt(timestampText, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "timestamp 必须是 Unix 毫秒")
return
}
requestTime := time.UnixMilli(millis)
diff := time.Since(requestTime)
if diff < 0 {
diff = -diff
}
if diff > p.window {
writeError(w, http.StatusUnauthorized, "请求已过期,请校准客户端时间")
return
}
// 2. 读取 Body 后必须放回,供后续 JSON 解析使用。
body, err := io.ReadAll(r.Body)
if err != nil {
writeError(w, http.StatusBadRequest, "读取请求体失败")
return
}
r.Body.Close()
r.Body = io.NopCloser(bytes.NewReader(body))
// 3. 按双方约定的规则重算签名。
bodyDigest := sha256Hex(body)
signContent := strings.Join([]string{
r.Method,
r.URL.Path,
timestampText,
nonce,
bodyDigest,
}, "\n")
expected := hmacSHA256(secret, signContent)
if !hmac.Equal([]byte(expected), []byte(signature)) {
writeError(w, http.StatusUnauthorized, "签名校验失败")
return
}
// 4. nonce 原子去重。key 加入 accessKey,避免不同调用方随机串碰撞。
nonceKey := "replay:nonce:" + accessKey + ":" + nonce
ok, err = p.nonces.SetNX(nonceKey, "1", p.window+time.Minute)
if err != nil {
log.Printf("nonce store error: %v", err)
writeError(w, http.StatusServiceUnavailable, "防重放服务暂不可用")
return
}
if !ok {
writeError(w, http.StatusConflict, "重复请求已被拒绝")
return
}
next.ServeHTTP(w, r)
})
}
func main() {
protector := NewReplayProtector(newMemoryNonceStore())
mux := http.NewServeMux()
mux.Handle("POST /api/v1/orders", protector.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_, _ = w.Write([]byte(`{"code":0,"message":"success"}`))
})))
log.Println("listen on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}客户端签名时必须使用完全相同的规范化规则。示例 Body 为 {"sku_id":1001,"quantity":1},请求头示例如下:
bash
curl -X POST http://localhost:8080/api/v1/orders \
-H 'Content-Type: application/json' \
-H 'X-Access-Key: demo-access-key' \
-H 'X-Timestamp: 1735704000000' \
-H 'X-Nonce: 9f2e7c1a8b6d4e01' \
-H 'X-Signature: 按规则计算出的HMAC' \
-d '{"sku_id":1001,"quantity":1}'校验顺序为什么建议先时间、再验签、后占用 nonce
先校验时间可以快速丢弃明显过期的垃圾请求,减少 Redis 访问。再验签是为了避免未认证调用方通过构造大量随机 nonce 消耗存储。最后执行 SET NX,把真正通过身份和完整性校验的请求登记为一次性请求。
有些团队会先占用 nonce 再验签,这会带来两个问题:攻击者可不掌握密钥就刷爆 nonce 空间;签名失败的请求也会污染去重集合,导致客户端修正签名后使用同一个 nonce 反而被拒绝。
如果安全等级更高,也可以先验签、再校验时间、最后去重,但无论如何都不应让未验签请求写入 Redis。
Redis TTL 与多实例部署的关键配置
直连服务通常会水平扩容,多个 API 实例不能使用本地内存保存 nonce,否则请求落到不同实例就会重复通过。生产环境应使用 Redis、KeyDB 等共享存储,并保证以下配置:
text
nonce TTL = 请求允许时间窗 + 时钟偏差缓冲
例如:5 分钟时间窗 + 1 分钟 TTL = 6 分钟Redis 命令应使用原子写法:
text
SET replay:nonce:{accessKey}:{nonce} 1 NX PX 360000不要先 GET 再 SET,这在并发重试下会产生检查时序漏洞。也不要把 nonce 只打到日志里靠离线任务识别,因为这种方式无法实时拦截第一次重放。
避坑总结:实战中最常见的 5 个错误
错误现象:客户端刚发出的请求提示过期。 常见原因是服务端取秒、客户端传毫秒,或者容器与宿主机时钟不同步。修正方法是统一文档为 Unix 毫秒,服务端同时拒绝未来时间过大的请求,并在 Kubernetes 节点配置 NTP/Chrony,接口错误码中明确区分“过期请求”和“签名失败”。
错误现象:签名偶现失败,本地测试却正常。 多数由 query 参数顺序变化、网关自动补全斜杠、Body 被日志中间件提前读取导致。修正方法是固定路径规范化规则,对 Body 使用
io.ReadAll后重新塞回r.Body,签名内容包含方法、路径、时间、nonce 和 Body 摘要,而不是依赖字符串化后的完整请求。错误现象:压测时大量重复请求仍进入业务。 常见原因是 nonce 存在单机内存,或者使用先查后插。修正方法是改用共享 Redis,并使用
SET NX PX一条命令完成判断和写入;同时压测同一请求重放,确认第二次返回 409。错误现象:接口偶发 500,关闭防重放后恢复。 可能是 Redis 慢查询、连接池耗尽或防重放中间件无限等待。修正方法是给 Redis 操作设置 20 到 50 毫秒级超时,区分 fail-open 与 fail-closed 策略。支付、退款等高危接口建议 fail-closed;低风险查询可结合熔断降级,但必须记录告警。
错误现象:合法用户网络重试后被误杀。 原因可能是客户端 SDK 对同一次业务操作每次生成新 nonce,导致防重放层无法识别业务重复;也可能是把 nonce 当成幂等键长期保存。修正方法是明确分层:nonce 只在短时间窗内防止 HTTP 请求重放,业务提交应额外携带
request_id或幂等号,并在订单表、流水表建立唯一索引兜底。
结语
直连接口防重放的核心不是某一个算法,而是一组边界清楚的约束:timestamp 限制有效期,nonce 保证窗口内唯一,HMAC 签名保证关键字段不可篡改,业务幂等键负责最终写入一致性。把这四层关系设计清楚,再配合共享存储、原子命令、时钟同步和可观测日志,才能既挡住恶意重放,又不误伤正常重试。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。