你有没有想过这样一个场景一台服务器的登录接口攻击者用抓包工具截获一次正常登录请求后原封不动再发一次服务器会怎么处理如果接口没有任何防护答案是会当作全新请求照常处理。这就是接口“裸奔”最典型的风险之一重放攻击。Spring Boot 项目里要给登录接口加上防重放攻击核心不在于引入多复杂的加密框架而是把“时间戳 随机数 签名 Redis 一次性消费”这套组合用起来5 分钟就能跑通。我这次分享的方案适合正在做接口安全加固的后端开发也适合那些不想改动业务代码结构、只想低成本提升接口安全性的团队。它不要求改数据库不要求换框架核心就三样东西一个自定义注解、一个拦截器、一个 Redis 操作。登录接口只要加一行注解就能具备防重放能力。1. 防重放到底在防什么1.1 登录接口为什么是重灾区登录接口天然就是攻击者最喜欢盯上的目标。它对外暴露被调用频率高而且一旦请求被截获重放成功的登录报文可能让服务端反复签发会话凭证重放验证码接口能把短信通道刷到欠费重放注册接口能批量刷出大量垃圾账号。这些后果都直接和安全挂钩。很多人会把防重放和“幂等”“限流”混在一起实际上它们解决的问题完全不同。幂等说的是同一个操作执行一次和多次业务结果一致限流说的是控制请求频率防止客户端在短时间内刷爆接口防重放则聚焦在“同一条经过合法签名的报文不能被原样使用第二次”。登录接口这三件事都得做防重放是其中最容易遗漏、也最容易被绕开的一层。攻击者在做重放时并不是非得重放“成功”的请求。重放一条密码错误的失败请求同样可能触发账号锁定策略让正常用户被锁死这本身就是一种干扰攻击。所以防重放不能只在关键业务环节做认证入口反而要最先做。1.2 时间戳、随机数、签名各管哪一段防重放最常见的技术组合是三件套时间戳、随机数、签名。可以拿取快递来类比时间戳相当于取件码的“截止时间”太旧就不让取随机数相当于取件码本身取过一次就作废签名相当于取件时校验手机号后四位证明这个取件码确实是你向快递点申请来的而不是别人随手编的。具体到接口请求里三者的分工是这样的时间戳保证请求是新鲜的。服务端拿到请求后先看报文里的时间戳和当前时间差多少超过预设窗口直接拒绝。随机数保证新鲜请求也只能用一次。服务端把已经出现过的随机数记下来第二次见到相同的随机数就拒绝。签名保证时间戳和随机数不能被攻击者伪造。密钥只掌握在合法的客户端和服务端手里攻击者就算给自己的报文换一个新时间戳、新随机数也造不出合法的签名。如果只做时间戳校验几秒钟窗口内的重放就完全防不住如果只做随机数校验攻击者每次换一个随机数服务端无法分辨真假如果只做签名校验没有时间窗口和一次消费攻击者拿一条报文慢慢分析总有办法绕过。三个一起上才是登录接口防重放的完整形态。1.3 这套防护的适用边界与影响范围登录、注册、发送验证码、修改密码、支付下单这类接口天然适合加防重放。它们都有一个共同特点请求本身会产生副作用而且被重放后的代价很高。反过来纯查询的 GET 接口、幂等且无副作用的接口一般不建议加加了反而增加客户端配合成本还可能因为请求参数获取不完整导致误杀。这套方案上线后的影响主要有三个方面。第一是 Redis 存储量。每个登录请求会多一个 keykey 的过期时间等于时间戳窗口。假设登录接口每秒 1000 次请求5 分钟窗口内会生成约 30 万个 key单机 Redis 完全没压力但如果峰值达到每秒几万次就必须评估内存占用后续可以引入布隆过滤器做近似去重只是精确性会打折扣。第二是响应延迟。每次请求多一次 Redis SETNX 和一次 HMAC 计算耗时为毫秒级对用户体验影响可以忽略。第三是客户端配合成本。前端需要生成 UUID 并计算 HMAC工作量很小但密钥怎么安全下发到客户端需要单独评估。2. 方案选型为什么是拦截器 Redis2.1 先看几个常见方案再决定防重放的实现思路不止一种我把自己评估过的方案放在一起对比过各有各的适用场景。方案核心原理优点明显短板时间戳校验请求带时间戳服务端判断新鲜度实现最简单窗口内重放无法拦截随机数记录服务端记录已使用随机数能精确拦截重复请求需要存储依赖服务端状态一次性 Token服务端先下发 token请求携带消费后失效控制力度最强交互多登录场景流程变重时间戳 随机数 签名时间窗口、一次性消费、防伪造三者组合覆盖主要重放路径需要客户端配合全局限流限制单位时间请求次数能挡住高频攻击对慢速重放基本无效全局限流只能挡住频率高的攻击攻击者把重放的频率控制得很低每 10 分钟重放一次限流完全察觉不到。所以真正的问题是“这一条报文能不能被用两次”而不是“请求次数够不够多”。这也是我最终选择三件套组合的原因。2.2 拦截器 注解最大的优势是不脏代码主流的落地方式大概有两种一种在 Filter 里做全局拦截另一种在业务代码里手动处理。全局 Filter 的问题是你没法精准控制哪些接口需要防重放只能靠路径匹配配置一多就乱手动处理的问题更明显每个接口复制粘贴一遍校验代码漏一个就相当于没做。用“自定义注解 HandlerInterceptor”是我实践下来最舒服的方案。业务接口不需要写任何校验逻辑只加一个NoRepeatSubmit注解拦截器里统一判断当前 HandlerMethod 上有没有这个注解有就走校验没有就直接放行。业务代码零侵入后面想给新接口加防护一行注解就搞定。有人会问既然是走 Spring MVC 的 HandlerMethod为什么不用 AOP 环绕通知AOP 同样可以实现但它在 Controller 方法执行前后织入而拦截器天然在方法执行前触发语义更直观还能直接访问HttpServletRequest和HttpServletResponse该拒绝时直接设置状态码和响应体流程更顺。2.3 为什么用 Redis 存随机数随机数记录需要一个地方保存“哪些 nonce 已经用过了”。这块存储选型很重要用本地内存方案多实例部署时各实例的数据不互通同一个请求打到不同实例就可能被放行两次而且 JVM 重启后记录全部丢失用数据库表一个登录请求一次插入高并发下压力立刻成为瓶颈。Redis 天然适合这种场景。一条SETNX命令就能原子占坑配合过期时间自动清理多实例部署时只要共用同一个 Redis所有实例看到的是同一套随机数记录。它不需要单独建表不需要写清理任务key 过期就是最好的垃圾回收机制。3. 5分钟落地完整代码实现3.1 先加一个自定义注解先写一个最简单的注解参数只保留一个过期时间窗口默认 5 分钟。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 NoRepeatSubmit { long timeout() default 5 * 60 * 1000L; }Target标在方法上意味着只想对单个接口生效Retention(RUNTIME)必须保留到运行期拦截器才能通过反射拿到。timeout既用来判断时间戳的新鲜度也用来给 Redis key 设置过期时间两个值保持一致逻辑更清晰。3.2 写拦截器核心逻辑拦截器是整个方案的主干负责四件事判断是否有注解、校验时间戳、校验签名、校验随机数是否已经被使用过。import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.HandlerInterceptor; import jakarta.annotation.Resource; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.concurrent.TimeUnit; Component public class AntiReplayInterceptor implements HandlerInterceptor { private static final String HEADER_TIMESTAMP X-Timestamp; private static final String HEADER_NONCE X-Nonce; private static final String HEADER_SIGN X-Sign; Value(${api.security.sign-key}) private String signKey; Resource private StringRedisTemplate stringRedisTemplate; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } HandlerMethod handlerMethod (HandlerMethod) handler; NoRepeatSubmit annotation handlerMethod.getMethodAnnotation(NoRepeatSubmit.class); if (annotation null) { return true; } String timestamp request.getHeader(HEADER_TIMESTAMP); String nonce request.getHeader(HEADER_NONCE); String sign request.getHeader(HEADER_SIGN); if (!StringUtils.hasText(timestamp) || !StringUtils.hasText(nonce) || !StringUtils.hasText(sign)) { return writeError(response, 缺少防重放请求头); } long ts; try { ts Long.parseLong(timestamp); } catch (NumberFormatException e) { return writeError(response, 时间戳格式错误); } long diff Math.abs(System.currentTimeMillis() - ts); if (diff annotation.timeout()) { return writeError(response, 请求已过期); } String content timestamp nonce request.getMethod() request.getRequestURI(); String expectSign SignUtils.hmacSha256(content, signKey); if (!MessageDigest.isEqual(expectSign.getBytes(StandardCharsets.UTF_8), sign.getBytes(StandardCharsets.UTF_8))) { return writeError(response, 签名校验失败); } String key antiReplay: nonce; Boolean success stringRedisTemplate.opsForValue() .setIfAbsent(key, timestamp, annotation.timeout(), TimeUnit.MILLISECONDS); if (!Boolean.TRUE.equals(success)) { return writeError(response, 重复请求请刷新后重试); } return true; } private boolean writeError(HttpServletResponse response, String message) throws IOException { response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:403,\message\:\ message \}); return false; } }顺序上有一个细节时间戳校验失败、签名校验失败时不会往 Redis 里写任何东西。这样做是为了防止攻击者用一堆非法请求把 Redis 的随机数空间撑爆只有真正通过校验的请求才允许“占坑”。MessageDigest.isEqual不是随便写的它在做字节数组比较时是常量时间比较比普通的equals更不容易被时序侧信道攻击。虽然登录接口的签名校验理论上不一定会被拿到线上做时序分析但这是成本极低的加固写上去不亏。3.3 签名工具类与密钥配置签名算法用 HMAC-SHA256比 MD5 更可靠比 RSA 简单适合内部服务和可控客户端。工具类代码如下import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; public final class SignUtils { private SignUtils() { } public static String hmacSha256(String content, String secret) { try { Mac mac Mac.getInstance(HmacSHA256); SecretKeySpec keySpec new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HmacSHA256); mac.init(keySpec); byte[] raw mac.doFinal(content.getBytes(StandardCharsets.UTF_8)); StringBuilder sb new StringBuilder(); for (byte b : raw) { String hex Integer.toHexString(0xff b); if (hex.length() 1) { sb.append(0); } sb.append(hex); } return sb.toString(); } catch (Exception e) { throw new IllegalStateException(HMAC calculation error, e); } } }密钥在配置文件中维护api: security: sign-key: please-change-me-to-a-long-random-string生产环境一定不要把密钥明文写死在代码仓库里建议通过环境变量、配置中心等渠道注入并且设置定期轮换的机制。注册拦截器的方式也很简单在一个配置类里把拦截器加到路径上即可import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import jakarta.annotation.Resource; Configuration public class WebConfig implements WebMvcConfigurer { Resource private AntiReplayInterceptor antiReplayInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(antiReplayInterceptor) .addPathPatterns(/**) .excludePathPatterns(/error); } }静态资源请求的 Handler 不是HandlerMethod拦截器里已经判断过所以不需要在注册时排除静态资源。关键是/error路径要排除否则处理 404 时也容易被误伤。3.4 前端配合拿到那三个 Header登录请求在发出前前端要做三件事生成毫秒级时间戳、生成随机数、拼接字符串计算签名。下面是一个用浏览器 Web Crypto API 计算 HMAC 的示例。async function hmacSha256(message, secret) { const enc new TextEncoder(); const cryptoKey await crypto.subtle.importKey( raw, enc.encode(secret), { name: HMAC, hash: SHA-256 }, false, [sign] ); const signature await crypto.subtle.sign(HMAC, cryptoKey, enc.encode(message)); return Array.from(new Uint8Array(signature)) .map((byte) byte.toString(16).padStart(2, 0)) .join(); } const timestamp String(Date.now()); const nonce crypto.randomUUID().replace(/-/g, ); const content [timestamp, nonce, POST, location.pathname].join(); const sign await hmacSha256(content, please-change-me-to-a-long-random-string); fetch(/api/login, { method: POST, headers: { Content-Type: application/json, X-Timestamp: timestamp, X-Nonce: nonce, X-Sign: sign }, body: JSON.stringify({ username, password }) });这段代码里有几个细节要特别注意。签名拼接顺序必须和后端完全一致。后端代码里是timestamp nonce method requestURI前端这里也是timestamp nonce POST location.pathname任何一边多一个空格或者大小写不一致签名就会校验失败。location.pathname要和服务端拿到的request.getRequestURI()保持一致。如果服务前面还有网关或路由组件改写了 URI前后端约定必须统一否则会出现“本地测得好好的上到测试环境全报签名失败”的怪问题。3.5 实际验证第一次放行重放被拦代码写完后最直观的验证方法是用接口调试工具把登录请求完整发一遍。第一次发送时返回正常登录结果。紧接着把请求体、Header、签名原封不动再发送第二次这时候应该收到 403 响应错误信息是“重复请求请刷新后重试”。再验证时间戳的作用把X-Timestamp改成一个小时前的时间戳签名也要重新计算发送后应该收到“请求已过期”。再把签名故意改错一位应该收到“签名校验失败”。这三个实验做下来防重放的时间窗口、一次性消费、签名防伪三条防线就都验证到位了。4. 关键参数与避坑实录4.1 Redis key 到底怎么设计我在代码里用的 key 是antiReplay:加 noncevalue 存的是请求时间戳过期时间等于annotation.timeout()。这个设计不是随便定的。key 不能只用时间戳。两个不同客户端很可能在同一毫秒生成相同的时间戳一旦当成同一个请求正常用户就被误杀了。key 也不能只用用户 ID 或接口 URI因为同一个用户、同一个接口本来就可以在短时间内发出多个不同请求用这些做 key 会导致所有并发请求互相覆盖防重放直接失效。nonce 是客户端生成的 UUID全局唯一作为 key 最合适。value 存时间戳是为了排查时能看到这条请求什么时候进来方便关联日志。如果完全不关心审计value 存什么都可以真正起作用的是 key 的“存在性”。过期时间设置太短会漏掉窗口外的重放设置太长又会让 Redis 里的 key 积累太多。默认 5 分钟是个比较合理的区间大多数重放攻击都会在几分钟内完成超过 5 分钟的延迟重放要么被时间戳校验挡住要么已经被业务层判定为过期。具体业务如果对时效更敏感可以在注解的timeout参数里单独覆盖。4.2 并发安全为什么必须用 setIfAbsent随机数记录最大的坑是并发。两个请求同时带着同一个 nonce 到达服务端如果先用 get 查是否存在不存在就又执行 set两个请求可能同时读到同样的结果最终都认为自己是“第一次”重放就被放过去了。Redis 的setIfAbsent(key, value, timeout, TimeUnit)是一条原子命令等价于SET key value NX EX timeout。多个请求同时到达时只有第一个能成功写入并返回 true其他请求拿到 false 直接拒绝。这是防重放在高并发场景下不失效的关键。还有一个隐蔽问题是Boolean.TRUE.equals(success)的写法。setIfAbsent返回的是Boolean对象直接用比较会有拆箱风险用equals是最稳妥的。如果 Redis 出现故障拦截器里抛出的异常会变成 500 响应登录请求直接失败。登录接口我建议采用“宁可拒绝不可放行”的策略Redis 挂了就挂登录同时一定要加上告警让值班人员能第一时间发现。如果你的业务方对可用性要求极高可以考虑降级开关但降级期间防重放处于失效状态必须有配套的风控手段兜底。4.3 时钟偏移别让过期时间误杀正常请求时间戳窗口的核心矛盾是客户端和服务器的时钟不可能完全一致。手机时间不准、虚拟机时钟漂移、容器时间同步问题这些在真实环境里都很常见。如果窗口设得太小比如 30 秒一台时钟慢了两分钟的手机访问登录接口会直接收到“请求已过期”。5 分钟窗口能覆盖大多数时钟偏差但生产环境还是建议在客户端做一次校时或者用服务器下发的标准时间作为基准。另一个思路是只限制“请求不能太旧”对未来时间单独放宽 1 分钟比如改成now - ts timeout || ts - now 60_000这样对未来时钟偏差的容忍度更高。但要注意放宽未来时间容忍度后攻击者伪造一个未来时间戳就可能在更长时间内有效。这时候签名的作用就体现出来了没有签名攻击者可以把时间戳改成任意未来时间绕过窗口有签名攻击者不知道密钥改了时间戳就没法通过校验。4.4 密钥管理与客户端环境这套方案有一个绕不开的边界签名密钥要参与前端计算就免不了暴露在客户端代码里。浏览器页面里的密钥可以被查看App 里的密钥可以被逆向提取。所以它更适合内部管理系统、企业后台、可控终端这类场景。如果登录接口面向公网 App 或完全不可控的浏览器环境更可靠的做法是服务端动态下发一次性密钥或短期 token再配合验证码、设备指纹、风控体系一起用。防重放只是接口安全里的一层不该把它当成唯一防线。密钥还要定期轮换轮换时要考虑前端版本兼容最好做一个带版本号的密钥体系旧密钥保留一段时间防止前端发版跟不上。5. 常见问题排查与上线建议5.1 问题排查速查表实际落地中踩过的坑我整理成一张表方便照方抓药。现象可能原因处理建议正常请求偶尔被拦截客户端与服务器时钟偏差过大检查两端时钟同步适当放宽时间窗口拦截器完全没有生效注解没有加到方法上拦截器未注册检查 Controller 方法注解确认注册路径重放第二次没有被拦截第二次请求使用了新 nonceRedis key 过期时间不一致确认测试时 nonce 是否复用检查过期时间设置所有请求都签名失败前后端拼接顺序不一致URI 取值不一致密钥不匹配逐项对齐拼接规则打印日志对比签名原串返回中文乱码响应 ContentType 没有设置 UTF-8设置application/json;charsetUTF-8高并发下同一请求被放行多次误用了先 get 后 set 的逻辑改为setIfAbsent原子命令Redis 抖动后登录全挂拦截器里 Redis 异常未处理增加异常捕获、告警提前选择降级策略5.2 上线前的测试步骤我上线前一般会按下面这套流程走一遍时间不长但能把大部分问题提前暴露出来。第一正常功能回归。确认添加注解后普通登录流程没有受到任何影响返回结果和响应结构与之前完全一致。第二重放验证。用接口调试工具完整抓一次登录请求原样重发确认第二次被拦截。第三并发验证。写一个简单的多线程脚本用同一个 nonce 并发调用登录接口最终应该只有一个请求成功其他请求全部收到 403。第四签名失效验证。随便改动签名、时间戳、路径中的任何一项确认请求被拒绝。这一步能发现前后端拼接规则是否完全对齐。第五监控验证。检查日志中记录的拒绝原因是否清晰Redis key 增长速率是否符合预期确认没有因为错误请求把 Redis 打满。5.3 几个我建议一起上的安全措施防重放不是万能的它只是把“同一条报文被二次使用”的路堵住了。攻击者如果直接换新 nonce、新时间戳并且有能力重新生成签名仍然可以继续试探接口。所以登录接口真正要形成合力至少要叠加这几件事验证码是成本最低、效果最直接的登录保护图形验证码、滑块验证码都可以重点是让自动化脚本没法轻易发起批量请求。频率限制要按 IP、按账号、按设备维度分别做。比如同一个 IP 一分钟最多 10 次登录尝试同一个账号一小时最多 5 次密码错误超过阈值直接锁定一段时间。有了频控攻击者即使能绕过防重放也很难快速完成暴力猜解。日志和告警不能少。在拦截器的每个拒绝分支打上日志记录来源 IP、nonce、签名原串、拒绝原因。出现大量“重复请求”或者“签名校验失败”时本身就是一个安全告警信号说明有人在对你的登录接口做异常操作。我个人在操作中习惯把拒绝响应体里的 message 写得简单一点不要暴露内部细节。像“签名校验失败”这种消息已经足够定位问题把具体哪一位签名不对、期望值是什么都返回给客户端等于给攻击者递刀子。这套防重放方案上线后后续如果想继续扩展可以往通用接口安全组件的方向走把防重放、幂等、频控合并到一个注解里通过配置开关控制不同接口走哪条链路。登录接口不是简单判断账号密码对不对就行多花 5 分钟把防重放做上后面能省下很多处理异常请求的麻烦。