1. 先想清楚高流量Spring Cloud项目里接口加密究竟要解决什么问题接了个任务领导要求给现有Spring Cloud项目实现接口加密。一开始想的很简单找个AES工具类Controller入口解密完事。真动手才发现事情远没这么简单——请求要过网关、要经过拦截器、要打日志、要进Feign调用每层都有机会碰到请求体而Servlet的输入流只能读一次你在这层读了后面的Controller就全瞎了。先厘清需求边界。接口加密通常包含两件事一是传输加密防止请求在网络链路中被抓包后直接看到明文参数二是防篡改和防重放保证请求体没有被中间人改过。大多数项目的实际诉求是第一件顺带能挡住一部分爬虫和恶意扫描。Spring Cloud架构下请求链路是客户端 → API网关 → 业务服务。这一步就带来一个核心问题解密到底在哪一层做团队内开了两次会方案争论不休方案优点缺点网关统一解密收敛业务侧无感知网关需要持有所有服务的密钥网关一旦被攻破所有服务的请求全部裸奔网关转发时内部网络传输的是明文各业务服务自行解密密钥分散、风险隔离每个服务都要改工作量大过滤器/拦截器逻辑重复网关只负责透传业务服务统一用公共组件解密兼顾安全性和工作量公共组件要设计好不然后期难维护最终选的是第三个网关透传服务内用公共加解密组件处理。原因很实际API网关和业务服务之间往往还有别的内部网络节点如果明文从网关流出那加密的意义就打了折扣而密钥放在各业务服务里就算某个服务被拖库也不会殃及所有服务。这个决策是整个项目的基石后续所有设计都围绕它展开。接下来就是对加解密方案本身的拆解。2. 加解密方案选型为什么是AESRSA混合加密而不是只用一种2.1 纯对称加密的问题密钥分发与安全性不可兼得第一版想得很简单客户端和服务端约定一个AES密钥所有请求体用这个密钥加密即可。AES加解密性能极好几KB的请求体加密也就零点几毫秒完全扛得住业务量。但纯AES的问题出在密钥管理上。客户端的密钥一旦在反编译、抓配置、日志打印中泄露整个系统的加密就形同虚设。如果定期更换密钥又面临一个麻烦客户端版本参差不齐老版本还没换新密钥服务端如果直接切新密钥老客户端的请求会全部解密失败。纯RSA又不行。RSA非对称加密性能太差加密1KB数据就要几十毫秒高并发下CPU直接被打满而且RSA有长度限制1024位的密钥最多只能加密117字节的数据一个正常的登录请求就远超这个长度根本没法直接加密请求体。所以采用混合加密客户端每次请求前随机生成一个AES密钥会话密钥用这个AES密钥加密请求体用服务端下发的RSA公钥加密这个AES密钥放在请求头里服务端用RSA私钥解出AES密钥再用它解请求体。相当于每请求一把独立的AES密钥即使某个请求被完整抓包也拿不到密钥去解密其他请求。RSA只负责加密一小串AES密钥性能开销完全可以忽略。这是目前比较成熟的做法也是我这次项目的最终方案。2.2 服务端如何识别客户端用的哪把公钥公钥下发这件事也踩过一个坑。当时简单做了个接口把RSA公钥返回给客户端客户端硬编码在代码里。后来要轮换密钥才发现所有客户端必须同时更新否则新公钥和旧私钥对不上线上请求大面积失败。这里要引入一个密钥版本的概念。服务端生成密钥对时给每对密钥起一个ID比如keyId1、keyId2公钥返回给客户端时带上keyId。客户端请求时在头里带上keyId字段服务端根据keyId找到对应的私钥去解密。轮换时服务端保留新旧两把私钥同时生效一段时间客户端逐步切换到新公钥后再下线旧密钥。这个设计一开始就要做好不然后面改成本更高。public class KeyPairHolder { private static final MapString, KeyPair KEY_PAIR_MAP new ConcurrentHashMap(); static { // 这里从配置中心加载密钥对keyId作为Map的key } public static PrivateKey getPrivateKey(String keyId) { KeyPair pair KEY_PAIR_MAP.get(keyId); if (pair null) { throw new BizException(不支持的密钥版本: keyId); } return pair.getPrivate(); } public static String getPublicKey(String keyId) { KeyPair pair KEY_PAIR_MAP.get(keyId); return Base64.getEncoder().encodeToString(pair.getPublic().getEncoded()); } }密钥体系设计到这里配套的核心类可以落地了。3. 核心实现可重复读取的请求体包装器与解密过滤器3.1 手写一个能反复读取请求体的WrapperServlet的HttpServletRequest里getInputStream()拿到的流是一次性的读完就没了。过滤器中想读取请求体读完InputStream后就无法传给后面的DispatcherServletController拿到的永远是null——这是所有接口加密项目遇到的第一道坎。解决办法是包装请求用一个自定义的ServletInputStream包装解密后的明文数据替换掉原来的流。这里有个让大家新手的点ServletInputStream是个抽象类必须实现三个方法isReady()、isFinished()、setReadListener()缺一个都编译不过。public class DecryptRequestWrapper extends HttpServletRequestWrapper { private final byte[] body; public DecryptRequestWrapper(HttpServletRequest request, byte[] body) { super(request); this.body body; } Override public ServletInputStream getInputStream() throws IOException { ByteArrayInputStream bais new ByteArrayInputStream(body); return new ServletInputStream() { Override public boolean isFinished() { return bais.available() 0; } Override public boolean isReady() { return true; } Override public void setReadListener(ReadListener readListener) { // 不需要实现保持空即可 } Override public int read() throws IOException { return bais.read(); } }; } Override public BufferedReader getReader() throws IOException { return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8)); } Override public int getContentLength() { return body.length; } Override public long getContentLengthLong() { return body.length; } }注意最后覆写了两个getContentLength这个细节容易漏。如果Content-Length还是原来的密文长度某些框架的报文解析会错乱出现数据截断或者多读的问题。3.2 过滤器里的解密主流程解密过滤器的核心逻辑是从原始Request中读取密文Body从请求头拿到会话密钥和密钥版本RSA解出AES密钥AES解出明文用明文构造新的Request传给后续链路。Component public class DecryptFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { // 约定加密请求的Content-Type为 application/octet-stream // 或者通过自定义头 X-Encrypted: true 标记 if (!isEncryptedRequest(request)) { filterChain.doFilter(request, response); return; } try { // 1. 读取密文Body byte[] ciphertext StreamUtils.copyToByteArray(request.getInputStream()); // 2. 从请求头获取会话密钥和密钥版本 String sessionKeyBase64 request.getHeader(X-Session-Key); String keyId request.getHeader(X-Key-Id); if (StringUtils.isBlank(sessionKeyBase64) || StringUtils.isBlank(keyId)) { throw new BizException(缺少加密请求头: X-Session-Key 或 X-Key-Id); } // 3. RSA私钥解出AES会话密钥 PrivateKey privateKey KeyPairHolder.getPrivateKey(keyId); byte[] sessionKeyBytes rsaDecrypt(Base64.getDecoder().decode(sessionKeyBase64), privateKey); // 4. AES解出明文 byte[] plainBytes aesDecrypt(ciphertext, sessionKeyBytes); // 5. 用明文构造新的Request DecryptRequestWrapper wrappedRequest new DecryptRequestWrapper(request, plainBytes); filterChain.doFilter(wrappedRequest, response); } catch (BizException e) { response.setStatus(HttpStatus.BAD_REQUEST.value()); response.getWriter().write(JsonUtils.toJson(ApiResult.error(e.getMessage()))); } catch (Exception e) { log.error(接口解密失败, uri: {}, request.getRequestURI(), e); response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()); response.getWriter().write(JsonUtils.toJson(ApiResult.error(解密失败))); } } }这套逻辑里有个容易踩的坑解密失败不能直接抛异常让全局异常处理器接管。因为在过滤器这一层异常处理器未必能覆盖到而且一旦把堆栈信息直接暴露给客户端等于把RSA密钥的使用情况告诉攻击者。最好是自己catch住返回统一的错误格式内部再打完整日志。还有一个经验加解密组件的日志一律打密文不要打明文。之前有同事在debug日志里把解密后的明文Body打印出来结果线上出问题时日志文件泄露用户手机号、订单信息全在里面被安全部门通报这个教训太惨痛。后来约定除非显式配置了decrypt.debug.enabledtrue否则只允许打印密文和请求路径。3.3 Controller层完全无感接收明文解密完成后后续链路拿到的就是一个正常的HTTP请求。Controller的写法没有任何特殊变化RestController RequestMapping(/api/order) public class OrderController { PostMapping(/create) public ApiResultOrderVO create(RequestBody CreateOrderRequest request) { // 这里拿到的 request 已经是解密后的明文对象 return ApiResult.success(orderService.create(request)); } }这也正是这套方案的收益所在加密逻辑全部收敛在过滤器中业务代码零侵入。老项目改造时Controller一行都不用动只要把客户端请求方式改成加密即可。但是这里也有一个容易产生误导的地方如果用Spring的拦截器HandlerInterceptor而不是过滤器有个明显的局限性——拦截器拿到的HttpServletRequest同样面临流只能读一次的问题而且Interceptor原生不能修改传入的Request对象再向下传递只能执行前置/后置逻辑让后续Handler拿到明文几乎做不到。AOP切入Controller虽然能拿到参数但在切面里读不到原始的请求流改造面反而更大。所以最终落在Filter这一层是整个链路里最合适的位置。4. 网关层的隐藏坑Header滤过与内部路由透传4.1 网关默认会过滤自定义Header当时项目用的网关组件自带一个默认行为只放行预设的Header白名单自定义的X-Session-Key、X-Key-Id默认都会被过滤掉。这导致一个诡异的现象本地联调加密接口一切正常到测试环境走网关后服务端一直报“缺少加密请求头”。排查了大半天最后打开网关的访问日志才发现请求到达业务服务时自定义头已经全部消失。网关层的Header放行配置必须显式加上否则加密请求头到不了后端。设置时要注意大小写不敏感。有些网关组件在Header匹配时用全小写匹配你在配置文件里写X-Session-Key没效果但写x-session-key就正常了。如果配置后还是拿不到Header优先排查是不是大小写导致的。4.2 网关转发时是否要重新设置Content-Length另一个坑是Content-Length。客户端加密后HTTP头里的Content-Length是密文的长度经过网关转发后有些网关会根据请求体自动重新计算有些则原样转发。服务端过滤器读取流时如果用的IOUtils.toByteArray这种工具一般没问题但某些底层的网络解析会依赖Content-Length导致读出来的流不完整。经验做法是服务端不要信任Header里的Content-Length直接读取完整InputStream以流实际内容为准。这样无论网关怎么改Header都不会出问题。4.3 网关层不应该解密但可以在网关做黑白名单既然网关不参与解密那网关能做什么可以做两件有意义的事放行或拦截加密接口的路径比如/api/encrypt/**统一走加密校验其余路径直接放行在网关层做粗粒度的限流和防刷拦截明显异常的请求比如请求头缺失、请求体过大的攻击尝试减轻后端解密压力。实际项目中我们同时开放了明文接口和加密接口明文接口面向低敏场景加密接口面向高敏场景登录、支付、订单、个人信息。网关根据路径前缀路由到不同的处理策略避免所有接口都套一层加解密徒增无谓的性能消耗。5. 踩坑实录请求体流只能读一次引发的连锁故障5.1 经典故障过滤器里读了流Controller收到null第一个故障出现在联调阶段。过滤器里为了解出Body先调用了request.getInputStream()然后解密得到明文拿着明文构造了DecryptRequestWrapper传给后续链路。结果Controller里RequestBody一直收到null排查了很久。问题出在这一行new DecryptRequestWrapper(request, plainBytes)——这个构造方法里我下意识写成了super(request)把原始request传给了父类。但在getInputStream()被覆盖的情况下其实应该正确返回新body的流而原代码里构造后的流已经空读后续链路再拿到的就是空流。这个案例对同类问题很有代表性对Servlet请求流的任何读取都必须发生在wrapper的getInputStream()里面一旦你在过滤器中先用原request读了流无论怎么包装都晚了。5.2 解密后的JSON字符串没能被RequestBody识别第二坑更隐蔽。解密得到的明文是JSON字符串比如{username:test,password:123456}。过滤器里我把它转成byte[]塞进wrapper。按道理Spring的HttpMessageConverter会自动读流并做JSON反序列化但Controller收到的对象却全是null字段没有映射上。原因在于消息转换器的类型判断依赖Content-Type。原来请求的Content-Type是application/octet-stream解密后我们没改Content-TypeSpring看到这个类型不知道该用哪个HttpMessageConverter来处理于是跳过了解析。解决方式在wrapper里覆写getContentType()返回application/json;charsetUTF-8同时确保getContentLength()返回明文的长度。这样Spring才能正确地把流反序列化成Controller的参数对象。Override public String getContentType() { return application/json;charsetUTF-8; }注意的是这个覆写只能针对统一JSON格式的接口。如果项目里有文件上传、表单提交这种非JSON的接口就不能在全局过滤器里面无脑改Content-Type而要根据请求路径或者请求头里标识的加密类型做区分。这也是我在做完第一版后返工的一个重要原因。5.3 动态AES密钥串导致RSA解密乱码客户端生成AES密钥后将其字符串形式比如32字节随机数base64后的字符串用RSA公钥加密。服务端拿到后用RSA私钥解密再base64解码。一开始两边约好的编码方式不同客户端是把AES密钥的字节数组直接塞进RSA/ECB/PKCS1Padding加密而服务端先对密文做了UTF-8解码再解密结果一直报BadPaddingException。这个报错有误导性大家下意识以为是RSA密钥不匹配实际是密文的字节在传输过程中被做了字符集转换导致字节数组变化。排查思路是客户端加密前打印RSA密文的Base64服务端用同一个Base64串本地解密对比是否一致——这一步能快速定位是编码链路问题还是密钥问题。最后统一规范AES会话密钥原始字节32字节由SecureRandom生成客户端流程AES密钥 → Base64编码 → 字符串 → RSA公钥加密 → Base64编码 → 放入HeaderX-Session-Key服务端流程Base64解码X-Session-Key→ RSA私钥解密 → 得到AES密钥的Base64字符串 → Base64解码 → 得到AES密钥原始字节。编码规范一旦定下生成客户端SDK时也要写成文档。这是所有联调问题的根源没有统一的编码约定前端和后端各写一套必然翻车。5.4 拦截器打印请求日志的时间点接完过滤器后发现原有的一套日志拦截器把请求参数打出来的时候全是密文线上排查问题非常痛苦。后来在日志拦截器里加了一个判断如果请求头有X-Encrypted: true就对Body打印做特殊处理只打印精简信息请求路径、耗时、状态码不再打印Body。同时把解密后的明文打印开关放到过滤器中通过配置控制。日志这块大家一定要提前规划如果等上线后业务方来投诉说排查问题看不到参数值那时候再改已经有些晚了。6. 性能测试与容量评估加密到底吃掉多少开销上线前我做了一轮压测。用一个模拟的订单创建接口请求体约1.2KB分别压明文版和加密版观察两版指标指标明文接口加密接口差异单机QPS18501705约-8%平均响应时间(ms)18224msCPU使用率40%48%8%8%的QPS损耗对于绝大多数业务来说可以接受。加解密的主要开销在RSA私钥解密约1-3ms和AES解密小于0.5ms整体比一次数据库查询还便宜所以不要过度担心性能。但如果项目是超高并发场景每秒上万请求建议再叠加两个优化复用加解密对象AES的Cipher实例不是线程安全的但可以通过ThreadLocal缓存避免每个请求重新初始化RSA解密结果缓存同一个客户端的RSA公钥加密后的AES密钥其实是变化的所以这里没法缓存。要优化的话可以改成服务端下发对称密钥客户端用对称密钥直接加密省掉一次RSA交互——不过这样安全性会有所降低需要根据业务场景权衡。性能这块没有银弹把耗时大头测出来再决定要不要优化比盲目上缓存更重要。7. 客户端接入提供SDK还是让调用方自己实现服务端做完后联调阶段最大的阻力来自客户端。Android、iOS、前端H5、第三方系统四波调用方各有各的语言和加密实现对加密协议的理解也不一致。当时整理了接近10页的联调文档结果还是每天都有各种加解密不一致的报错。最后统一做法提供一个跨平台的加密协议说明文档 一个明文示例调用Demo所有客户端照着Demo调通即可。文档里把关键点用表格列出来项约定请求方法POSTContent-Typeapplication/octet-streamBody内容AES加密后的密文Base64编码Header X-Session-KeyRSA加密后的AES密钥Base64编码Header X-Key-Id公钥版本号加密算法AES/GCM/NoPadding推荐或 AES/CBC/PKCS5PaddingRSA填充RSA/ECB/PKCS1Padding这里有个选择要说明AES的mode建议用GCM而不是CBC。CBC需要对IV初始向量做额外管理而GCM模式自带完整性校验密文被篡改后服务端解密时直接抛异常连防篡改逻辑都省了。GCM的额外开销也很小建议新项目直接用。引入GCM后的过滤器解密代码需要做个小调整这里给个示例密钥长度16字节public static byte[] aesGcmDecrypt(byte[] ciphertext, byte[] keyBytes) throws Exception { SecretKeySpec keySpec new SecretKeySpec(keyBytes, AES); // 约定密文前12字节为IV byte[] iv Arrays.copyOfRange(ciphertext, 0, 12); byte[] encrypted Arrays.copyOfRange(ciphertext, 12, ciphertext.length); Cipher cipher Cipher.getInstance(AES/GCM/NoPadding); GCMParameterSpec spec new GCMParameterSpec(128, iv); cipher.init(Cipher.DECRYPT_MODE, keySpec, spec); return cipher.doFinal(encrypted); }GCM模式下GCMParameterSpec(128, iv)里的128指的是认证标签长度单位bit一般用128也就是16字节。这个参数别写错了写错在某些JDK版本会直接报错或验签失败。8. 密钥管理的最佳实践配置中心动态刷新而不是写死在代码里项目最初把RSA密钥对放在配置文件里每次要换密钥都得发版重启非常痛苦。后来把密钥放到了配置中心动态刷新配合Value和配置监听器在运行期直接热更新密钥对。密钥存储需要注意私钥在配置中心里必须加密存储不能用明文配置。配置中心里单独加一层加密解密读取时再解密。有些团队直接用jasypt-spring-boot-starter这类组件配置文本里存密文应用启动时解密这个思路可取。轮换流程建议做成这样的步骤生成新的密钥对keyId1推送到配置中心服务端加载新密钥但保留旧私钥Map结构里新旧keyId并存客户端分批升级公钥从老keyId切到新keyId等所有客户端都切完之后再从配置中心删除旧私钥。这个过程中最怕的是“客户端没切、服务端先删了旧私钥”结果老用户全部401/解密失败。所以删除旧私钥前看监控日志中的keyId分布确认旧keyId几乎没了再下线。密钥管理这块没什么高深的东西考验的就是流程严谨度和对线上状态的观测能力。9. 上线后的监控与问题排查工具加解密上线后建议在日志中增加几个关键指标方便排查问题解密成功率每100次解密请求里成功了多少低于某个阈值直接告警解密失败原因分布是缺Header、RSA异常、还是AES异常各自占比多少keyId分布当前线上流量主要用的是哪个版本的公钥方便轮换时决策。用日志采集系统做简单的关键字统计即可不用上复杂的链路追踪。把失败原因分类打点用类似decrypt.fail.reasonbad_padding这样的日志标签后续排查直接从监控后台聚合。给排查者一个标准的处理路径先看Header有没有过来再看keyId对不对得上最后看密文解码是否正常。90%的问题都能在这三步里定位不需要翻到业务代码里一层层找。10. 忍不住再多说两句个人体会这次接口加密做完最大的体会不是技术难度而是在整个改造过程中对“解耦”的坚持。解密逻辑聚合成一个过滤器业务方零感知这是方案能顺利落地的关键。很多团队做这类改造容易把加解密代码散落在各种工具类和拦截器里时间一长就变成一团乱麻。还有一个容易被忽视的点加密和鉴权是两件事。接口加密解决的是“数据在传输过程中被看到/篡改”的问题而JWT、OAuth这些解决的是“谁在调用”的问题。不要觉得做了接口加密就可以不做登录鉴权这两个防护层叠加才能形成比较完整的安全体系。项目上线一个月线上解密失败率稳定在万分之几以内剩下的都是客户端历史版本没升级导致的运营推动升级后就归零了。整体改造的收益很明显之前安全巡检报告里抓包明文的问题彻底消失等保测评也因为接口加密加分不少。最后再分享一个个人习惯写这类公共组件一定要在项目里保留一个独立的“加解密联调Demo”模块里面模拟了完整的客户端加密 → 服务端解密链路。以后任何一个新客户端接入、或者新同事接手直接跑这个Demo就能自测不用每次都用postman手动算密钥能省下大把的时间。