后端接口防重放攻击的核心不是简单地加一个时间戳,而是要在“请求唯一性”和“时间窗口有效性”之间建立强绑定关系。很多团队以为用了HTTPS就万事大吉,实际上HTTPS只解决传输加密和防篡改,并不阻止攻击者把截获的完整请求原样再发一次。重放攻击真正要防的是“合法请求的非法重复使用”,所以方案必须围绕“如何让每一条请求只能生效一次”来设计。

最常见的实现思路是“时间戳+随机数+签名”。客户端在发起请求时生成一个随机字符串(nonce),连同当前时间戳和服务端分配的身份标识一起参与签名,服务端收到后先校验时间戳是否在允许的窗口内,再检查这个nonce是否已经被使用过。如果时间戳过期或者nonce重复,直接拒绝。这个组合能挡住绝大多数重放,因为攻击者即使原样重放,时间戳会过期,nonce也会被服务端记录为已使用。

但这里有一个容易被忽视的细节:nonce的存储和清理策略。如果服务端把所有用过的nonce永久保存,存储量会无限增长。通常的做法是结合时间戳做分段存储,比如以分钟为单位把nonce放进对应的集合里,时间窗口一过就整体删除。这样既能保证窗口内的唯一性校验,又能自动清理过期数据。时间窗口一般设置为60秒到5分钟,太短会导致正常请求因网络延迟被误拒,太长又给攻击者留下重放空间。

时间戳校验的精度问题

服务端校验时间戳时不能只比较“是否小于当前时间”,必须允许一定的时钟偏差。客户端和服务器的系统时间可能不完全同步,移动端设备尤其常见。通常服务端会设置一个偏差容忍值,比如正负60秒。如果请求时间戳与服务器当前时间的差值超过这个范围,直接判定为过期。这个偏差值要结合业务场景调整,金融交易类接口可以收紧到30秒,普通查询接口放宽到120秒也没问题。

另一个关键点是时间戳必须参与签名,而不是作为独立参数传递。如果时间戳不参与签名,攻击者可以截获请求后只修改时间戳再重放,签名依然有效。正确做法是把时间戳、nonce、请求参数、请求路径、HTTP方法全部拼接成一个规范字符串,再用密钥生成签名。服务端收到请求后用同样的方式重新计算签名,任何一项不一致都会导致签名校验失败。

nonce的生成与校验

nonce的生成必须保证足够随机,不能使用简单的递增数字或者可预测的序列。推荐使用UUID或者加密安全的随机数生成器,长度至少128位。客户端每次请求都要生成全新的nonce,不能复用。服务端校验nonce时,需要在对应时间窗口的存储集合中查找是否已存在,如果存在则拒绝请求。这里有一个并发问题:同一个nonce可能在极短时间内被并发提交两次,服务端需要保证检查和写入的原子性。可以使用Redis的SETNX命令或者数据库的唯一约束来实现。

对于高并发系统,nonce的存储和查询会带来额外开销。一个优化方案是把nonce的存储放在Redis中,key设置为nonce本身,value存时间戳,设置过期时间等于时间窗口长度。这样Redis会自动清理过期数据,不需要额外写定时任务。如果请求量特别大,还可以对nonce做哈希分片,分散到多个Redis实例上,避免单点压力。

签名算法的选择

签名算法推荐使用HMAC-SHA256,而不是简单的MD5或者SHA1。HMAC基于密钥的哈希运算,安全性更高,而且计算速度快,适合接口鉴权场景。密钥的管理要严格,每个客户端分配独立的密钥,服务端存储密钥的哈希值而不是明文。如果密钥泄露,攻击者就能伪造签名,所以密钥需要定期轮换,并且支持紧急吊销。

签名计算时要注意参数排序问题。如果客户端和服务端对参数的排序方式不一致,会导致签名校验失败。通常的做法是对所有参数名按字典序排序,然后拼接成key=value的字符串,用&连接,最后加上时间戳和nonce。请求体如果是JSON,需要先转换成字符串,并且保证客户端和服务端的序列化结果完全一致。建议使用紧凑格式的JSON,不要包含多余空格和换行。

防重放与幂等性的区别

很多人把防重放和接口幂等性混为一谈,实际上两者解决的是不同层面的问题。防重放关注的是“同一个请求不能被重复执行”,而幂等性关注的是“同一个业务操作执行多次结果一致”。比如支付接口,防重放可以阻止攻击者把一次成功的支付请求原样重放导致重复扣款,但如果是客户端因为网络超时主动重试,防重放机制可能会把第二次请求也拒绝掉,这时候就需要幂等性来保证业务正确。实际项目中,两者通常配合使用:防重放挡住恶意重放,幂等性保证正常重试不会产生副作用。

实现幂等性的常见做法是引入业务唯一标识,比如订单号或者客户端生成的请求ID。服务端在处理请求前先检查这个标识是否已经处理过,如果处理过直接返回上次的结果。这个标识需要存储在数据库或者缓存中,并且和业务数据关联。防重放的nonce和幂等的业务标识可以合并设计,但要注意nonce是短期有效的,而幂等标识通常需要保存较长时间,甚至永久保存。

针对特定场景的增强措施

对于安全性要求极高的接口,比如银行转账、密码修改,单纯的时间戳+nonce+签名还不够。可以增加一次性令牌机制,客户端在发起关键操作前先向服务端申请一个短期有效的一次性令牌,服务端把这个令牌和用户会话绑定,使用一次后立即失效。这个令牌通过独立的安全通道下发,比如短信或者推送。即使攻击者截获了完整请求,没有有效的一次性令牌也无法重放。

另一个增强措施是绑定请求的上下文信息。比如把用户ID、设备指纹、IP地址等标识参与签名计算。服务端校验时除了验证签名,还要检查这些上下文信息是否与当前会话匹配。如果攻击者从不同IP重放请求,即使签名正确也会因为IP不匹配被拒绝。不过IP绑定要谨慎使用,移动网络下用户IP可能频繁变化,容易误伤正常请求。

代码实现示例

下面是一个基于Java的防重放校验核心逻辑示例,展示了时间戳、nonce和签名的综合校验流程:

public boolean validateRequest(String timestamp, String nonce, String signature, 
                               String requestPath, Map params, String clientKey) {
    long currentTime = System.currentTimeMillis();
    long requestTime;
    try {
        requestTime = Long.parseLong(timestamp);
    } catch (NumberFormatException e) {
        return false;
    }
    
    // 时间窗口校验,允许120秒偏差
    if (Math.abs(currentTime - requestTime) > 120000) {
        return false;
    }
    
    // nonce唯一性校验,使用Redis的SETNX保证原子性
    String nonceKey = "nonce:" + nonce;
    boolean nonceExists = redisTemplate.opsForValue().setIfAbsent(nonceKey, "1", 120, TimeUnit.SECONDS);
    if (!nonceExists) {
        return false;
    }
    
    // 重新计算签名
    String expectedSignature = generateSignature(timestamp, nonce, requestPath, params, clientKey);
    return expectedSignature.equals(signature);
}

这个示例中,时间戳校验和nonce唯一性校验都在签名校验之前完成,这样可以快速拒绝明显非法的请求,减少不必要的签名计算开销。nonce的存储使用了Redis的setIfAbsent方法,同时设置了120秒的过期时间,与时间窗口保持一致。

常见错误与规避

第一个常见错误是只校验时间戳不校验nonce。有些开发者认为时间戳过期了请求自然就失效了,但实际上攻击者可以在时间窗口内快速重放,时间戳校验根本拦不住。nonce的唯一性校验是防重放的核心,不能省略。

第二个常见错误是把nonce存储在应用内存中。单机部署时看起来没问题,但一旦服务做水平扩展,多个实例之间的nonce记录不共享,攻击者可以把请求发给不同的实例来绕过校验。nonce存储必须放在共享存储中,比如Redis、数据库或者分布式缓存。

第三个常见错误是签名算法过于简单。有些团队直接用参数拼接后做MD5,攻击者可以通过分析多个请求推算出签名规则。必须使用带密钥的HMAC算法,并且密钥不能出现在客户端代码中。移动端App的密钥可以通过安全存储或者动态下发的方式获取,不能硬编码在安装包里。

第四个常见错误是时间窗口设置不合理。窗口太长,重放攻击的成功率就高;窗口太短,正常用户因为网络延迟或者设备时间不准被频繁拒绝。需要根据业务容忍度和安全要求做平衡,并且可以在服务端记录时间戳偏差的分布情况,动态调整窗口大小。

监控与应急响应

防重放机制上线后,需要监控nonce冲突率、时间戳过期率、签名失败率等指标。nonce冲突率突然升高可能意味着有攻击者在尝试重放,时间戳过期率异常升高可能意味着客户端时间同步有问题或者有攻击者在用旧请求试探。签名失败率持续偏高则可能意味着密钥泄露或者客户端实现有bug。

一旦发现重放攻击迹象,可以临时收紧时间窗口、缩短nonce过期时间、增加额外的上下文校验。如果确认某个客户端的密钥已经泄露,需要立即吊销该密钥,并要求客户端重新申请。对于高风险接口,可以启用动态挑战机制,比如要求客户端在收到服务端下发的随机挑战值后,把挑战值也参与签名计算,这样即使攻击者截获了历史请求,没有当前有效的挑战值也无法重放。

防重放攻击本质上是一个系统性的安全设计,不是加一个中间件就能一劳永逸。时间戳、nonce、签名、存储策略、密钥管理、监控响应这些环节环环相扣,任何一个环节薄弱都会导致整体防线失效。实际落地时,建议把防重放逻辑封装成独立的过滤器或者拦截器,统一处理所有需要防护的接口,避免每个业务模块重复实现导致标准不一致。