防止 API 重放攻击最可靠的方案是 Nonce+Timestamp+ 签名三者组合,其中 Nonce 必须配合 Redis 原子校验才能保证真正有效,单机内存方案仅适合低并发内部接口。
先说结论:只校验时间戳无法防重放,必须引入服务端可验证的一次性随机数 (Nonce) 并用 Redis 做原子性存储校验。
- 先判断:确认业务是否涉及资金、订单等不可重复操作,这类接口必须上防重放
- 优先做:客户端每次请求生成新 Nonce,服务端用 Redis SET NX EX 命令原子校验
- 再验证:用相同请求参数重复提交,确认第二次被拒绝才算生效
快速处理思路
这不是靠一条命令能解决的问题,需要客户端和服务端配合改造。核心流程是:客户端生成随机 Nonce 和时间戳→两者一起参与签名→服务端先验时间窗口→再用 Redis 检查 Nonce 是否已使用→通过后才处理业务。
Redis 校验命令示例(伪代码):
SET nonce:{userId}:{nonceValue} 1 EX 300 NX返回 1 表示首次使用,返回 nil 表示已存在即重放请求。在生产环境中,建议使用 Lua 脚本保证校验与设置的原子性,防止高并发竞态。
代码实现示例
以下是基于 Java 服务端和 Node.js 客户端的核心实现逻辑,重点展示 Nonce 生成与签名验证。
1. 客户端生成 Nonce 与签名 (Node.js)
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 已使用或重复请求。
第一次请求(成功):
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 等共享存储。