ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot接口安全实战:AES加密+签名+防重放,一次搞定

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 或直接做摘要,服务端用同样规则重算并比对。拼接规则必须满足两个条件:一是覆盖所有敏感字段,二是顺序固定且可复现。我采用的规则是:把所有业务参数按字段名字典序排序,拼成key1=value1&key2=value2的形式,末尾追加&timestamp=xxx&nonce=xxx,再用 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 的 JSON,AES 加密加上 Base64 编码会让响应时间明显上升,内存占用也会翻倍(原始字节 + 密文字节 + Base64 字符串同时在内存里)。我的处理原则是:对超大响应体(比如超过 1 MB)不做应用层加密,改用 HTTPS 保障传输安全,或者只加密其中的敏感字段。这个阈值可以根据实际压测结果调整。

6. 密钥轮换与灰度上线的工程化考量

功能跑通不等于可以上线。密钥怎么换、新旧版本怎么共存、出问题怎么回滚,这些工程化问题不提前想清楚,上线当天就是灾难现场。

6.1 密钥版本号机制

密钥不能是一成不变的,定期轮换是基本要求。但轮换时新旧密钥要能共存一段时间,否则切换瞬间所有在途请求全部失败。做法是在密文里带上密钥版本号,服务端维护一个版本到密钥的映射表,解密时按版本号取对应密钥。新版本上线后,客户端逐步切换到新密钥,服务端同时支持新旧两个版本,等切换完成再下线旧版本。

密文结构可以设计成版本号(1字节) + IV(12字节) + 密文,解析时先读版本号,再按版本取密钥。这个设计改动很小,但为后续轮换留足了空间。

6.2 灰度开关与降级预案

加密功能上线必须带开关。通过配置中心控制某个接口是否强制加密,灰度期间可以让部分客户端先接入,观察错误率和性能指标。同时要准备降级预案:如果加密链路出现严重问题(比如密钥服务不可用),能快速切回明文模式保证业务可用。降级不等于放弃安全,而是在可用性和安全性之间做临时权衡,事后必须补齐。

我在一个模拟项目里做过灰度:先对 10% 的流量开启加密,观察一天,错误率正常后扩到 50%,再全量。整个过程没有出现批量故障,靠的就是这个渐进节奏和随时可关的开关。

6.3 日志脱敏不能忘

加密上线后,很多人以为万事大吉,结果日志里把解密后的明文原样打出来了,等于白加密。所有涉及请求响应的日志输出都必须做脱敏处理,手机号、身份证、银行卡这类字段要么不打印,要么打码。这个工作要在加密功能上线的同一批次完成,不能拖。

7. 写在最后的一点实操体会

这套方案我在几个项目里落地过,从最初的手忙脚乱到后来的模板化复用,最大的感受是:加密方案的复杂度要和业务敏感度匹配。给一个内部管理后台套上会话密钥协商加动态密钥轮换,纯属过度设计,维护成本远大于收益;反过来,给支付类接口只做固定密钥 AES 而不做防重放,又是明显的安全短板。先想清楚你的接口面临什么风险,再决定上哪几件套,比盲目堆技术重要得多。

另外一个体会是,加解密这类横切逻辑,一定要做成"可开关、可降级、可观测"的基础设施,而不是散落在业务代码里的零碎调用。开关让你能灰度、降级让你能兜底、可观测让你出问题时能快速定位。这三点做到了,这套东西才算真正能上生产,而不只是 demo 里跑得通。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进