☰
Spring Boot接口安全实战:RSA+AES混合加密与防重放设计
2026/10/16 10:30:28 网站建设 项目流程

先说一个我印象很深的场景:项目上线第二周,安全同事直接甩了一份抓包报告到群里,请求参数里用户的手机号、身份证号、地址全是明文,而且同一个请求被重复丢到线上几十次,每次都成功返回了数据。当时团队第一反应是"我们有 HTTPS 啊",但后来才反应过来,HTTPS 只保护了传输链路,应用层的接口安全完全裸奔。

那次之后我们就做了整套基于 RSA+AES 混合加密的接口全链路加解密改造,把防泄露、防篡改、防重放三个问题一次性解决。这篇文章把方案的设计思路和 Spring Boot 落地代码都整理出来,适合正在做开放平台、App 后端接口,或者对 API 安全有要求的工程师参考。我会尽量把为什么这样设计、代码怎么落、线上踩过哪些坑都讲清楚。

1. 为什么接口安全需要 RSA+AES 混合加密

1.1 先破除一个错觉:HTTPS 不等于接口安全

很多人觉得上了 HTTPS 接口就安全了,这是最大的误区。HTTPS 的本质是 TLS 加密,它解决的是数据在网络上传输时被窃听、被中间人劫持的问题。但数据到了某个环节之后,很可能就变成明文了。

举个我们实际遇到的例子:调我们接口的第三方系统,会在自己的网关层记录完整请求日志。他们的运维能看到明文参数,我们这边的应用日志如果打得不小心,request body 也会落盘成明文。再往前端看,手机 App 里做 Charles 抓包,只要在测试机上装了证书,HTTPS 流量一样能看到明文请求体。也就是说,HTTPS 解决不了"数据在业务链路的任何一个节点上被明文读取"的问题。

重放攻击就更不用说了,HTTPS 对重放完全无感。攻击者把一次合法请求原样再发一百次,TLS 层是不会有任何反应的,因为它只是运输管道。所以业务接口层面的加密、签名、时效校验,必须自己在代码层实现。

1.2 纯 RSA 和纯 AES 各自的硬伤

既然要做应用层加密,很多人第一个想法是用 RSA,毕竟非对称加密听起来更安全。但实际上纯用 RSA 做接口加密有两个致命问题。

第一个是长度限制。RSA 单次加密的明文长度受密钥长度和填充模式限制,比如 2048 位密钥 + PKCS1 填充,单次最多只能加密 245 个字节。一个正常业务的请求体随随便便就超过 245 字节了,要么做分块加密,要么只能加密摘要,非常别扭。第二个是性能。非对称加密涉及大数模幂运算,比我后面要讲的 AES 慢几个数量级,在高并发接口上撑不住。

那纯用对称加密 AES 行不行?功能上完全没问题,AES 加解密速度非常快,长度也不受限。但问题出在密钥配送:客户端和服务端要共用同一个对称密钥,这个密钥怎么安全地给到客户端?如果每次请求都把 AES 密钥明文放在请求头里,攻击者一抓包就拿到了,后面的加密形同虚设。如果提前线下约定一个固定密钥,一旦泄露就是所有接口全部失守,而且密钥轮换成本极高。

所以现实中的方案基本都是 RSA + AES 混合加密,用一句话总结就是:RSA 负责保护 AES 密钥的传输,AES 负责加密实际业务数据。RSA 只加密一个 32 字节左右的 AES 密钥,性能压力很小;AES 加密整个请求体,速度完全跟得上。两边各干各擅长的事。

1.3 攻防模型:加密、签名、时效三件事分开看

在设计这套方案之前,我建议先把要防的攻击类型列清楚,不要糊在一起做。我们当时梳理出的三个核心风险是:

威胁类型攻击方式防护手段
泄露抓包、日志偷看、中间节点读取明文参数请求体和响应体整体加密
篡改中间人修改金额、收款账号等关键参数RSA 数字签名 + AES-GCM 完整性校验
重放把抓到的合法请求原样重复发送时间戳窗口 + 一次性 nonce 缓存

这三件事必须支撑同一个方案里,但不能混为一谈。加密解决的是"看不懂",签名解决的是"改不了",时效校验解决的是"重复发也没用"。后面所有的设计都会围绕这三条线展开。

2. 报文与密钥体系设计

2.1 双 RSA 密钥对:服务端加密、客户端签名

动手写代码之前,有一件事必须先想明白:AES 密钥由哪一方生成。我们最终的方案是客户端生成 AES 密钥。客户端每次请求都会生成一个随机的 AES key,用它加密请求体,然后用服务端的 RSA 公钥把这个 AES key 加密到请求头里。服务端拿到后用 RSA 私钥解出 AES key,用这个 key 解密请求体;响应的时候,服务端再用同一个 AES key 加密响应体返回给客户端,客户端本地就持有这个 key,直接解密即可。

这样做的好处是整个流程里只做一个密钥协商动作,服务端不需要再把新的密钥送回客户端,闭环非常干净。每次请求都是一个全新的 AES 密钥,哪怕某一个请求的 AES key 被破解,也不会影响其他请求,攻击面被压到最小。

这里还要引入一个容易忽略的设计点:需要两对 RSA 密钥,而不是一对。

  • 服务端密钥对:服务端持有私钥,用于解密 AES key;客户端持有公钥,用于加密 AES key。
  • 客户端密钥对:客户端持有私钥,用于对请求内容做数字签名;服务端持有公钥,用于验签。

为什么不能共用一对?因为一个密钥对共用的话,"加密"和"签名"两个职责就混在一起了。持有公钥的一方既能加密数据又能验证签名,持有私钥的一方既能解密又能签名,角色边界完全是糊的。分拆成两对之后,服务端的私钥只做解密,永远不会用来签名;客户端的私钥只做签名,永远不会把自己的私钥发给别人。这个设计在对接多家外部系统时尤其重要,每家客户端的签名公钥可以独立管理、独立吊销。

2.2 请求头、请求体和响应格式约定

报文格式是整个方案的规格书,必须在一开始就定死,不然前后端联调一定互相甩锅。我们的约定是这样的:

POST /api/secure/order HTTP/1.1 Content-Type: text/plain X-Encrypted-Key: Base64(RSA_Encrypt(AesKey)) X-Timestamp: 1731234567890 X-Nonce: 7fe6f892-84f2-4c1d-9b3c-123456789abc X-Sign: Base64(SHA256withRSA(encryptedKey + timestamp + nonce + body密文)) 请求体:Base64(AES_GCM_Encrypt(业务JSON明文))

有三个点值得注意。

第一,请求体的 Content-Type 是 text/plain,不是 application/json。因为整个 body 是一段 Base64 密文,不是结构化 JSON,再标 application/json 反而误导后面的解析器。

第二,X-Encrypted-Key 里面装的是 RSA 加密后的 AES 密钥字节,Base64 编码后放进去。服务端拿私钥解出来的是一个 16/24/32 字节的密钥数组,不是字符串,后面直接用 SecretKeySpec 包成 AES 密钥。

第三,X-Sign 签名的原材料是encryptedKey + timestamp + nonce + body密文,这些都是字符串拼接,不需要额外分隔符,因为每个字段长度本身就是确定的。签名之前先拼接,签名之后整体 Base64。

响应格式则统一包裹一层:

{ "code": 0, "msg": "success", "data": "Base64(AES_GCM_Encrypt(业务JSON明文))" }

这里的 data 字段是一个密文字符串,客户端收到后用本地保存的 AES key 解密,再反序列化成业务 DTO。为什么响应不单独再搞一套密钥交换?因为没必要,同一个请求已经协商好了 AES key,响应继续复用,对称加密本来就是双向的。

2.3 签名到底签什么,为什么必须包含密文和随机因子

签名是防止篡改的核心,但"签名到底签什么"这个细节特别容易做错。我们踩过一次坑之后总结出来的原则是:签名内容必须把本次请求的所有关键要素全部绑定进去。

签名的原材料是四个部分的拼接:X-Encrypted-Key的值、X-Timestamp的值、X-Nonce的值、请求体密文(Base64 字符串)。

  • 包含encryptedKey是为了防止攻击者替换整个请求头里的 AES 密钥。如果签名不包含它,攻击者可以把自己生成的 AES key 换进去,再用自己的 key 重新加密请求体,服务端拿自己私钥解出来的就是攻击者的 key,后面完全失控。
  • 包含timestamp和nonce是为了把签名和防重放机制绑定。签名只保护内容不被修改还不够,还要确保这些防重放字段本身没有被替换。
  • 签的是密文而不是明文,这样签名验证发生在解密之前。服务端先拿客户端公钥验签,验签通过说明请求在传输过程中没有被动过手脚,再去做解密操作。如果反过来先解密再验签,万一数据被篡改导致解密直接报错,你连验签的机会都没有,排查起来也分不清是密钥不对还是内容被改。

整个校验顺序我们固定为:读头 → 校验时间戳 → 校验 nonce → 验签 → 解密请求体。这个顺序不是随便定的,每一步都在缩小攻击面,每一步失败都能直接返回明确的错误码。

2.4 防重放的两道闸:时间戳窗口与一次性 nonce

防重放如果只做时间戳或只做 nonce,都是不够的,必须两个配合。

时间戳解决的是"时间维度上的过期"。客户端在请求头带一个毫秒级时间戳,服务端收到后对比当前时间,如果差值超过配置的窗口(默认 5 分钟),直接拒绝。这就把攻击者的重放时间窗口压缩到了 5 分钟以内。但光有它不够,因为攻击者在 5 分钟内原样重放一个请求,服务端是无法通过时间戳判断出来的。

nonce 解决的是"同一时刻的重复"问题。每个请求带一个全局唯一的随机串,服务端把用过的 nonce 记录下来,当同一个 nonce 再次出现时,说明这是一次重放。nonce 缓存的时间要略大于时间戳窗口,比如时间窗口 5 分钟,nonce 至少缓存 10 分钟。这样攻击者在一个窗口内重放,nonce 还在缓存里,拒绝;过了窗口再重放,时间戳已经失效,照样拒绝。

这两个机制配合起来才能做到:每一个加了密、签了名的请求,只有在时间窗口内、且 nonce 唯一、且签名匹配、且能被服务端私钥正确解密时,才会被真正执行。任何一步不对,请求都会被拦在前面。

3. Spring Boot 完整落地实现

3.1 工程入口:依赖和配置加载

这套方案本身不依赖第三方加解密库,JDK 自带的 javax.crypto 和 java.security 就够用了,所以工程依赖很少。我用 Spring Boot 2.7 演示,Spring Boot 3.x 代码也基本一致。

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>

加解密工具类里会用到 java.security 包。真正要额外引入的是一个缓存库,我后面会在 nonce 缓存部分说清楚,本地单机直接用 ConcurrentHashMap 就能撑住,生产环境建议换成 Redis。

密钥配置放到 application.yml,这里只展示核心配置项:

api-crypto: server-private-key: | MIIEvQIBADANBgkqhkiG9w0BAQEFAASC... client-public-key: | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A... timestamp-window-ms: 300000 nonce-expire-ms: 600000

server-private-key 是服务端 RSA 私钥,用来解密客户端传过来的 AES key,PKCS#8 格式。client-public-key 是客户端 RSA 公钥,用来验签,X.509 格式。私钥绝不放进代码仓库,生产环境应该从环境变量或配置中心读取。这里为了演示方便才写在 yml,线上千万别这么干。

3.2 AES-GCM 加解密工具类

AES 模式我强烈建议用 GCM,也就是AES/GCM/NoPadding。GCM 是 AEAD 模式,密文里自带完整性认证标签,解密时会自动校验数据有没有被改动过,相当于加密和防篡改一起做了。如果用 ECB 或 CBC,加密之外还得额外搞 MAC,很容易在拼接时出差错。

AES 工具类的核心实现:

public final class AesUtils { private static final int IV_LENGTH = 12; private static final int GCM_TAG_BITS = 128; private static final String TRANSFORM = "AES/GCM/NoPadding"; public static String encrypt(String plainText, byte[] aesKey) throws Exception { byte[] iv = new byte[IV_LENGTH]; SecureRandom secureRandom = SecureRandom.getInstanceStrong(); secureRandom.nextBytes(iv); Cipher cipher = Cipher.getInstance(TRANSFORM); SecretKeySpec keySpec = new SecretKeySpec(aesKey, "AES"); GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_BITS, iv); cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec); byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); // 将 IV 拼到密文前面,方便解密时取回 byte[] result = new byte[iv.length + encrypted.length]; System.arraycopy(iv, 0, result, 0, iv.length); System.arraycopy(encrypted, 0, result, iv.length, encrypted.length); return Base64.getEncoder().encodeToString(result); } public static String decrypt(String cipherBase64, byte[] aesKey) throws Exception { byte[] all = Base64.getDecoder().decode(cipherBase64); // 前 12 字节是 IV byte[] iv = new byte[IV_LENGTH]; byte[] encrypted = new byte[all.length - IV_LENGTH]; System.arraycopy(all, 0, iv, 0, iv.length); System.arraycopy(all, iv.length, encrypted, 0, encrypted.length); Cipher cipher = Cipher.getInstance(TRANSFORM); SecretKeySpec keySpec = new SecretKeySpec(aesKey, "AES"); GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_BITS, iv); cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec); byte[] plain = cipher.doFinal(encrypted); return new String(plain, StandardCharsets.UTF_8); } }

GCM 模式下每次加密都必须生成一个全新的随机 IV,IV 不能固定,更不能复用。一旦同一个 AES key 下 IV 复用了,攻击者可以通过 XOR 密文推算出明文关系。我们把 IV 直接拼在密文前面返回,解密时取前 12 字节还原 IV,这个做法在实际项目里非常常见。

3.3 RSA 加解密与 SHA256withRSA 签名验证

RSA 工具类主要做三件事:加载密钥、公钥加密 AES key、私钥解密 AES key。密钥加载要注意 yml 里的换行,我用 replace 把-----BEGIN PRIVATE KEY-----头尾和换行符全部清掉,再交给 KeyFactory 解析。

public final class RsaUtils { public static PrivateKey loadPrivateKey(String privateKeyPem) throws Exception { String content = privateKeyPem .replace("-----BEGIN PRIVATE KEY-----", "") .replace("-----END PRIVATE KEY-----", "") .replaceAll("\\s", ""); byte[] keyBytes = Base64.getDecoder().decode(content); PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes); KeyFactory factory = KeyFactory.getInstance("RSA"); return factory.generatePrivate(spec); } public static PublicKey loadPublicKey(String publicKeyPem) throws Exception { String content = publicKeyPem .replace("-----BEGIN PUBLIC KEY-----", "") .replace("-----END PUBLIC KEY-----", "") .replaceAll("\\s", ""); byte[] keyBytes = Base64.getDecoder().decode(content); X509EncodedKeySpec spec = new X509EncodedKeySpec(keyBytes); KeyFactory factory = KeyFactory.getInstance("RSA"); return factory.generatePublic(spec); } public static String encryptAesKeyByPublicKey(byte[] aesKey, PublicKey publicKey) throws Exception { Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding"); cipher.init(Cipher.ENCRYPT_MODE, publicKey); return Base64.getEncoder().encodeToString(cipher.doFinal(aesKey)); } public static byte[] decryptAesKeyByPrivateKey(String encryptedKeyBase64, PrivateKey privateKey) throws Exception { byte[] encryptedKey = Base64.getDecoder().decode(encryptedKeyBase64); Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding"); cipher.init(Cipher.DECRYPT_MODE, privateKey); return cipher.doFinal(encryptedKey); } }

签名和验签要单独拆一个类出来,因为它们是另一个 RSA 密钥对的工作:

public final class SignUtils { private static final String ALGORITHM = "SHA256withRSA"; public static String sign(String content, PrivateKey clientPrivateKey) throws Exception { Signature signature = Signature.getInstance(ALGORITHM); signature.initSign(clientPrivateKey); signature.update(content.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(signature.sign()); } public static boolean verify(String content, String signBase64, PublicKey clientPublicKey) throws Exception { Signature signature = Signature.getInstance(ALGORITHM); signature.initVerify(clientPublicKey); signature.update(content.getBytes(StandardCharsets.UTF_8)); byte[] signBytes = Base64.getDecoder().decode(signBase64); return signature.verify(signBytes); } }

签名算法固定用 SHA256withRSA。签名内容的拼接规则要和客户端完全一致:encryptedKey + timestamp + nonce + body密文,顺序错了、多了空格、多了换行,验签都会失败,这是联调阶段最常遇到的问题。

3.4 注解与上下文线程变量

为了让加解密不侵入业务代码,我用自定义注解 + 拦截器 + 参数解析器 + ResponseBodyAdvice 这套组合来实现,而不是在业务方法里手动调用加解密工具。

先定义注解:

@Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) public @interface ApiCrypto { // 是否需要解密请求体 boolean decrypt() default true; // 是否需要加密响应体 boolean encrypt() default true; }

加在 Controller 的方法上,或者整个类上。比如:

@ApiCrypto @PostMapping("/secure/order") public OrderResp createOrder(@DecryptedBody OrderReq req) { // 业务代码 }

再定义一个线程上下文。为什么要它?因为 AES key 在拦截器里解出来之后,后面参数解析器要拿明文、响应通知要拿 AES key 加密响应,它们都在同一个请求线程里,用 ThreadLocal 传递最直接:

public final class CryptoContext { private static final ThreadLocal<SecretKey> CURRENT_KEY = new ThreadLocal<>(); public static void setKey(SecretKey key) { CURRENT_KEY.set(key); } public static SecretKey getKey() { return CURRENT_KEY.get(); } public static void clear() { CURRENT_KEY.remove(); } }

这里必须强调,ThreadLocal 用完一定要在拦截器的 afterCompletion 里调用 clear(),否则高并发场景下线程池复用会导致上一个请求的 AES key 泄漏到下一个请求,轻则解密报错,重则出现数据串号。

3.5 解密拦截器:把密文请求还原成明文

拦截器是整个方案的调度中枢。它的职责按固定顺序执行:读请求头、校验时间戳、校验 nonce、验签、用服务端私钥解出 AES key、用 AES key 解密请求体、把明文放进 request attribute 和 CryptoContext。

@Component public class CryptoInterceptor implements HandlerInterceptor { private static final String BODY_KEY = "CRYPTO_PLAIN_BODY"; @Value("${api-crypto.timestamp-window-ms:300000}") private long timestampWindowMs; private final NonceCache nonceCache; public CryptoInterceptor(NonceCache nonceCache) { this.nonceCache = nonceCache; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod handlerMethod)) { return true; } ApiCrypto apiCrypto = findAnnotation(handlerMethod); if (apiCrypto == null || !apiCrypto.decrypt()) { return true; } String encryptedKey = request.getHeader("X-Encrypted-Key"); String timestampStr = request.getHeader("X-Timestamp"); String nonce = request.getHeader("X-Nonce"); String sign = request.getHeader("X-Sign"); String encryptedBody = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8); if (encryptedKey == null || timestampStr == null || nonce == null || sign == null) { throw new ApiCryptoException("missing required headers"); } // 1. 时间戳校验 long timestamp = Long.parseLong(timestampStr); long diff = Math.abs(System.currentTimeMillis() - timestamp); if (diff > timestampWindowMs) { throw new ApiCryptoException("request expired"); } // 2. nonce 防重放校验 if (nonceCache.isReplay(nonce)) { throw new ApiCryptoException("replay attack detected"); } // 3. 验签 String signContent = encryptedKey + timestampStr + nonce + encryptedBody; boolean valid = SignUtils.verify(signContent, sign, ClientPublicKeyHolder.get()); if (!valid) { throw new ApiCryptoException("sign verify failed"); } // 4. 解密 AES key byte[] aesKeyBytes = RsaUtils.decryptAesKeyByPrivateKey(encryptedKey, ServerPrivateKeyHolder.get()); SecretKey aesKey = new SecretKeySpec(aesKeyBytes, "AES"); // 5. 解密请求体 String plainBody = AesUtils.decrypt(encryptedBody, aesKeyBytes); // 6. 保存上下文 request.setAttribute(BODY_KEY, plainBody); CryptoContext.setKey(aesKey); return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { CryptoContext.clear(); } private ApiCrypto findAnnotation(HandlerMethod handlerMethod) { ApiCrypto methodAnn = handlerMethod.getMethodAnnotation(ApiCrypto.class); if (methodAnn != null) { return methodAnn; } return handlerMethod.getBeanType().getAnnotation(ApiCrypto.class); } }

关于密钥对象的获取,我有一个封装没写全:ServerPrivateKeyHolder 和 ClientPublicKeyHolder 是启动时从配置中心加载并缓存的密钥持有器。它们本质上是静态字段,服务启动时初始化好,避免每次请求解析一遍 PEM 字符串。

接下来是参数解析器。为什么不用 @RequestBody?因为我发现 HandlerInterceptor 里替换 request 对象并不能影响后面 HandlerAdapter 读取 body,Spring MVC 在进入拦截器阶段之后,@RequestBody 的解析用的是原始 request。要让 @RequestBody 读到解密后的内容,就必须在更早的 Filter 阶段包装 request,那样又拿不到注解信息做精细控制。所以我干脆改用自定义 @DecryptedBody 注解 + HandlerMethodArgumentResolver,从 request attribute 里取明文。

@Target(ElementType.PARAMETER) @Retention(RetentionPolicy.RUNTIME) public @interface DecryptedBody { }
@Component public class DecryptedBodyResolver implements HandlerMethodArgumentResolver { private final ObjectMapper objectMapper = new ObjectMapper(); @Override public boolean supportsParameter(MethodParameter parameter) { return parameter.hasParameterAnnotation(DecryptedBody.class); } @Override public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); String plainBody = (String) request.getAttribute("CRYPTO_PLAIN_BODY"); if (plainBody == null) { throw new ApiCryptoException("request body not decrypted"); } if (parameter.getParameterType() == String.class) { return plainBody; } return objectMapper.readValue(plainBody, parameter.getParameterType()); } }

最后在 WebMvcConfigurer 中注册:

@Configuration public class WebConfig implements WebMvcConfigurer { private final CryptoInterceptor cryptoInterceptor; private final DecryptedBodyResolver decryptedBodyResolver; public WebConfig(CryptoInterceptor cryptoInterceptor, DecryptedBodyResolver decryptedBodyResolver) { this.cryptoInterceptor = cryptoInterceptor; this.decryptedBodyResolver = decryptedBodyResolver; } @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(cryptoInterceptor).addPathPatterns("/api/secure/**"); } @Override public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) { resolvers.add(decryptedBodyResolver); } }

这个组合干净利落:拦截器只负责解密和校验,参数解析器负责把明文塞进 controller 的参数,两者各干各的,互不干扰。

3.6 加密响应:统一返回结构闭环

响应加密用 ResponseBodyAdvice,在所有 Controller 方法执行完成后、结果被序列化之前拦截。

@ControllerAdvice public class EncryptResponseAdvice implements ResponseBodyAdvice<Object> { private final ObjectMapper objectMapper = new ObjectMapper(); @Override public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) { ApiCrypto classAnn = returnType.getContainingClass().getAnnotation(ApiCrypto.class); ApiCrypto methodAnn = returnType.getMethodAnnotation(ApiCrypto.class); return methodAnn != null || classAnn != null; } @Override public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) { SecretKey aesKey = CryptoContext.getKey(); if (aesKey == null) { return body; } try { String plainJson = body instanceof String ? (String) body : objectMapper.writeValueAsString(body); String encrypted = AesUtils.encrypt(plainJson, aesKey.getEncoded()); Map<String, Object> wrapper = new HashMap<>(); wrapper.put("code", 0); wrapper.put("msg", "success"); wrapper.put("data", encrypted); return wrapper; } catch (Exception e) { throw new ApiCryptoException("response encrypt failed", e); } } }

这里有几个细节要提醒。supports() 中判断注解的逻辑和后端拦截器保持一致,类注解和方法注解都要处理。响应加密用的 AES key 必须是同一个请求协商出来的 key,这也是为什么前面用 ThreadLocal 保存它。如果响应体中某个业务对象里已经包含了敏感字段,整个 JSON 都会被加密成一段密文丢进 data 字段,客户端反序列化后看到的是完整的业务 JSON。

3.7 一次性 nonce 缓存:并发安全的本地实现

nonce 防重放需要一个能并发读写的缓存。我先给出本地单机可用的实现,生产环境多实例部署时建议直接换成 Redis。

@Component public class NonceCache { private final ConcurrentHashMap<String, Long> cache = new ConcurrentHashMap<>(); private final long expireMs; public NonceCache(@Value("${api-crypto.nonce-expire-ms:600000}") long expireMs) { this.expireMs = expireMs; // 定期清理过期 nonce,避免缓存无限增长 ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); scheduler.scheduleAtFixedRate(this::cleanup, 5, 5, TimeUnit.MINUTES); } public boolean isReplay(String nonce) { long now = System.currentTimeMillis(); Long oldTimestamp = cache.putIfAbsent(nonce, now); if (oldTimestamp != null) { return true; } return false; } private void cleanup() { long now = System.currentTimeMillis(); cache.entrySet().removeIf(entry -> now - entry.getValue() > expireMs); } }

putIfAbsent 这个操作是原子性的,同一时刻只有一个线程能把一个 nonce 放进缓存,所以天然能挡住并发场景下的重放请求。性能上 ConcurrentHashMap 在高并发下表现不错,但本地缓存只适用于单实例。如果服务做了水平扩展,请求可能打到不同实例上,本地缓存就失效了。

生产环境多实例的正确做法是用 Redis:

Boolean first = redisTemplate.opsForValue() .setIfAbsent(nonce, "1", Duration.ofMillis(nonceExpireMs)); if (Boolean.FALSE.equals(first)) { throw new ApiCryptoException("replay attack detected"); }

Redis 的 SETNX 能力和 putIfAbsent 完全一致,但缓存是全局共享的,不管请求打到哪台机器上都能识别出重复 nonce。而且 key 自动过期,不用自己写清理任务。我建议优先用 Redis,本地缓存的方案只适合演示或单机部署的小服务。

4. 线上踩过的坑与排查清单

4.1 二进制与字符串互转的编码陷阱

第一次联调时最容易爆的坑,就是把 AES 解密后的 byte[] 直接new String(bytes)用默认编码转字符串。AES 加密输出的字节是任意 0-255 的值,不是合法的 UTF-8 序列,如果中间要用字符串承载密文,必须用 Base64 编码,绝不能直接拼进字符串里。

同理,请求体密文经过 Base64 后是 ASCII 字符,在 HTTP 传输过程中没有任何问题。但如果有人图省事,把加密后的字节数组用 UTF-8 转字符串放进 JSON,解密时再用 UTF-8 取回来,大概率会得到乱码或直接抛异常。还有一次我们遇到的是 Base64 字符串里混入了换行符,验签怎么都不过,排查了很久才发现是密钥解析时把换行一起编进去了。所有涉及密文的中间环节,只认 Base64。

4.2 输入流只能读一次的经典问题

这个坑在方案设计阶段就应该想清楚。HttpServletRequest 的输入流只能读取一次,拦截器读完了,后面 controller 的 @RequestBody 再读就是空流。网上很多文章教你用 ContentCachingRequestWrapper 把流缓存下来,但那是在 Filter 阶段替换 request 对象才有效。我后来改用 @DecryptedBody + 参数解析器,直接从 request attribute 里取明文,彻底绕开了流只能读一次的束缚。

如果你确实想保留 @RequestBody 写法,就必须用 Filter 在链路最前面包装一次 request,然后在 Filter 里根据 URL 判断哪些接口需要解密。这样做的代价是无法精细地用注解控制,只能靠路径匹配,灵活性差一些。两种方案都有道理,看团队更看重哪头。

4.3 签名不一致时按什么顺序排错

签名不一致是最常见的联调问题,而且报错信息非常让人抓狂,一个 verify failed 什么都看不出来。根据我这几个项目的经验,建议按照下面顺序排查。

先看拼接顺序。签名原材料必须是encryptedKey + timestamp + nonce + body密文,顺序差一点都不行。再看是否 Base64 解码后签的名,流程中任何一步多了一次 Base64 编码或解码都会导致结果不同。然后检查是不是密钥对错了,客户端用自己私钥签,服务端要用对应客户端的公钥验,如果对接了多家客户,公钥串了也是 verify failed。

还有一个很隐蔽的问题:某些 HTTP 客户端框架会在发送前对请求头做 Trim,或者对 header value 做规范化。如果某个 header 值被加了空格或去掉了换行,服务端拿到的字符串和服务端验签时用的字符串就不一致了。建议在联调阶段把服务端收到的原始 header 内容用日志打印出来,用肉眼对比两边到底哪里不一样。

4.4 时间窗口怎么定、时钟偏移怎么处理

时间戳窗口我们默认是 5 分钟,这个值不是拍脑袋定的。窗口太小,客户端服务器的时钟稍微有点偏差就会误伤正常请求;窗口太大,重放攻击的有效时间窗口就太长。5 分钟兼顾了大多数场景。

但要注意,时间戳本身是客户端上报的,攻击者完全可以改一个更小的时间戳来通过校验,所以 time 窗口只是防重放的第一道闸,真正拦住重复请求的是 nonce。另外服务器之间、客户端与服务器之间的时钟需要校时,否则长时间运行后系统时钟漂移,明明请求是新鲜的也会被判过期。生产环境必须有 NTP 校时,这个是隐形成本,很多人会忘。

4.5 性能与密钥保管的实战建议

性能方面,其实整个方案对系统的额外开销比想象中小。RSA 只解密一个 32 字节的 AES 密钥,AES-GCM 加密一整段 JSON 的速度非常快。真正要注意的是密钥对象的重复加载问题,如果我们每次请求都从 PEM 字符串解析一次 RSA 私钥,那才是性能瓶颈。所以我在前面专门提到用 ServerPrivateKeyHolder 在启动时做一次加载,后续请求直接复用对象。

Cipher 的创建也比较重,但每个请求只创建两三次,不算瓶颈。如果接口 QPS 非常高,可以考虑用 ThreadLocal 缓存 Cipher 实例,不过要注意 Cipher 不是纯线程安全的,一定要每个线程单独持有,不能共享。

密钥保管是这条链路里最敏感的一环。yml 里写私钥只配出现在本地开发环境,测试和生产环境的密钥必须放到配置中心或者专业的 KMS 服务里。私钥一旦泄露,攻击者就能解密所有用客户端公钥加密的 AES key,整个加密体系就形同虚设。建议上线前做一次密钥管理审计,至少做到:私钥不落代码库、不落日志、定期轮换、按客户端维度独立管理。

我个人在这些项目里最大的体会是,加解密本身并不难,难的是把每个环节的顺序和边界整理清楚。只要有一个环节的顺序理不清楚,排查起来就非常痛苦。所以如果你准备在项目里落地这套方案,建议第一步先写几个纯单元测试,把 AES 加解密、RSA 加解密、签名验签这四个动作单独测透,然后再往上接拦截器。联调阶段 90% 的问题都在签名不一致和密钥格式上,提前把工具方法测稳能省下大量时间。最后还有一个建议,把报文结构速查表贴到团队文档里,明确请求头字段、签名拼接顺序、错误码约定,这样不管是客户端接入还是后续维护,都不会靠猜。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询