Node.js 接口防重放攻击怎么配置异步签名验证中间件保障安全

文章导读
防止接口重放攻击,最稳妥的方案是在网关或业务层中间件引入“时间戳 + 随机数 + 签名”的异步验证机制,适合对安全性要求较高的支付、用户信息修改等场景。
📋 目录
  1. 快速处理思路
  2. 为什么会这样
  3. 分步处理
  4. 怎么验证是否生效
  5. 常见坑
  6. 参考来源
A A

防止接口重放攻击,最稳妥的方案是在网关或业务层中间件引入“时间戳 + 随机数 + 签名”的异步验证机制,适合对安全性要求较高的支付、用户信息修改等场景。

先说结论:单纯靠 HTTPS 无法防止重放,必须在应用层通过中间件校验请求的时效性和唯一性。核心在于使用 Redis 原子操作(SETNX)缓存随机数,避免并发竞态条件。

  • 先判断:确认接口是否涉及敏感操作或资产变动,普通查询接口通常无需如此严格的校验。
  • 优先做:在 Node.js 中间件层统一拦截,校验时间戳窗口和随机数(Nonce)是否已存在。
  • 再验证:通过重复发送同一请求测试,确保第二次请求被明确拒绝且日志有记录。

快速处理思路

由于这是代码配置而非 shell 命令,建议按以下逻辑流快速落地:

  1. 客户端生成请求参数签名(含时间戳、随机数)。
  2. 服务端中间件解析请求头,提取签名要素。
  3. 异步查询 Redis 确认随机数未过期且未使用(需原子操作)。
  4. 校验签名匹配后,将随机数写入 Redis 并设置过期时间。

为什么会这样

重放攻击的本质是攻击者截获了合法的请求数据包,并在后续时间原封不动地再次发送给服务器。如果服务器只验证签名正确性而不验证请求的新鲜度,攻击者就可以利用旧的有效请求重复执行操作,比如重复转账或重复领取优惠券。

引入时间戳是为了限制请求的有效窗口期(例如前后 5 分钟),引入随机数(Nonce)是为了确保在该窗口期内每个请求都是唯一的。异步验证主要是为了不阻塞主线程,利用 Redis 等缓存中间件快速查询随机数状态,避免每次校验都查数据库影响性能。

分步处理

以下以 Express 框架为例,展示如何编写一个异步签名验证中间件。

1. 准备依赖

需要 crypto 模块进行签名计算(Node.js 内置),以及 redis 客户端用于存储随机数。

npm install ioredis express

2. 编写中间件逻辑

创建一个 middleware 文件,核心是校验时间戳和查询 Redis。注意:必须使用原子操作防止并发竞态,且需处理时间戳单位差异。

const crypto = require('crypto');
const Redis = require('ioredis');
const redis = new Redis();

// 辅助函数:对象键排序,确保签名一致性
function sortedStringify(obj) {
  return JSON.stringify(obj, Object.keys(obj).sort());
}

const replayProtection = async (req, res, next) => {
  // 注意:客户端通常发送秒级时间戳,服务器 Date.now() 为毫秒,需转换
  const clientTs = parseInt(req.headers['x-timestamp']); 
  const timestamp = clientTs * 1000; 
  const nonce = req.headers['x-nonce'];
  const signature = req.headers['x-signature'];
  const secret = process.env.API_SECRET;

  // 1. 校验时间戳窗口(假设允许前后 5 分钟)
  const now = Date.now();
  if (Math.abs(now - timestamp) > 5 * 60 * 1000) {
    return res.status(401).json({ error: 'Request timestamp expired' });
  }

  // 2. 原子检查随机数是否已存在 (SETNX 模式)
  const redisKey = `nonce:${nonce}`;
  // 'NX' 表示仅当不存在时设置,'EX' 设置过期时间 (秒)
  const result = await redis.set(redisKey, '1', 'NX', 'EX', 360);
  if (!result) {
    return res.status(403).json({ error: 'Replay attack detected' });
  }

  // 3. 验证签名(客户端需按相同规则生成,注意对象键序)
  const signStr = `${timestamp}${nonce}${sortedStringify(req.body)}`;
  const expectedSign = crypto.createHmac('sha256', secret).update(signStr).digest('hex');
  
  if (signature !== expectedSign) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  next();
};

3. 挂载中间件

在需要保护的路由前使用该中间件。关键:确保 body 解析中间件在签名验证之前加载,否则 req.body 为空导致签名校验失败。

const express = require('express');
const app = express();

// 必须先解析 body
app.use(express.json()); 
// 再挂载安全中间件
app.use('/api/secure', replayProtection);

回滚提醒:如果上线后发现大量合法请求被拦截,优先检查服务器时间是否同步,或临时调大时间戳窗口。

怎么验证是否生效

不要只看代码,要用工具模拟攻击行为。

1. 正常请求测试

Node.js 接口防重放攻击怎么配置异步签名验证中间件保障安全

使用 Postman 或 curl 发送带正确签名、时间戳和随机数的请求,确认返回 200。

curl -X POST http://localhost:3000/api/secure \
  -H "Content-Type: application/json" \
  -H "x-timestamp: 1715623000" \
  -H "x-nonce: unique-id-123" \
  -H "x-signature: correct_sig_hex" \
  -d '{"key":"value"}'

2. 重放请求测试

保持所有参数不变,再次发送完全相同的请求。此时中间件应拦截并返回 403 错误,且 Redis 中该 nonce 键已存在。

3. 日志检查

查看应用日志,确认拦截记录中包含了"Replay attack detected"或类似标识,方便后续监控报警。

常见坑

1. 并发竞态条件

先 GET 后 SET 非原子操作,高并发下两个相同请求可能同时通过检查。必须使用 Redis SETNX 或 Lua 脚本保证原子性。

2. 服务器时间不同步

如果集群中多台 Node.js 实例时间不一致,会导致时间戳校验失败。建议所有服务器配置 NTP 时间同步。

3. 签名算法不一致

客户端和服务端的字符串拼接顺序、编码格式(UTF-8)必须完全一致。JSON 对象键序不确定,建议后端校验前先对键排序。

4. 忽略 HTTPS

签名验证只能防篡改和重放,不能防窃听。如果未启用 HTTPS,攻击者可直接获取密钥或明文数据,签名机制将失效。

参考来源

  • OWASP Cheat Sheet Series, "Authentication Cheat Sheet", https://owasp.org/
  • Node.js Official Documentation, "Crypto Module", https://nodejs.org/