1. 先搞清楚重放攻击到底在打什么
1.1 一个场景让我彻底记住了重放攻击
大概半年前,我接手一个线上接口的维护任务,是一个用户积分兑换的接口。逻辑很简单,前端点一下兑换,后端校验用户身份、扣积分、发奖品。上线一个多月,一切正常。
直到有一天运营发现,积分消耗量比兑换记录多了好几倍。查日志之后发现,有人在短短几分钟内把同一个兑换请求原封不动重发了上百次——不修改任何参数,不改任何签名,就是拿到你正常的请求报文,不断重复发送。后端因为每次收到的都是一个完整合法、签名正确的请求,所以每次都当作一次新的兑换来处理。奖品被刷走了一大批。
这件事让我把“防重放攻击”从一个理论概念,直接提升到了“上线前必须检查的安全项”。虽然通过后续的限流、幂等等手段做了补救,但我当时就意识到:防重放不是某一个中间件或者某一个框架自带的魔法,而是一套需要在设计阶段就考虑进来的完整机制。
1.2 为什么简单的鉴权拦不住重放
很多人第一反应是:接口不是有登录鉴权吗?有Token校验、有签名验签,为什么还会中招?
这里要分清两个概念:身份认证和请求唯一性。身份认证解决的问题是“发送者是谁”,签名验签解决的问题是“数据有没有被篡改”。但这两个都没解决“这个请求是不是第一次出现在系统里”的问题。
重放攻击的本质是:拿一个曾经合法的请求,在另一个时间点再次发送,让服务端以为是新请求。只要请求本身依然合法、签名依然有效、用户身份依然有效,后端就无法区分“这是用户刚发的第二次操作”还是“攻击者在重放以前的请求”。
你可能会说,那我加个时间戳不就行了?时间戳确实能拦住一部分,但单独用时间戳有一个致命问题:攻击者在时间窗口内重放依然有效。比如你允许10秒内的请求,攻击者拿到请求后的第3秒就重放,照样通过。这也是为什么成熟的防重放方案,需要把时间戳和随机数结合起来用。
2. 防重放方案的选型思路与核心设计
2.1 三种常见方案的优劣对比
我在设计防重放方案的时候,调研了几种主流做法,这里直接给你一个我自己的对比结论:
| 方案 | 核心机制 | 优点 | 缺点 |
|---|---|---|---|
| 单纯时间戳 | 服务端校验请求时间与当前时间的差值 | 实现简单,无额外存储 | 窗口期内可重放,攻击窗口明显 |
| 时间戳 + 随机数(Nonce) | 每种请求带随机数,服务端存储已用Nonce | 能拦截窗口内重放,可靠性高 | 需要存储介质,存在存储开销 |
| 业务幂等键 | 利用业务字段(如订单号)保证同一业务只执行一次 | 直接落在业务层面,无额外请求层逻辑 | 覆盖面有限,无法防接口级别的恶意重放 |
我个人最终选了“时间戳 + Nonce + 签名”的组合方案。原因很简单:时间戳解决时效性问题,Nonce解决唯一性问题,签名解决完整性和防篡改问题,三者组合起来,既覆盖了重放攻击的常见路径,又没有过度侵入业务代码。
2.2 方案落地前必须明确的四个问题
选型只是第一步。真正动手写代码之前,我建议你先想清楚下面这四个问题,否则代码写一半很容易推倒重来。
第一个问题是Nonce存哪里。防重放最核心的环节就是记住“这个随机数已经用过了”,所以必须有一个存储介质。小流量的单体项目可以直接用本地内存(比如ConcurrentHashMap),但要注意重启丢失的问题;分布式项目建议直接用Redis,后面我也会给出具体实现。
第二个问题是时间窗口设多大。窗口太小,正常用户稍微有点网络延迟就会误伤;窗口太大,攻击者重放的时间空间就越大。我的经验是,一般的后端接口设5分钟比较合适,内部系统可以缩小到60秒,对外强安全接口建议30秒到2分钟。这个没有标准答案,一个基本判断标准是:你的客户端和服务端时钟误差一般在多少,再加上合理的网络波动时间。
第三个问题是服务端和客户端的时间不一致怎么办。时间戳校验比较的是客户端带过来的时间和服务端当前时间的差值,如果两台机器时钟差得太大,正常请求也会被判成非法。除非你有能力控制所有客户端的时钟同步,否则我建议,在校验时留一个误差缓冲——比如你设了5分钟窗口,实际上可以在内部再加30到60秒的容忍值。
第四个问题是签名中应该包含哪些字段。签名不仅可以防篡改,还能有效防止攻击者直接改掉Nonce或时间戳。如果把时间戳和Nonce本身也纳入签名内容,攻击者只要改动任何一个字段,签名验不过去,重放就无从谈起。这一点非常关键。
3. 核心实现:从生成防重放Token到拦截校验代码
3.1 客户端如何生成防重放Token
先站在客户端的角度,看一个合法的防重放Token是怎么生成的。我在项目中用的方式是:客户端在发起请求之前,组装一个字符串,包含当前时间戳、一个UUID随机数,以及业务请求参数的摘要值,然后用双方约定的密钥做签名。
import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.Base64; import java.util.UUID; public class ReplayTokenGenerator { private static final String HMAC_ALGORITHM = "HmacSHA256"; public static String generate(String businessParamsDigest, String secret) throws Exception { long timestamp = Instant.now().getEpochSecond(); String nonce = UUID.randomUUID().toString().replace("-", ""); String raw = timestamp + ":" + nonce + ":" + businessParamsDigest; String sign = hmacSha256(raw, secret); return timestamp + ":" + nonce + ":" + sign; } private static String hmacSha256(String data, String secret) throws Exception { Mac mac = Mac.getInstance(HMAC_ALGORITHM); SecretKeySpec keySpec = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM); mac.init(keySpec); byte[] result = mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(result); } }这段代码里,时间戳用秒,Nonce用UUID去横线后的字符串,业务参数摘要的作用是确保请求的核心参数没有被篡改。签名的时候,把这三个部分按固定分隔符拼成一个原始串再计算HMAC,这样客户端带过来的Token就是“时间戳:随机数:签名”的格式。
3.2 服务端校验的核心流程
服务端拿到这个Token之后,要做四件事:
第一,解析Token,取出时间戳、Nonce和签名。如果Token格式不对,直接拒绝。
第二,校验时间戳。拿当前时间减去Token里的时间戳,如果绝对值超过允许窗口,直接拒绝。
第三,校验签名。重新使用业务参数摘要和约定的密钥计算HMAC,判断和Token里的签名是否一致。签名不一致,说明Token被篡改过,拒绝。
第四,也是最关键的一步,拿Nonce去存储介质里查询。如果已经存在,说明这个请求是重复请求,拒绝;如果不存在,则把当前Nonce写入存储并设置失效时间,表示这个Nonce已经被“消费”了。
用伪代码描述校验逻辑,你能看得更清楚:
boolean validate(String token, String businessParamsDigest) { 解析token -> timestamp, nonce, sign if (timestamp超出时间窗口) return false; if (!hmac(timestamp:nonce:businessParamsDigest).equals(sign)) return false; if (redis.exists(nonce)) return false; redis.set(nonce, "1", TTL); return true; }有人可能会问,Nonce在存储里多久删除比较合适?我的建议是,TTL直接和时间窗口保持一致,最多稍微长一点。因为时间戳校验已经保证了这个Nonce只会在窗口期内被接受,窗口期过了之后,就算Nonce还在存储里,校验也会先因为时间戳过期而拒绝,不会走到查重这一步。
3.3 拦截器实现:用注解还是用Filter
在Spring Boot项目中,实现防重放校验有两种常见方式:自定义注解配合拦截器,或者直接用Servlet的Filter。两种都有各自的使用场景。
我推荐的方式是:基础防重放逻辑做成一个HandlerInterceptor,再用自定义注解决定哪些接口开启防重放。原因是,Filter是Servlet层面的,对DispatcherServlet之外的路径也会生效,容易把静态资源、健康检查接口也纳入进来,造成不必要的干扰。而Interceptor结合注解,逻辑清晰,控制粒度更灵活。
下面是一个注解的示例:
import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface ReplayProtect { long windowSeconds() default 300L; }再配一个拦截器,核心逻辑放在preHandle里:
import org.springframework.web.servlet.HandlerInterceptor; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; public class ReplayProtectInterceptor implements HandlerInterceptor { private final ReplayTokenService replayTokenService; public ReplayProtectInterceptor(ReplayTokenService replayTokenService) { this.replayTokenService = replayTokenService; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { ReplayProtect annotation = getAnnotation(handler, ReplayProtect.class); if (annotation == null) { return true; } String token = request.getHeader("X-Replay-Token"); String businessParamsDigest = request.getHeader("X-Business-Digest"); if (token == null || businessParamsDigest == null) { response.setStatus(HttpServletResponse.SC_BAD_REQUEST); response.getWriter().write("missing replay token or digest"); return false; } boolean valid = replayTokenService.validate(token, businessParamsDigest, annotation.windowSeconds()); if (!valid) { response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.getWriter().write("request rejected: expired or replayed"); return false; } return true; } private <T> T getAnnotation(HandlerMethodInfo handler, Class<T> annotationClass) { // 从handler中解析方法级注解,省略细节 return null; } }这里需要说明一下,我没有完整贴出注解解析细节,因为不同项目的Handler包装方式不一样。实际开发时,HandlerMethod类型的handler可以直接调用getMethodAnnotation方法,拿到方法上的ReplayProtect注解,然后做后续判断。
3.4 Redis存储Nonce的完整实现
前面说了推荐使用Redis,这里给出我的实现思路:
import org.springframework.data.redis.core.StringRedisTemplate; import java.time.Duration; public class ReplayTokenService { private final StringRedisTemplate redisTemplate; public ReplayTokenService(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } public boolean validate(String token, String businessParamsDigest, long windowSeconds) { String[] parts = token.split(":"); if (parts.length != 3) { return false; } long timestamp = Long.parseLong(parts[0]); String nonce = parts[1]; String sign = parts[2]; long current = System.currentTimeMillis() / 1000; long tolerateSeconds = 60L; long allowedDelta = windowSeconds + tolerateSeconds; if (Math.abs(current - timestamp) > allowedDelta) { return false; } String expectedSign = SignUtil.hmacSha256(timestamp + ":" + nonce + ":" + businessParamsDigest); if (!expectedSign.equals(sign)) { return false; } String redisKey = "replay:nonce:" + nonce; // 检查Nonce是否已经使用过 Boolean exists = redisTemplate.hasKey(redisKey); if (Boolean.TRUE.equals(exists)) { return false; } // 首次使用,写入并设置失效时间 Boolean success = redisTemplate.opsForValue() .setIfAbsent(redisKey, "1", Duration.ofSeconds(allowedDelta)); return Boolean.TRUE.equals(success); } }这段代码有个细节值得展开:setIfAbsent是一个原子操作,在并发请求同时到达时,只有一个线程能成功写入,另一个线程会被拒绝。这比“先hasKey再set”两步操作要安全得多,因为两步操作之间存在并发间隙,两个相同的请求可能同时通过检查并同时写入,导致防重放失效。
在实际项目中,我把Nonce的存储和校验逻辑完整落到Redis里之后,接口整体表现稳定,重复请求基本都在Redis这一步就被拦截掉了,不再往下走到业务层。
4. 实施过程中的难点与避坑经验
4.1 掉进过“只防重放不防篡改”的坑
我第一次实现防重放时,只是简单地校验时间戳和Nonce,没有把业务参数摘要加进签名。当时觉得Nonce是随机的,攻击者改参数之后Nonce会变,但服务端校验不关心参数和Nonce的对应关系,所以攻击者完全可以拿一个全新的Nonce,配合修改后的业务参数重放。
直到做测试的时候,我手动修改请求里的积分数量,发现服务端竟然通过了校验,才意识到问题严重性。
所以这里我强烈建议:签名内容必须包含业务参数摘要。不管是用sha256(businessParams)还是用请求体整体Hash,目的是建立“Token和请求内容”的绑定关系。攻击者只重放不改内容会被Nonce拦住,改了内容则会签名不匹配,两条路都被堵死。
如果你觉得每个接口都手动生成摘要麻烦,可以在网关层或者拦截器里统一对请求体做摘要,然后自动附加到Token签名计算的原料中。这样业务侧基本无感知。
4.2 并发场景下的Nonce重复问题
并发环境下的Nonce校验,是防重放实现里最容易出bug的地方。很多初版代码写成这样:
if (redis.hasKey(nonceKey)) { return false; } redis.set(nonceKey, "1", TTL); return true;这段代码在高并发下会出现竞态条件:两个相同请求同时执行hasKey,都发现Key不存在,然后都执行set,结果两次重放都被放行。
规避方式就是前面提到的setIfAbsent。这个操作在Redis内部是原子的,同一时刻只会有一个客户端成功写入。另一个客户端会返回false,你直接把它当成“Nonce已存在”处理即可。
另外一个容易被忽略的点是,这里不需要额外的锁。很多同事一听并发问题就想着加分布式锁,其实没必要。Redis的setnx语义本身已经解决了“判断并写入”的原子性问题,加锁反而增加了系统复杂度。
4.3 时间校验的边界,到底该卡多死
时间窗口的边界处理,是个让人头大的问题。我见过有人把校验写成:
if (currentTime - timestamp > windowSeconds) { return false; }这个写法有一个漏洞,只校验了“请求不能太旧”,却没校验“请求不能太新”。如果攻击者把时间戳改成未来时间,比如说一年后,那currentTime - timestamp就是一个负数,永远不会大于windowSeconds,校验就形同虚设。
虽然客户端正常不会产生未来时间戳,但攻击者是完全可以伪造的。所以稳妥的写法是使用绝对值判断:
if (Math.abs(currentTime - timestamp) > allowedDelta) { return false; }双向校验既防旧请求重放,也防未来时间戳绕过,同时预留一段容忍值应对时钟误差。
4.4 接口重放与业务幂等的关系
写防重放方案的时候,很容易跟业务幂等搞混。我当时也花了好长时间才理清这俩的关系。
防重放是“技术上拦截同一个请求报文被再次提交”,而业务幂等是“业务上保证同一个操作只生效一次”。两者是防御纵深的不同层次。
举例来说,一个下单接口,如果业务上已经用订单号做了唯一约束,那么就算防重放被绕过,同一订单号也不会重复创建订单。但防重放防的是“攻击者抓包抓到的合法请求直接重放”,它往往在请求达到业务逻辑之前就拦住了,减少了很多不必要的业务补偿逻辑。
所以我的建议是:防重放要加上,业务幂等也不能省。防重放挡掉绝大部分的重放流量,幂等兜底极端场景下的漏网之鱼。两者不是二选一的关系,而是互相补充。
5. 常见问题排查与压测验证
5.1 排查问题一般从哪几个方向入手
我把自己做防重放时踩过的问题整理成了一张速查表,你在遇到类似现象时可以直接对照排查:
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| 正常请求偶尔被拒绝 | 客户端与服务端时间差较大 | 查看两端服务器时间,调整容忍值 |
| 所有请求都被拒绝 | Redis不可用或Key前缀配置错误 | 检查Redis连接与Key命名是否一致 |
| 重放请求仍然通过 | Nonce没有写入存储,或校验逻辑顺序错误 | 检查setIfAbsent是否生效,校验顺序是否先时间后Nonce |
| 改了参数还能通过 | 签名未包含业务参数摘要 | 确认签名原料中是否包含请求正文摘要 |
| 重启后防重放失效 | 使用本地内存存储Nonce | 切换为Redis等外部存储,或接受重启窗口风险 |
| 同Token在多个节点重复通过 | 各节点未使用统一存储 | 确认所有实例连接同一Redis |
最常见的一个隐蔽问题就是Key命名。因为不同服务可能都用了replay:nonce:前缀,如果Redis里混存了多个业务系统的数据,一旦服务端校验时改成了带业务标识的前缀而客户端生成Token时没改,所有请求都会被误判为“Nonce已存在”。
5.2 通过并发压测验证防重放是否生效
编码完成之后,我习惯用简单的手段做并发测试。这里分享一个我用过的测试思路:准备一个请求Token,然后把同样的请求用并发工具同时发出几十次,观察后端接口被真正调用的次数。
如果你用的是JMeter,可以配置一个线程组,例如50个线程同时循环执行同一个HTTP请求,请求头固定同一个X-Replay-Token。正常情况下,50个请求里最多只有一个能通过拦截器进入业务逻辑,其余49个都应在防重放层被拦截。
如果50个请求全部进入了业务逻辑,那说明你的校验顺序可能有问题,或者setIfAbsent没生效。调试时,在拦截器里打日志,确认哪些请求命中了“Nonce已存在”分支,基本就能定位原因。
压测还有一个额外好处,就是能顺带测出Redis的读写性能瓶颈。防重放逻辑每成功放行一个请求,就增加一次Redis写入;每拦截一个请求,就增加一次Redis查询。如果你的接口本身对性能要求很高,建议提前把Redis的读写耗时压出来看看。
5.3 一组压测数据参考
我当时在一个内部项目上做过一次简单压测,机器配置是Spring Boot服务单节点,Redis单实例,接口逻辑本身是简单查询,未加防重放前TPS大约在1800左右。加上防重放过滤之后,TPS掉到了1500左右。考虑到多了一次Redis网络交互,这个损耗是可以接受的。
如果你连并发都还没遇到过,那先别急着优化。先确认防重放逻辑本身正确,再考虑性能优化。优化的方向通常是在本地加一层Nonce缓存,把最近几秒内已验过的Nonce放在本地内存里,减少对Redis的重复查询。但要注意,本地缓存只能用来“提前拦截”,不能用来“决定放行”,否则分布式环境下会出现漏判。
6. 落到项目里的完整接入流程
6.1 梳理哪些接口需要接入
防重放不是所有接口都要加。加了防重放,意味着客户端每一次请求都要多生成Token,多计算一次签名,服务端多一次校验。所以先理清楚哪些接口需要保护。
我的分类标准是:涉及资产变更、涉及敏感信息、涉及频控敏感操作的接口优先接入。比如登录接口、支付下单、积分兑换、密码修改、短信发送这类。而纯查询类的接口,如果已经被安全的鉴权体系保护,并且重放不会造成数据变更,可以考虑不接,降低性能损耗。
6.2 客户端接入改造清单
接入流程可以分为服务端和客户端两个维度。服务端要做的,是提供统一的Token生成和校验SDK,并把它定义成标准请求头规范。客户端要做的,是在请求发起前生成Token,在收到特定错误码后重新获取Token再试一次。
这里给一个简单的接入清单,照着做基本不会漏:
- 服务端提供Token生成工具类,封装时间戳、Nonce、签名逻辑。
- 服务端定义TokenHeader名和业务摘要Header名,统一用常量管理。
- 服务端在需要防护的接口上打
@ReplayProtect注解。 - 客户端在发送请求前,调用Token生成工具,附加到请求头。
- 客户端处理
403响应时,区分普通权限不足和防重放拒绝,前者正常报错,后者刷新Token重试。
有一个容易被遗漏的细节:如果客户端有重试机制,比如网络超时自动重试,必须重试时生成新的Token,不能把旧Token原封不动再发一次。旧Token在第一次请求时已经被服务端消费了,第二次再用必定被拦截,接口还是会失败。
6.3 平滑发布与灰度方案
防重放接入过程中如果直接全量上线,老版本的客户端没带Token,所有请求都会直接被拦截,这会造成事故。所以建议分三步走:
第一步,服务端先上线“校验开关”,默认关闭,只记录“模拟校验结果”到日志。这一步的目的是了解线上实际情况,比如有多少老客户端、时间戳偏差大不大。
第二步,打开校验开关,但设置一个白名单。白名单内的接口不校验,其他接口正常校验。观察一段时间业务指标的波动。
第三步,确认稳定后移除白名单,全量开启。如果后续新客户端全部收齐,还可以关掉兼容逻辑。
我自己实际接入的教训是,千万不要跳过灰度。看起来只是一层额外的校验逻辑,但一旦客户端的时钟有问题,或Token生成代码有兼容性bug,线上大量请求瞬间失败,你连回滚的时间窗口都没有。
7. 扩展:在分布式和微服务场景下如何设计
7.1 统一入口还是每服务各自校验
在微服务架构下,确定防重放校验放在哪一层,比单体应用复杂得多。我调研过几种方案,最终比较倾向的做法是:在统一网关层生成并校验Global Nonce,再做一层基于业务键的幂等校验。
如果每个服务各自校验,客户端就得针对不同服务分别生成Token,而且每个服务要共享同一套Nonce存储,不然同一个客户端请求到了不同服务,Nonce存储不一致,就会出现放行或误拦截的情况。维护成本很高。
放在网关层,则可以统一处理Token解析、时间戳校验、Nonce存取,业务服务只关心业务逻辑本身。网关拦截掉重放流量之后,业务服务收到的几乎都是“可信的新请求”。
但注意,网关层拦截并不能完全替代业务幂等。服务之间存在内部调用时,比如服务A调用服务B,B收到的不再是原始客户端请求,网关层的Nonce没有直接传递,内部调用就可能产生另一个维度的重复执行风险。这种场景还是要靠业务幂等键来兜底。
7.2 Multi-Region部署下的时钟同步
分布式系统还有一个天然坑:不同机房、不同区域的服务器时钟并不完全一致。虽然NTP可以同步到毫秒级别,但极端情况下,某个节点可能出现几十毫秒甚至更大的偏差。
防重放如果设了30秒的时间窗口,通常几十毫秒的偏差问题不大;但如果你把窗口缩小到1秒以内做严格限制,就可能频繁出现“正常请求被拒绝”的情况。
我的建议是:设计时预留一个“时钟容忍度”,而不是把窗口卡死。比如你希望请求从发出到服务端处理不超过30秒,那服务端校验时可以接受“当前时间 - 客户端时间”在30秒到90秒之间。容差值完全是可配置的,这样在极端网络或时钟问题上能有效减少误杀。
8. 防重放方案上线后的日常维护
8.1 监控指标怎么设定
防重放上线后,不要只是“装完就完事”。没有监控的情况下,防重放逻辑突然失效了,你往往是最后一个知道的。
我的做法是至少监控三个指标:防重放拦截率、Nonce存储容量、Redis读写耗时。拦截率突然升高,可能说明有攻击流量进来,也可能说明你的时间窗口设置得太小,误伤了很多正常用户。Nonce的存储容量则直接反映Redis的内存消耗,如果TTL设置不合理,Key会堆积得非常快。Redis读写耗时是因为每次请求都多了一次网络交互,如果P99延时有明显上升,要考虑加缓存层或者调整部署。
8.2 Token密钥轮换策略
签名用的密钥,最好定期轮换。密钥一旦泄露,攻击者可以自行生成合法的防重放Token,整个方案就名存实亡了。
但密钥轮换有一个兼容性问题:旧客户端还在用旧密钥签名的时候,你不能直接把密钥改掉。我的处理方式是服务端同时支持两个密钥版本,校验签名时先用新Key验证,不通过再用旧Key验证。假设密钥有效期是30天,轮换期可以定为15天。在轮换期间新老密钥都有效,待到下个轮换周期,旧密钥彻底废弃。
这个方案实现起来并不复杂,一个密钥版本列表常量就能搞定,但对线上稳定性的提升非常明显。我见过有人图省事直接改密钥,结果线上大量请求签名校验失败,临时回滚又费了很大劲。
8.3 我踩过的一个教训
最后分享一个我印象最深的维护教训。有一次在优化接口性能时,觉得防重放逻辑每次都要查Redis,太影响性能,就改成了把所有校验逻辑放本地内存。当时单机测试没问题,但上线之后,因为多节点部署,同一个Nonce在不同节点的本地内存状态不一样,导致攻击者在一台节点消费过的Nonce,在另一台节点上还能再次通过。
那次事故之后,我建立了两个固定的认知:本地缓存只能拦截重复,绝不能作为放行的唯一依据;凡是涉及全局状态校验的逻辑,存储就必须是全局可见的。这些教训写下来就一句话,但都是真金白银踩出来的。
防重放攻击不是一个新话题,但很多项目直到上线后才开始补这个漏洞,代价往往比提前设计高出好几倍。设计阶段就把时间戳、Nonce、签名、存储介质这四个要素理清,实现起来并不复杂,主要的工作量反而在兼容性、并发和分布式场景的细节处理上。希望这份从原理到落地的实战笔记,能让你在设计和实现防重放方案时少踩几个坑。