后端接口的 “防重放” 设计:抵御重复请求的安全屏障

404 阅读3分钟

在分布式系统中,“重放攻击” 是常见的安全威胁 —— 攻击者截获并重复发送合法请求(如支付、下单),可能导致用户重复支付、订单重复创建。防重放机制通过为请求添加 “时效性标识”,确保同一请求只能在有效时间内使用一次,成为抵御这类攻击的关键防线。

防重放的核心原理

防重放的核心是 “让请求具有唯一性和时效性”,实现逻辑:

  1. 客户端生成唯一随机数(Nonce)和当前时间戳(Timestamp)
  2. 将 Nonce、Timestamp、请求参数与密钥一起签名
  3. 服务端验证签名有效性、时间戳是否在有效期内(如 5 分钟)、Nonce 是否已使用过
  4. 验证通过则处理请求,并将 Nonce 存入 Redis 标记为已使用(设置与时间戳相同的过期时间)

实战实现方案

1. 签名 + 时间戳 + Nonce 三重验证

客户端实现(伪代码)

// 生成随机数(32位UUID)
const nonce = uuid.v4().replace(/-/g, '');
// 生成时间戳(毫秒级)
const timestamp = Date.now();
// 请求参数
const params = { orderId: 123, amount: 100 };
// 拼接待签名字符串(参数按key排序)
const sortedParams = Object.keys(params).sort().map(key => `${key}=${params[key]}`).join('&');
const signStr = `${sortedParams}&nonce=${nonce}&timestamp=${timestamp}&secret=${clientSecret}`;
// SHA256签名
const sign = sha256(signStr);
// 发送请求(参数+nonce+timestamp+sign)
axios.post('/api/pay', { ...params, nonce, timestamp, sign });

服务端实现(Java)

@PostMapping("/api/pay")
public Result pay(@RequestBody PayRequest request) {
    // 1. 验证时间戳是否在有效期内(5分钟)
    long now = System.currentTimeMillis();
    if (now - request.getTimestamp() > 5 * 60 * 1000) {
        return Result.fail("请求已过期,请重新发起");
    }
    
    // 2. 验证Nonce是否已使用(Redis)
    String nonceKey = "replay:nonce:" + request.getNonce();
    Boolean isExist = redisTemplate.hasKey(nonceKey);
    if (Boolean.TRUE.equals(isExist)) {
        return Result.fail("重复请求,请不要重复提交");
    }
    
    // 3. 验证签名
    String serverSign = generateSign(request.getParams(), request.getNonce(), request.getTimestamp(), serverSecret);
    if (!serverSign.equals(request.getSign())) {
        return Result.fail("签名无效");
    }
    
    // 4. 标记Nonce为已使用(设置5分钟过期)
    redisTemplate.opsForValue().set(nonceKey, "1", 5, TimeUnit.MINUTES);
    
    // 5. 处理支付逻辑
    paymentService.process(request);
    return Result.success("支付成功");
}

// 服务端生成签名(与客户端逻辑一致)
private String generateSign(Map<String, Object> params, String nonce, long timestamp, String secret) {
    List<String> paramList = new ArrayList<>();
    params.forEach((k, v) -> paramList.add(k + "=" + v));
    Collections.sort(paramList);
    String sortedParams = String.join("&", paramList);
    String signStr = sortedParams + "&nonce=" + nonce + "&timestamp=" + timestamp + "&secret=" + secret;
    return DigestUtils.sha256Hex(signStr);
}

2. 令牌机制:一次性 Token 防重放

对于用户登录态下的请求,可结合 Token 实现防重放:

  1. 用户登录后,服务端生成临时 Token(如 JWT),包含用户 ID 和过期时间

  2. 客户端每次请求时携带 Token,服务端验证 Token 有效性

  3. 服务端处理请求后,使当前 Token 失效,返回新 Token 给客户端

  4. 客户端下次请求使用新 Token,确保 Token 只能用一次

优势:无需维护 Nonce,适合高频交互场景(如 WebSocket 通信)

防重放的注意事项

1. 时间同步与容差

  • 客户端与服务端时间可能存在偏差,时间戳验证需设置合理容差(如 30 秒)
  • 避免使用本地时间,建议客户端从服务端获取标准时间

2. Redis 高可用

  • Nonce 和 Token 的存储依赖 Redis,需确保 Redis 主从同步和持久化配置,避免单点故障
  • 可使用 Redis 集群 + 哨兵模式,确保服务可用性

3. 性能优化

  • Nonce 生成可简化(如 16 位随机字符串),减少计算开销
  • 对高频接口,可批量验证 Nonce(如一次验证多个 Nonce 是否存在)

避坑指南

  • 不要忽略 HTTPS:防重放机制需配合 HTTPS 使用,避免参数和签名被中间人篡改

  • 密钥定期轮换:客户端与服务端的密钥需定期更新,降低泄露风险

  • 避免 Nonce 长度过短:短 Nonce 可能被暴力破解,建议至少 16 位随机字符串

防重放设计看似增加了系统复杂度,却是高安全级别接口(如支付、转账)的必备机制。它通过数学手段确保请求的唯一性,让攻击者即使截获请求,也无法从中获利,这正是后端安全设计 “防患于未然” 的体现。