SpringBoot接口安全实战:AES加密+签名+防重放,一次搞定 接口安全这件事说大不大说小不小。很多团队在项目初期为了赶进度接口裸奔上线请求参数明文传输、响应结果明文返回等到被安全扫描或者被外部反馈才回头补加密结果发现改动面铺得太大牵一发动全身。我经历过几次这种事后补锅的场面后来总结出一套在 SpringBoot 里快速落地 API 加密的方案改动量可控、对业务代码侵入小、前后端联调成本也低。这篇就把这套方案完整拆开讲清楚包括为什么这么设计、每一步怎么落地、以及我在实际接入过程中踩过的那些坑。API 加密要解决的核心问题其实就三件事请求参数不能被中间人看懂、响应结果不能被随意截获解析、请求不能被原样重放。围绕这三件事业界常见的做法是对称加密AES 为主配合签名校验再叠加时间戳和随机串防重放。这套组合拳在 SpringBoot 里实现起来并不复杂关键在于把加密逻辑从业务代码里剥离出来做成统一的入口和出口处理。下面从方案选型开始一步步把整套东西搭起来。1. 先想清楚加密方案要覆盖哪些攻击面动手写代码之前得先把威胁模型想明白。很多团队一上来就说我们要加密但加密到底防什么、不防什么没人说得清最后做出来的东西既复杂又没用。我在实际项目里会把接口面临的风险拆成四类然后逐一对应到技术手段上。1.1 明文传输到底暴露了什么最直观的风险就是抓包。不管是移动端还是 Web 端只要请求走的是明文 HTTP 或者虽然走了 HTTPS 但参数本身是明文 JSON那么任何能拿到流量的人都能直接看到业务数据。用户手机号、身份证号、订单金额、内部接口的调用凭证全都一览无余。HTTPS 解决的是传输通道加密但解决不了参数在客户端就被看到以及日志、代理、网关层留痕的问题。所以应用层加密和传输层加密是两回事不能互相替代。我在一个模拟项目里做过对比测试同一个登录接口不加应用层加密时用普通的抓包工具就能直接读出账号密码字段加上 AES 加密后抓到的 body 是一串 Base64 密文没有密钥根本还原不出来。这个对比很能说明问题——应用层加密的价值在于让数据在离开业务代码之后到进入对端业务代码之前这段链路上始终是密文状态。1.2 重放攻击为什么比想象中更容易发生重放攻击的门槛比很多人以为的低得多。攻击者不需要破解你的加密只需要把抓到的完整请求原样再发一次就能重复触发一次业务操作。如果是查询接口危害有限但如果是转账、下单、扣库存这类写操作重放一次就是一次真实的业务损失。防重放的核心思路是让每个请求具备一次性特征常见手段是时间戳加随机数nonce服务端维护一个短期的 nonce 缓存同一个 nonce 在有效窗口内只接受一次。这里有个容易忽略的细节时间戳窗口不能设太大也不能太小。设太大比如 10 分钟攻击者有充足时间重放设太小比如 5 秒客户端和服务端时钟稍有偏差就大量误杀正常请求。我一般取 60 秒作为默认窗口配合 NTP 对时实测下来误杀率极低。1.3 签名和加密是两件不能混为一谈的事新手最容易犯的错是把加密和签名当成一回事。加密解决的是机密性——别人看不懂签名解决的是完整性和身份认证——别人改不了、也伪造不了。只加密不签名攻击者虽然看不懂明文但可以篡改密文比如翻转某些比特服务端解密后可能得到被污染的数据只签名不加密数据完整但明文暴露。所以生产环境里这两者要一起上先对业务参数做签名再对整体做加密或者反过来顺序取决于具体设计但两者缺一不可。我在方案里采用的顺序是业务参数 → 生成签名 → 签名随参数一起加密 → 传输。服务端收到后先解密、再验签、最后校验时间戳和 nonce。这个顺序的好处是任何一环出问题都能被明确拦截排查时日志里能清楚看到是解密失败、验签失败还是重放拦截。1.4 密钥管理才是真正的难点算法选型其实没什么好纠结的AES-128 或 AES-256 足够模式用 GCM 或 CBC 都行GCM 自带完整性校验更省事。真正难的是密钥怎么存、怎么换。密钥硬编码在客户端里等于没有密钥——反编译一下就拿走了。比较务实的做法是客户端首次启动时与服务端协商一个会话密钥用非对称加密RSA保护这个协商过程后续业务请求用会话密钥做对称加解密。这样即使某个会话密钥泄露影响范围也仅限于该会话。当然这套协商机制本身也有成本小项目未必需要做到这个程度。我的建议是分场景内部系统、低敏感业务用固定密钥加定期轮换就够了面向公众的高敏感业务支付、身份信息老老实实做会话密钥协商。下面章节的代码实现以固定密钥方案为主因为它落地最快也最容易讲清楚原理会话协商的部分我会在最后单独提一下扩展思路。2. 用 AES 加解密打底把工具类写扎实方案定了第一步是把加解密的基础能力做出来。这一步看似简单但坑特别多编码问题、填充模式、密钥长度任何一个细节没对齐前后端就会陷入我这边加密你那边解不开的拉锯战。我见过太多团队在这一步耗掉两三天所以这里把关键点全部摊开讲。2.1 算法模式的选择与参数约定AES 有三种常见模式ECB、CBC、GCM。ECB 最省事但最不安全相同的明文块会产生相同的密文块能看出数据规律生产环境绝对不能用。CBC 需要初始化向量IV安全性够用但 IV 的传递要处理好。GCM 是认证加密模式加密的同时产出认证标签能顺带校验数据完整性是我目前的首选。我最终选的是AES/GCM/NoPadding密钥长度 256 位IV 长度 12 字节GCM 推荐值认证标签 128 位。这几个参数必须前后端严格一致任何一方用错解密直接抛异常。下面是我实际在用的工具类核心结构public class AesGcmUtil { private static final String ALGORITHM AES; private static final String TRANSFORMATION AES/GCM/NoPadding; private static final int GCM_IV_LENGTH 12; private static final int GCM_TAG_LENGTH 128; public static String encrypt(String plainText, String keyBase64) throws Exception { byte[] keyBytes Base64.getDecoder().decode(keyBase64); SecretKeySpec keySpec new SecretKeySpec(keyBytes, ALGORITHM); byte[] iv new byte[GCM_IV_LENGTH]; SecureRandom random new SecureRandom(); random.nextBytes(iv); Cipher cipher Cipher.getInstance(TRANSFORMATION); GCMParameterSpec spec new GCMParameterSpec(GCM_TAG_LENGTH, iv); cipher.init(Cipher.ENCRYPT_MODE, keySpec, spec); byte[] cipherText cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); // IV 拼在密文前面一起传输解密时再拆出来 byte[] result new byte[iv.length cipherText.length]; System.arraycopy(iv, 0, result, 0, iv.length); System.arraycopy(cipherText, 0, result, iv.length, cipherText.length); return Base64.getEncoder().encodeToString(result); } public static String decrypt(String cipherBase64, String keyBase64) throws Exception { byte[] keyBytes Base64.getDecoder().decode(keyBase64); SecretKeySpec keySpec new SecretKeySpec(keyBytes, ALGORITHM); byte[] all Base64.getDecoder().decode(cipherBase64); byte[] iv Arrays.copyOfRange(all, 0, GCM_IV_LENGTH); byte[] cipherText Arrays.copyOfRange(all, GCM_IV_LENGTH, all.length); Cipher cipher Cipher.getInstance(TRANSFORMATION); GCMParameterSpec spec new GCMParameterSpec(GCM_TAG_LENGTH, iv); cipher.init(Cipher.DECRYPT_MODE, keySpec, spec); return new String(cipher.doFinal(cipherText), StandardCharsets.UTF_8); } }这里有个设计决策值得说明IV 为什么拼在密文前面而不是单独传。GCM 模式下每次加密都必须用全新的随机 IV如果 IV 重复密钥流就会重复安全性直接崩塌。把 IV 和密文打包在一起传输接收方按固定长度切分即可既避免了额外字段也杜绝了忘记传 IV的低级错误。代价是密文长度多了 12 字节可以忽略不计。2.2 密钥长度与 JDK 版本的隐藏坑AES-256 在部分 JDK 版本上会抛InvalidKeyException: Illegal key size原因是早期 JDK 受出口限制默认只支持 128 位密钥。JDK 8u161 之后默认放开了限制但如果你的运行环境是更老的版本就需要额外处理。我踩过一次这个坑本地开发环境是新 JDK 一切正常部署到某台老服务器上直接启动报错排查了半天才定位到密钥长度。规避方式有两种一是统一升级到较新的 JDK 小版本二是如果实在不能升级改用 AES-128密钥 16 字节安全性对绝大多数业务场景依然足够。我的建议是优先升级实在不行降级到 128 位别去折腾那些替换 jar 包的老办法维护成本太高。2.3 编码统一Base64 还是 Hex密文是二进制传输前必须编码成文本。Base64 和 Hex 都行我选 Base64因为体积比 Hex 小约三分之一网络传输更省。但要注意 Base64 的标准变体和 URL 安全变体的区别——如果密文要放在 URL 参数里必须用 URL 安全变体把和/替换掉否则会被 URL 编码规则破坏。我一般统一用标准 Base64 放在 body 里避免这个麻烦。还有一个细节明文的字符集必须固定为 UTF-8。如果客户端用 GBK 编码明文、服务端用 UTF-8 解码中文内容必然乱码。这个约定要写进接口文档前后端都遵守。3. 把加解密织进请求链路过滤器还是拦截器基础工具类有了接下来要解决在哪里调用的问题。最笨的办法是在每个 Controller 方法里手动加解密但这样业务代码会被安全逻辑污染改一个漏一个。正确的做法是利用 SpringBoot 的请求处理扩展点把加解密做成统一入口。这里有两个候选Filter 和 Interceptor选哪个有讲究。3.1 Filter 与 Interceptor 的能力边界对比Filter 属于 Servlet 规范层能拿到原始的HttpServletRequest可以包装请求体、替换输入流Interceptor 属于 Spring MVC 层拿到的已经是解析后的请求改请求体比较别扭。加解密需要读取原始 body 并替换成解密后的内容所以Filter 是更合适的选择。Interceptor 更适合做权限校验、日志埋点这类不需要动请求体的工作。不过 Filter 也有局限它对静态资源、健康检查等路径同样生效需要配置排除规则。我一般会定义一个路径前缀约定比如所有需要加密的接口统一走/api/secure/**Filter 只拦截这个前缀其他路径直接放行。这样既精准又不会误伤。3.2 请求体的可重复读取问题这是 Filter 方案里最大的坑。HttpServletRequest的输入流默认只能读一次Filter 里读完了解密Controller 里就拿不到数据了。解决办法是用HttpServletRequestWrapper把解密后的字节缓存起来重写getInputStream()和getReader()让后续读取都从缓存里取。这个包装类必须写对否则会出现Filter 里能读到、Controller 里读到空的诡异现象。public class DecryptRequestWrapper extends HttpServletRequestWrapper { private final byte[] body; public DecryptRequestWrapper(HttpServletRequest request, byte[] body) { super(request); this.body body; } Override public ServletInputStream getInputStream() { ByteArrayInputStream bais new ByteArrayInputStream(body); return new ServletInputStream() { Override public int read() { return bais.read(); } Override public boolean isFinished() { return bais.available() 0; } Override public boolean isReady() { return true; } Override public void setReadListener(ReadListener listener) { } }; } Override public BufferedReader getReader() { return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8)); } }注意getReader()里必须指定 UTF-8否则默认用平台编码跨环境部署时中文会出问题。这个细节我在两个项目里都遇到过属于典型的本地好好的、上线就乱码。3.3 响应体的加密包装请求解密解决了响应加密是镜像问题。Controller 返回的对象会被HttpMessageConverter序列化成 JSON 写回响应流我们要在写回之前拦截并加密。做法是包装HttpServletResponse用一个自定义的ServletOutputStream把写出的字节先缓存等请求处理完毕再统一加密输出。这里有个时序问题响应加密必须在 Controller 执行完之后进行所以 Filter 里的chain.doFilter()之后才是处理响应的时机。但此时响应可能已经提交committed无法再改。解决办法是包装 response 时重写getOutputStream()返回一个缓存流并重写flushBuffer()在真正刷出前完成加密。这套逻辑稍微绕但跑通一次之后就是模板可以直接复用。4. 签名、时间戳、nonce 三件套怎么配合加密解决了看不懂但解决不了改不了和重复用。签名负责防篡改时间戳加 nonce 负责防重放这三样要一起设计参数怎么拼、顺序怎么定直接影响安全性和联调效率。4.1 签名串的拼接规则设计签名的本质是把请求的关键要素按约定顺序拼成一个字符串用密钥做 HMAC 或直接做摘要服务端用同样规则重算并比对。拼接规则必须满足两个条件一是覆盖所有敏感字段二是顺序固定且可复现。我采用的规则是把所有业务参数按字段名字典序排序拼成key1value1key2value2的形式末尾追加timestampxxxnoncexxx再用 HMAC-SHA256 计算签名。为什么用字典序而不是原始顺序因为 JSON 对象在序列化时字段顺序不保证稳定不同语言、不同库的序列化结果可能不同用字典序能消除这个不确定性。这个坑我在跨端联调时踩过——Java 端按字段声明顺序拼前端按对象遍历顺序拼两边算出来的签名永远对不上查了一下午才发现是顺序问题。4.2 时间戳窗口与 nonce 缓存的落地时间戳校验很简单服务端取当前时间与请求时间戳求差超过窗口比如 60 秒直接拒绝。nonce 校验需要一个短期缓存我一般用 Redis 的SETNX加过期时间实现key 是nonce:{值}过期时间设为窗口的两倍比如 120 秒保证窗口内的重复请求一定能被拦到。public boolean checkReplay(String nonce, long timestamp) { long now System.currentTimeMillis(); if (Math.abs(now - timestamp) WINDOW_MS) { return false; // 时间戳超窗 } String key nonce: nonce; Boolean success redisTemplate.opsForValue() .setIfAbsent(key, 1, Duration.ofMillis(WINDOW_MS * 2)); return Boolean.TRUE.equals(success); // false 表示重复 }这里有个容易忽略的点nonce 缓存不能无限增长。如果不设过期时间缓存会被撑爆。设成窗口两倍是个经验值既保证覆盖完整窗口又不会占用太多内存。另外如果服务是多实例部署nonce 缓存必须用共享存储Redis不能用本地 Map否则请求打到不同实例上防重放就失效了。4.3 验签失败与解密失败的差异化处理安全校验失败时返回什么信息也有讲究。如果统一返回请求非法排查困难如果返回太详细比如签名不匹配期望 xxx 实际 yyy又等于给攻击者送情报。我的做法是对外统一返回模糊的错误码如40001 请求校验失败对内通过日志记录详细原因解密失败、验签失败、时间戳超窗、nonce 重复分别打不同日志。这样既安全又可运维。5. 前后端联调阶段最容易翻车的几个点代码写完只是开始真正折磨人的是联调。加解密涉及两端参数严格对齐任何一处不一致都会导致我这边没问题你那边解不开。这一章把我遇到过的典型问题列出来基本都是血泪教训。5.1 密钥和 IV 的对齐问题最常见的就是密钥不一致。前端把密钥当成普通字符串直接用后端却按 Base64 解码后取字节两边密钥字节完全不同解密必然失败。约定必须明确密钥以 Base64 字符串形式下发两端都先 Base64 解码再使用。IV 同理虽然我们的方案里 IV 随密文传输不需要额外约定但如果换成 CBC 模式IV 的传递方式就要单独说清楚。我建议在联调初期先做一个最小验证前端加密一个固定字符串hello把密文发给后端后端解密看是否还原。这一步通过了再往下联调业务接口能省掉大量扯皮。5.2 空参数和特殊字符的处理签名拼接时如果某个参数值为 null 或空字符串拼不拼进去这个规则必须提前定死。我的约定是值为 null 的字段不参与签名空字符串参与。另外参数值里如果含有、这类分隔符直接拼接会破坏签名串结构必须先做 URL 编码再拼。这些边界情况在测试用例里要覆盖到否则上线后遇到特殊字符就炸。5.3 大响应体的性能影响加密是有计算成本的。普通接口的响应体几百字节到几 KB加密开销可以忽略但如果是列表查询返回几 MB 的 JSONAES 加密加上 Base64 编码会让响应时间明显上升内存占用也会翻倍原始字节 密文字节 Base64 字符串同时在内存里。我的处理原则是对超大响应体比如超过 1 MB不做应用层加密改用 HTTPS 保障传输安全或者只加密其中的敏感字段。这个阈值可以根据实际压测结果调整。6. 密钥轮换与灰度上线的工程化考量功能跑通不等于可以上线。密钥怎么换、新旧版本怎么共存、出问题怎么回滚这些工程化问题不提前想清楚上线当天就是灾难现场。6.1 密钥版本号机制密钥不能是一成不变的定期轮换是基本要求。但轮换时新旧密钥要能共存一段时间否则切换瞬间所有在途请求全部失败。做法是在密文里带上密钥版本号服务端维护一个版本到密钥的映射表解密时按版本号取对应密钥。新版本上线后客户端逐步切换到新密钥服务端同时支持新旧两个版本等切换完成再下线旧版本。密文结构可以设计成版本号(1字节) IV(12字节) 密文解析时先读版本号再按版本取密钥。这个设计改动很小但为后续轮换留足了空间。6.2 灰度开关与降级预案加密功能上线必须带开关。通过配置中心控制某个接口是否强制加密灰度期间可以让部分客户端先接入观察错误率和性能指标。同时要准备降级预案如果加密链路出现严重问题比如密钥服务不可用能快速切回明文模式保证业务可用。降级不等于放弃安全而是在可用性和安全性之间做临时权衡事后必须补齐。我在一个模拟项目里做过灰度先对 10% 的流量开启加密观察一天错误率正常后扩到 50%再全量。整个过程没有出现批量故障靠的就是这个渐进节奏和随时可关的开关。6.3 日志脱敏不能忘加密上线后很多人以为万事大吉结果日志里把解密后的明文原样打出来了等于白加密。所有涉及请求响应的日志输出都必须做脱敏处理手机号、身份证、银行卡这类字段要么不打印要么打码。这个工作要在加密功能上线的同一批次完成不能拖。7. 写在最后的一点实操体会这套方案我在几个项目里落地过从最初的手忙脚乱到后来的模板化复用最大的感受是加密方案的复杂度要和业务敏感度匹配。给一个内部管理后台套上会话密钥协商加动态密钥轮换纯属过度设计维护成本远大于收益反过来给支付类接口只做固定密钥 AES 而不做防重放又是明显的安全短板。先想清楚你的接口面临什么风险再决定上哪几件套比盲目堆技术重要得多。另外一个体会是加解密这类横切逻辑一定要做成可开关、可降级、可观测的基础设施而不是散落在业务代码里的零碎调用。开关让你能灰度、降级让你能兜底、可观测让你出问题时能快速定位。这三点做到了这套东西才算真正能上生产而不只是 demo 里跑得通。