如何防止 API 接口重放攻击实现 nonce 机制验证?

文章导读
防止 API 重放攻击最可靠的方案是 Nonce+Timestamp+ 签名三者组合,其中 Nonce 必须配合 Redis 原子校验才能保证真正有效,单机内存方案仅适合低并发内部接口。
📋 目录
  1. 快速处理思路
  2. 代码实现示例
  3. 完整请求报文示例
  4. 怎么验证是否生效
  5. 常见坑
A A

防止 API 重放攻击最可靠的方案是 Nonce+Timestamp+ 签名三者组合,其中 Nonce 必须配合 Redis 原子校验才能保证真正有效,单机内存方案仅适合低并发内部接口。

先说结论:只校验时间戳无法防重放,必须引入服务端可验证的一次性随机数 (Nonce) 并用 Redis 做原子性存储校验。

  • 先判断:确认业务是否涉及资金、订单等不可重复操作,这类接口必须上防重放
  • 优先做:客户端每次请求生成新 Nonce,服务端用 Redis SET NX EX 命令原子校验
  • 再验证:用相同请求参数重复提交,确认第二次被拒绝才算生效

快速处理思路

这不是靠一条命令能解决的问题,需要客户端和服务端配合改造。核心流程是:客户端生成随机 Nonce 和时间戳→两者一起参与签名→服务端先验时间窗口→再用 Redis 检查 Nonce 是否已使用→通过后才处理业务。

Redis 校验命令示例(伪代码):

如何防止 API 接口重放攻击实现 nonce 机制验证?
SET nonce:{userId}:{nonceValue} 1 EX 300 NX

返回 1 表示首次使用,返回 nil 表示已存在即重放请求。在生产环境中,建议使用 Lua 脚本保证校验与设置的原子性,防止高并发竞态。

代码实现示例

以下是基于 Java 服务端和 Node.js 客户端的核心实现逻辑,重点展示 Nonce 生成与签名验证。

1. 客户端生成 Nonce 与签名 (Node.js)

如何防止 API 接口重放攻击实现 nonce 机制验证?
const crypto = require('crypto');

function generateSecurityHeaders(payload, secretKey) {
    // 生成 32 位随机 Nonce
    const nonce = crypto.randomBytes(16).toString('hex');
    // 生成毫秒级时间戳
    const timestamp = Date.now().toString();
    
    // 构造签名字符串:方法 +URI+ 时间戳 +Nonce+ 参数排序
    const sortedParams = Object.keys(payload).sort().map(k => `${k}=${payload[k]}`).join('&');
    const signStr = `POST:/api/order:${timestamp}:${nonce}:${sortedParams}`;
    
    // HMAC-SHA256 签名
    const sign = crypto.createHmac('sha256', secretKey)
                       .update(signStr)
                       .digest('hex');
    
    return {
        headers: {
            'X-Nonce': nonce,
            'X-Timestamp': timestamp,
            'X-Sign': sign
        }
    };
}

2. 服务端验证逻辑 (Java Spring)

@Autowired
private StringRedisTemplate redisTemplate;

public boolean verifyRequest(String userId, String nonce, long timestamp, String sign, Map params) {
    // 1. 校验时间窗口 (±5 分钟)
    long now = System.currentTimeMillis();
    if (Math.abs(now - timestamp) > 5 * 60 * 1000) {
        throw new SecurityException("Timestamp expired");
    }
    
    // 2. Redis 原子校验 Nonce (SETNX)
    String key = "nonce:" + userId + ":" + nonce;
    Boolean isNew = redisTemplate.opsForValue().setIfAbsent(key, "1", 6, TimeUnit.MINUTES);
    if (Boolean.FALSE.equals(isNew)) {
        throw new SecurityException("Nonce reused");
    }
    
    // 3. 验证签名
    String expectedSign = calculateSign(userId, timestamp, nonce, params);
    if (!expectedSign.equals(sign)) {
        throw new SecurityException("Invalid signature");
    }
    
    return true;
}

完整请求报文示例

实际 HTTP 请求中,安全参数通常放在 Header 中,业务参数放在 Body 中。以下是一个合法的 POST 请求示例:

POST /api/v1/order/create HTTP/1.1
Host: api.example.com
Content-Type: application/json
X-Nonce: a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
X-Timestamp: 1715623456789
X-Sign: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08

{
    "productId": "1001",
    "count": 1
}

怎么验证是否生效

使用 curl 发送一个合法请求,记录完整的请求参数和签名。然后用完全相同的参数再发一次,第二次应该返回 401 或 403 错误,提示 Nonce 已使用或重复请求。

如何防止 API 接口重放攻击实现 nonce 机制验证?

第一次请求(成功):

curl -X POST https://api.example.com/order \
  -H "X-Nonce: unique_nonce_001" \
  -H "X-Timestamp: 1715623456789" \
  -H "X-Sign: valid_signature" \
  -d '{"id": 1}'

第二次请求(重放拦截):

curl -X POST https://api.example.com/order \
  -H "X-Nonce: unique_nonce_001" \
  -H "X-Timestamp: 1715623456789" \
  -H "X-Sign: valid_signature" \
  -d '{"id": 1}'
# 预期返回:403 Forbidden - Nonce already used

检查 Redis 中是否有对应的 Nonce key,命令:GET nonce:{userId}:{nonceValue}。第一次请求后应该有值,第二次请求后值不变但请求被拒绝。查看服务端日志,确认重放请求在 Nonce 校验阶段就被拦截,没有进入业务逻辑层。

常见坑

  • Nonce 生成固定:把 Nonce 写死在客户端代码里,每次请求用同一个值,这样第一个请求成功后后续所有请求都会被当成重放。Nonce 必须每次请求重新生成,推荐使用语言内置的安全随机数 API。
  • 竞态条件:先 GET 再 SET 检查 Nonce 是否存在,中间有竞态窗口,高并发下可能两个相同 Nonce 的请求都通过校验。必须用 SET NX 原子操作或 Lua 脚本。
  • 参数缺失:签名原文里包含 Nonce 和时间戳,但 HTTP 请求中不传这两个参数,服务端无法提取校验。这两项必须明文传输。
  • 存储选型错误:用 MySQL 存储 Nonce,高并发下写入延迟会导致漏判。Redis 的内存操作和原子命令是唯一靠谱方案。
  • 时间窗口过长:时间窗口设得太长,比如 1 小时,这样攻击者有 1 小时的时间重放请求。通常 5 分钟足够覆盖网络延迟和服务处理时间。
  • 集群共享问题:多实例部署却用本地内存存 Nonce,攻击者轮询不同实例就能绕过。必须用 Redis 等共享存储。