API 接口签名防重放攻击实战:HMAC 配置

API 接口签名防重放是开放平台、小程序与移动端接口最关键的一道应用层防线。仅靠 HTTPS 只能保证传输过程不被窃听和篡改,一旦攻击者通过反编译客户端、日志泄露或中间人代理拿到一次合法请求,就能原样重放调用,在参数完全不变的情况下重复下单、重复领券、重复扣款,服务端从业务层看不出任何异常。本文给出基于 HMAC 签名、时间戳窗口与 Redis 一次性 nonce 的 API 接口签名防重放完整方案,所有配置与代码均可直接复制落地。

适用场景

  • 开放平台对外提供的 REST 接口,调用方是无法信任的第三方客户端。
  • 移动端 App、小程序接口,客户端可被反编译,密钥可能被提取后滥用。
  • 支付、订单、发券、提现等资金与权益相关的高价值接口,重复执行会造成直接损失。
  • 内部微服务之间跨网络调用,需要防止请求被录制后延迟重放。
  • 接口日志或网关访问日志被外部人员获取,历史请求被粘贴重发。

前置条件

  • 服务端已启用 HTTPS,TLS 版本不低于 1.2,避免签名与明文一同被抓包。
  • 接口调用方身份可识别,即为每个调用方分配独立的 app_id 与 app_secret。
  • 服务端具备统一的请求入口或中间件层,能够在业务逻辑之前完成签名校验。
  • Redis 5.0 及以上版本已部署,用于存储一次性 nonce 与幂等键。
  • 服务端与客户端时间已通过 NTP 同步,时钟偏差控制在 30 秒以内。
  • 准备一个独立测试环境,避免灰度阶段影响线上正常调用方。

原理说明

重放攻击是如何成立的

重放攻击不依赖破解密码或伪造会话,它只做一件事:把已经成功执行过的一次请求原封不动再发一遍。攻击面通常来自三处:客户端反编译后拿到固定密钥、请求经过代理被完整记录、服务端日志被越权读取。只要服务端不校验请求的时效性与唯一性,签名验证永远通过,因为签名字段本身也是被复制过来的。

因此防重放必须解决两个问题:请求是否有有效期(时间戳窗口),请求是否已被使用过(nonce 一次性去重)。只做时间戳不做 nonce,攻击者可以在窗口内高频重放;只做 nonce 不做时间戳,攻击者可以无限期保留合法请求等到业务条件成熟再发。

签名串的组成与计算顺序

推荐的签名串按固定顺序拼接:请求方法、请求路径、按字典序排序后的查询参数、请求体摘要(SHA-256 十六进制)、时间戳、随机字符串 nonce、调用方标识 app_id。拼接完成后使用 HMAC-SHA256 以 app_secret 为密钥计算摘要,输出十六进制小写字符串通过请求头传递。

签名串必须包含请求体摘要而不是请求体原文,原因是摘要长度固定、便于拼接,同时能保证 body 一旦被篡改签名必然失配。排序参数是为了让客户端与服务端在参数顺序上达成一致,避免因拼接顺序不同导致合法请求被判为非法。

操作步骤

步骤一:部署 Redis 并规划键空间

nonce 去重与幂等键都落在 Redis 上,两者使用不同前缀便于隔离与清理:

# 安装并启动 Redis(以 Ubuntu 22.04 为例)
apt-get install -y redis-server
systemctl enable --now redis-server

# 关键参数:仅监听本机,限制最大内存并设定淘汰策略
sed -i 's/^# maxmemory .*/maxmemory 512mb/' /etc/redis/redis.conf
sed -i 's/^# maxmemory-policy .*/maxmemory-policy noeviction/' /etc/redis/redis.conf
sed -i 's/^bind .*/bind 127.0.0.1 ::1/' /etc/redis/redis.conf
systemctl restart redis-server

# 键空间约定
# replay:nonce:{app_id}:{nonce}   过期时间 = 时间戳窗口,例如 300 秒
# idem:{app_id}:{idempotency_key} 过期时间 = 业务去重窗口,例如 86400 秒
redis-cli INFO memory | head -5

注意 maxmemory-policy 必须设为 noeviction。若使用 allkeys-lru,nonce 键可能在过期前被淘汰,导致同一请求被允许重放,防护直接失效。

步骤二:服务端签名校验中间件

下面的 Flask 中间件完成时间戳校验、签名比对、nonce 原子写入与失败限流。三个校验步骤必须全部通过才放行,任何一步失败都返回统一错误码,避免通过错误信息区分是时间戳过期还是签名错误,减少攻击者的探测收益。

import hashlib, hmac, time, redis
from flask import request, jsonify

rds = redis.Redis(host="127.0.0.1", port=6379, db=0, decode_responses=True)
TS_WINDOW = 300          # 时间戳有效期(秒)
NONCE_TTL = 300          # nonce 保留时长(秒)
APPS = {"app_demo": "a3f1c9e7b25d4086f1e2c7a9b3d5f806"}  # 生产环境改为数据库或配置中心

def build_sign_string(method, path, query, body_digest, ts, nonce, app_id):
    items = sorted((query or {}).items())
    qs = "&".join("%s=%s" % (k, v) for k, v in items)
    return "\n".join([method.upper(), path, qs, body_digest, str(ts), nonce, app_id])

def body_sha256(raw):
    return hashlib.sha256(raw or b"").hexdigest()

def verify_signature():
    app_id = request.headers.get("X-App-Id", "")
    ts = request.headers.get("X-Timestamp", "")
    nonce = request.headers.get("X-Nonce", "")
    sign = request.headers.get("X-Signature", "")
    secret = APPS.get(app_id)
    if not (app_id and ts and nonce and sign and secret):
        return False, "missing_params"
    if len(nonce) < 16 or len(nonce) > 64:
        return False, "bad_nonce"

    # 1) 时间戳窗口校验:拒绝过期与来自未来过久的请求
    try:
        delta = abs(time.time() - int(ts))
    except ValueError:
        return False, "bad_timestamp"
    if delta > TS_WINDOW:
        return False, "timestamp_expired"

    # 2) 签名比对:使用 compare_digest 做恒定时间比较
    raw = request.get_data() or b""
    digest = body_sha256(raw)
    expect = hmac.new(secret.encode(), build_sign_string(
        request.method, request.path, request.args.to_dict(),
        digest, ts, nonce, app_id).encode(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expect, sign.lower()):
        return False, "sign_mismatch"

    # 3) nonce 一次性写入:SET NX 保证并发下只有一个请求成功
    key = "replay:nonce:%s:%s" % (app_id, nonce)
    if not rds.set(key, "1", nx=True, ex=NONCE_TTL):
        return False, "replay_detected"
    return True, "ok"

@app.before_request
def guard():
    if request.path.startswith("/openapi/"):
        ok, reason = verify_signature()
        if not ok:
            return jsonify(code=4010, msg="signature rejected", reason=reason), 401

关键点有三处:时间戳使用 abs() 取绝对值,同时拦截过期与超前请求;签名比较使用 hmac.compare_digest,避免时序侧信道泄露;nonce 使用 SET NX EX 原子写入,绝不能用「先 GET 判断存在、再 SET」的两步写法,两步写法在高并发下存在竞态窗口。

步骤三:客户端签名生成

import hashlib, hmac, json, time, uuid, requests

APP_ID = "app_demo"
APP_SECRET = "a3f1c9e7b25d4086f1e2c7a9b3d5f806"

def sign_request(method, path, query, body):
    ts = str(int(time.time()))
    nonce = uuid.uuid4().hex                      # 32 位随机串,每次请求唯一
    raw = json.dumps(body, separators=(",", ":"), ensure_ascii=False).encode() if body else b""
    digest = hashlib.sha256(raw).hexdigest()
    qs = "&".join("%s=%s" % (k, query[k]) for k in sorted(query or {}))
    base = "\n".join([method.upper(), path, qs, digest, ts, nonce, APP_ID])
    sign = hmac.new(APP_SECRET.encode(), base.encode(), hashlib.sha256).hexdigest()
    headers = {"X-App-Id": APP_ID, "X-Timestamp": ts, "X-Nonce": nonce,
               "X-Signature": sign, "Content-Type": "application/json"}
    return headers, raw

headers, raw = sign_request("POST", "/openapi/v1/order", {"page": "1"}, {"sku": "A100", "num": 1})
print(requests.post("https://api.example.com/openapi/v1/order", data=raw, headers=headers).json())

客户端与服务端必须约定完全一致的序列化方式。若客户端用 json.dumps 默认空格分隔,服务端按紧凑格式计算摘要,两边摘要必然不同,表现为「签名永远校验失败」。建议在协议文档中明确写出紧凑序列化要求,并提供一个签名调试接口供调用方自检。

步骤四:Nginx 层限流与请求体保护

签名校验发生在应用层,攻击者仍可在校验前用大量非法请求消耗 CPU。在 Nginx 层做前置拦截可以显著降低后端压力:

limit_req_zone $binary_remote_addr zone=api_sign:10m rate=20r/s;

server {
    listen 443 ssl http2;
    server_name api.example.com;

    location ^~ /openapi/ {
        limit_req zone=api_sign burst=40 nodelay;
        limit_req_status 429;

        client_max_body_size 1m;              # 限制请求体大小,防大包拖慢摘要计算
        client_body_timeout 10s;
        client_header_timeout 10s;

        proxy_set_header X-Real-IP $remote_addr;
        proxy_pass http://127.0.0.1:8000;
    }
}

需要强调的是,限流只能降低资源消耗,不能替代签名校验。限流是概率性丢弃,签名与 nonce 才是确定性拦截。

步骤五:密钥轮换与幂等键

app_secret 一旦泄露,攻击者可以伪造任意请求。生产环境应支持双密钥并行:客户端同时持有主密钥与备用密钥,服务端在校验时按顺序尝试两个密钥,轮换完成后再下线旧密钥。轮换周期建议不超过 90 天,员工离职后立即轮换。

对于下单、支付等写操作,还需要在业务层增加幂等键:客户端为每个业务意图生成一个 Idempotency-Key,服务端用 SET NX EX 86400 占位,首次请求写入处理结果,重复请求直接返回首次结果而非报错。这样即使 nonce 机制被绕过,业务也不会重复执行。

配置验证

验证必须覆盖正常请求、重放请求、过期请求、篡改请求四类,缺一不可。下面的脚本按顺序执行这四项检查:

import time, requests
from your_client import sign_request

URL = "https://api.example.com/openapi/v1/order"
body = {"sku": "A100", "num": 1}

# 用例 1:正常请求,预期 code=4010 之外的业务成功码
h, raw = sign_request("POST", "/openapi/v1/order", {}, body)
r1 = requests.post(URL, data=raw, headers=h)
print("1 normal  ->", r1.status_code, r1.json())

# 用例 2:原样重放,预期 reason=replay_detected
r2 = requests.post(URL, data=raw, headers=h)
print("2 replay  ->", r2.status_code, r2.json())

# 用例 3:时间戳回拨 10 分钟,预期 reason=timestamp_expired
h3, raw3 = sign_request("POST", "/openapi/v1/order", {}, body)
h3["X-Timestamp"] = str(int(time.time()) - 600)
r3 = requests.post(URL, data=raw3, headers=h3)
print("3 expired ->", r3.status_code, r3.json())

# 用例 4:篡改 body 但保留原签名,预期 reason=sign_mismatch
h4, raw4 = sign_request("POST", "/openapi/v1/order", {}, body)
r4 = requests.post(URL, data=b'{"sku":"A100","num":99}', headers=h4)
print("4 tamper  ->", r4.status_code, r4.json())

预期结果:用例 1 返回业务成功;用例 2、3、4 均返回 HTTP 401,reason 分别为 replayed、expired、mismatch。同时用以下命令确认 nonce 键确实写入并带 TTL:

redis-cli --scan --pattern 'replay:nonce:*' | head
redis-cli TTL replay:nonce:app_demo:3f2a9c0e1b7d4a86b5c2d9e7f0a1b3c4
# 预期输出为一个接近 300 的正整数,每过一秒减一

常见问题

问题一:签名校验偶尔失败,但调用方坚称请求是合法的

先看服务端与客户端的时间偏差。时间戳窗口 300 秒看似宽松,若双方 NTP 同步源不同且服务器时钟漂移,边界请求就会偶发失败。执行 timedatectl status 与 chronyc tracking 检查偏差值,超过 1 秒就应修复同步。其次检查参数排序与序列化方式,常见坑是客户端把空值参数也参与拼接,而服务端在解析时把空值参数丢弃,两边签名串长度不同。最后确认网关是否改写了请求路径,例如 Nginx 用 rewrite 去掉了前缀,而客户端按原始路径签名。

问题二:时间戳窗口设多长才合适

窗口越长,容忍的时钟偏差越大,但攻击者可重放的时间也越长。实践值取 300 秒,即 5 分钟。对于支付类接口可收紧到 60 秒,前提是客户端强制使用 NTP 同步时间;对于弱网环境下的移动端,可放宽到 600 秒但必须配合更严格的 nonce 保留时长,让 nonce 的 TTL 与窗口一致。切勿把窗口设为 0 或极小值,那会让正常请求大面积失败。

问题三:Redis 故障时接口应该放行还是拒绝

取决于业务性质,需要在代码里显式选择而不是默默降级。资金类接口应选择「fail closed」,Redis 不可用时直接返回 503,宁可短时不可用也不能接受可能被重放的请求。查询类只读接口可以选择「fail open」并打出高优先级告警,用时间戳与签名两道校验兜底。无论选择哪种,都必须接入监控,Redis 连接异常的告警级别应与核心服务同级。

问题四:有了签名防重放,还需要限流和风控吗

需要,三者解决的是不同问题。签名防重放保证请求真实且未被重复使用;限流控制单位时间内的调用规模;风控识别行为是否符合正常业务模式,例如同一账户短时间内在多个城市下单。攻击者用合法密钥、合法签名高频调用正常接口,签名校验会全部通过,此时只有限流与风控能够拦住。

总结

API 接口签名防重放的核心是两条硬性约束:请求必须带时间戳并落入可接受窗口,请求的 nonce 必须在服务端一次性消费。工程实现上有四个不可妥协的细节:nonce 用 SET NX EX 原子写入、签名用 compare_digest 恒定时间比较、Redis 淘汰策略设为 noeviction、写操作叠加业务幂等键。把这四点落实,再配合 Nginx 限流与应用层风控,接口就能抵御录制重放这一类不依赖漏洞利用的攻击。