1. 接口加密这件事,先搞清楚要防的是谁
若依(RuoYi)前后端分离版落地到实际项目里,最常被安全评审挑出来的一条就是:/system/user/list这类接口,打开浏览器 F12 或者挂个抓包工具,请求体里的手机号、身份证号、部门名称全是明文,响应体里的用户列表也原样躺在那里。接口加密这个词听着玄乎,实际上要解决的问题非常具体——让业务数据在浏览器到服务端这一段路上不以可读形式出现,同时让请求具备"不可篡改、不可重放"的属性。
我在几个基于 SpringBoot + 前后端分离的中后台项目里都做过这块改造,包括若依这套脚手架。踩过的坑不算少,有些坑甚至不是加密本身的问题,而是加密顺带把框架原有机制给打乱了。这篇内容围绕若依前后端分离版讲清楚四件事:方案怎么选、后端在过滤器链的哪个位置插入解密、前端 axios 拦截器里怎么组装报文、以及实测中那些让人抓狂的排查过程。适合已经把若依跑起来、想给接口加一层防护的后端同学,也适合负责前端请求封装的伙伴对照着改。
需要先摆正一个认知:前端加密永远不是"绝对安全",它的价值在于抬高攻击成本、满足合规要求、防止低级的信息泄露。任何声称"前端加密后数据绝对安全"的说法都不成立,因为密钥就在浏览器里。想明白这一点,后面的技术选型和投入尺度才不会跑偏。
1.1 抓包工具面前,明文接口有多透明
前后端分离之后,浏览器和服务端之间的通信全部走 HTTP 报文,只要用户在本地装了抓包工具、配了证书,或者干脆就是公司内部做安全测试的同事,请求和响应对他而言就是一本书。若依默认的登录接口/login提交的是用户名密码明文(虽然有 HTTPS 兜底),/system/user/list返回的是完整用户信息,/system/dept/list返回组织架构。这些东西一旦被批量抓取,配合一个简单的脚本就能拖库。
HTTPS 能挡住链路中间的窃听,但挡不住客户端本地的抓包,也挡不住"用户自己就是攻击者"这个场景。比如一个内部系统,员工账号泄露之后,攻击者拿这个账号登录,再改一改请求参数,就能拉到不属于自己的数据。接口加密在这种场景下的作用,是让攻击者无法通过简单地修改明文参数来构造请求——因为参数被加密了,改动之后服务端解密直接失败。
所以判断一个项目需不需要做接口加密,我的经验是看三条:一是有没有等保或者行业合规的硬性要求;二是接口里有没有身份证、手机号、薪资、病例这类敏感字段;三是接口权限模型是不是只靠前端隐藏菜单、后端缺少细粒度校验。三条中两条以上命中,就值得做。
1.2 加密、签名、防重放是三件不同的事
很多人把"接口加密"理解成"把请求体加密一下",实际上完整的报文防护包含三个互相独立的能力,缺一个都会留下口子。
**加密(Encrypt)**解决的是机密性——数据不以明文出现在报文中。攻击者抓包看到的是一串 Base64,读不懂。
**签名(Sign)**解决的是完整性——报文有没有被篡改。只加密不签名的话,虽然攻击者看不懂明文,但他可以把整个密文原样截下来再发一次,或者把 A 接口的密文贴到 B 接口上。签名是拿参数、时间戳、随机数加一个密钥算出来的哈希,服务端收到后重新算一遍,对不上就拒绝。
**防重放(Anti-Replay)**解决的是时效性——同一个合法报文能不能被无限次使用。做法是请求里带时间戳和一次性随机数(nonce),服务端校验时间戳在容忍窗口内(比如 5 分钟),并把 nonce 写进 Redis,TTL 覆盖窗口期,重复出现直接拒绝。
三者组合起来才是完整方案。只做加密,攻击者可以重放;只做签名,攻击者可以读明文;只做防重放,参数照样裸露。实际项目里我一般按这三个能力一起设计,代码量其实比想象中小,一个工具类加一个过滤器就能装下。
1.3 全站加密的代价与分级策略
有人上来就想给所有接口加解密,结果上线后发现两件事:性能掉了一截,前端到处报错。AES 加解密的开销单次几乎可以忽略(微秒级),但高频接口叠加上 Base64 编解码、JSON 序列化两次、大响应体翻倍,吞吐确实会下降。而且像文件下载、图片预览、验证码这些接口,加密之后就很难处理了。
我的做法是分级,按接口的敏感程度分三档:
| 等级 | 适用接口 | 处理方式 |
|---|---|---|
| L1 强保护 | 登录、实名认证、手机号/身份证相关、批量导出 | 加密 + 签名 + nonce 防重放 |
| L2 中保护 | 常规增删改查、列表查询 | 只做签名 + 时间戳,不加密请求体 |
| L3 不处理 | 验证码图片、文件上传下载、静态资源、健康检查 | 白名单直接放行 |
分级的意义在于把有限的复杂度花在刀刃上。L2 只签名不加密很简单,前端只是在请求头多带两个字段,后端在过滤器里做一次 HMAC 校验,几乎无感。L1 才需要动请求体。
这里有个细节要说清楚:分级判定不能让前端自己说了算,否则攻击者绕过前端直接把请求标成 L3 就通过了。正确做法是后端维护一份 URL 白名单(放行的)和一份强制加密的 URL 清单,前端加密属于"配合",后端校验属于"兜底"。真正的高危接口,后端必须强制要求签名头存在,缺失直接返回 401。
2. 算法与密钥方案:AES-CBC 打底,RSA/SM2 只解决密钥分发
算法选型这块,网上有很多"教科书式"的方案:前端用 RSA 公钥加密,后端用私钥解密。听起来很优雅,但真跑起来会撞上一个残酷的现实——RSA 只能加密不超过密钥长度减去填充开销的数据,2048 位密钥最多加密 245 字节。一个稍微大一点的 JSON 就超了,更别说列表接口的响应。
所以实际能落地的方案几乎都是对称加密打底,非对称加密最多用来传递对称密钥,或者干脆不用。若依这类中后台项目,我推荐的就是 AES-128/256 + CBC 模式 + PKCS5Padding,再配一个 HMAC-SHA256 签名。国密场景换成 SM4 + SM3,结构完全一样。
2.1 为什么绝大多数项目最后落回对称加密
对称加密与非对称加密的性能差距是数量级的。AES 在普通笔记本上单核能做几百 MB/s 的吞吐,RSA-2048 单次解密大概在毫秒级,而且还有上面说的长度限制。一个列表接口返回 200 条记录、200KB 的 JSON,用非对称加密根本走不通。
还有一个更现实的问题:前端做非对称加密需要引入 JS 库(jsencrypt、sm-crypto 之类),这些库处理大字符串时性能很差,在低端安卓机上能卡出明显的白屏。而对称加密用 crypto-js 里的 AES,性能表现可以接受。
那么 RSA 在方案里还有没有位置?有。如果你们的安全要求里明确"每次会话使用不同的对称密钥",那就可以用"前端随机生成 AES key,用 RSA 公钥加密后放在请求头,后端用私钥解出 AES key"这套混合模式。但代价是每个请求都要做一次 RSA 解密,且后端需要维持密钥与会话的关联(或者干脆一次一密,把加密后的 key 随请求传递)。对绝大多数中后台系统来说,这个复杂度收益比不划算。
我的建议很直接:固定密钥的 AES + HMAC 签名 + nonce 防重放,这套方案能覆盖 90% 的场景,代码量小,排查容易。只有金融、医疗这类强监管场景才考虑混合模式。
2.2 密钥写在前端就等于公开,这一点必须承认
不管你把密钥藏在哪个 JS 文件里,做了多少层混淆、拆成几段拼接、在运行时才组装,最终它都要在浏览器内存里以明文形式存在。稍微懂行的人打开 DevTools、打个断点、看一眼前端算出来的结果,密钥就出来了。
那这套方案还有什么意义?意义在于它防的是"批量脚本化的攻击"和"随手改参数的好奇心",挡不住的是"专门针对你的逆向分析"。这两类攻击者的成本差了两三个数量级,绝大多数系统只需要挡住前一类。
因此密钥管理上有几条实际经验:
- 前端密钥和后端密钥分环境配置,开发、测试、生产的密钥必须不同,避免测试环境泄露之后生产直接沦陷。
- 前端密钥至少做一次 Base64 编码或者简单的字符打散,不要让
const AES_KEY = '1234567890abcdef'这样赤裸裸地躺在打包产物里。 - 密钥定期轮换,后端支持同时接受新旧两把密钥(用版本号区分,请求头带
X-Key-Version),轮换时不影响在线用户。 - 后端密钥放配置中心或环境变量,不要写死在
application.yml里跟着代码一起进 Git。
2.3 和现有 JWT 认证体系的职责划分
若依前后端分离版的认证走的是 Spring Security + JWT,Token 放在Authorization请求头里。接口加密加进来之后,两者职责要理清楚,不然会出现"到底谁先校验"的混乱。
我的分工是这样的:JWT 负责"你是谁",签名负责"这个报文是不是你发的且没被改过",加密负责"报文内容别被人看见"。执行顺序上,先做解密 + 验签(在过滤器里),再做 JWT 认证(在 Spring Security 的过滤链里),最后进 Controller。
为什么这个顺序?因为签名里通常会把 Token 的摘要也算进去,服务端需要先验证报文完整,再去解析 Token 判断身份。如果反过来,先认证后验签,攻击者可以拿一个合法 Token 构造任意报文的签名请求——虽然他还是进不来(因为签名不对),但错误码会误导排查方向。
还有一个易错点:登录接口本身没有 Token,但它恰恰是最需要保护的接口(密码明文传输)。所以登录接口要放进"需要加密但不需要 Token 校验"的名单,在 Spring Security 的permitAll里放行,同时加密过滤器照样生效。
3. 后端改造:在若依的过滤器链上开一道解密口子
后端这块是整个改造的核心,也是最容易出问题的地方。核心难点只有一个:Servlet 的请求体流只能读一次。如果我们在过滤器里把 InputStream 读完解密,后面的@RequestBody就拿不到数据了,会直接报Required request body is missing。
解决办法是经典的HttpServletRequestWrapper套路:把原始流读出来、解密、塞进一个字节数组,然后用一个自定义的 Wrapper 覆盖getInputStream()和getReader(),让它每次都返回一个新的流对象。这个套路不新鲜,但在若依这种已经叠了好几层过滤器的框架里,位置和顺序要格外小心。
3.1 解密放在过滤器而不是拦截器的原因
Spring MVC 的HandlerInterceptor里读请求体是可以的,但问题是拦截器执行时,HttpMessageConverter还没有消费流,你必须自己读完再包一层,然后想办法把包装后的 request 传递下去——而拦截器的preHandle方法签名里根本没有办法替换 request 对象。所以拦截器这条路基本走不通。
@ControllerAdvice配RequestBodyAdvice倒是一个思路,它能在消息转换阶段介入,beforeBodyRead里可以替换输入消息。但这种方式只对@RequestBody生效,对表单提交、文件上传无能为力,而且和全局异常处理的配合比较别扭。
过滤器(Filter)是最合适的位置:它在 DispatcherServlet 之前执行,可以完整地替换 request 对象,对所有进入容器的请求统一生效。若依本身就用了不少过滤器(XSS 过滤、Repeatable 过滤等),我们加一个解密过滤器属于同一种模式,风格也统一。
3.2 可重复读的 RequestWrapper 写法
先看 Wrapper 的实现,这是整个方案的地基,写错了后面全是坑。
public class DecryptRequestWrapper extends HttpServletRequestWrapper { private final byte[] body; public DecryptRequestWrapper(HttpServletRequest request, byte[] body) { super(request); this.body = body == null ? new byte[0] : body; } @Override public BufferedReader getReader() { return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8)); } @Override public ServletInputStream getInputStream() { final 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 listener) { throw new UnsupportedOperationException(); } @Override public int read() { return bais.read(); } }; } @Override public int getContentLength() { return body.length; } @Override public long getContentLengthLong() { return body.length; } }这段代码里最容易被忽略的是最后两个方法。如果不重写getContentLength(),它返回的还是原始密文的长度,而解密后的明文长度通常和密文不同(Base64 解码后长度变短,但 JSON 明文又比原始密文短)。Spring 在读取 body 时如果依赖 Content-Length 做长度校验,就会出现"只读了一半"或者"读了多余字节"的问题,表现是 JSON 解析报Unexpected end of JSON input或者末尾多出乱码。这个坑我踩过一次,排查了整整一个下午。
3.3 过滤器注册顺序与若依 XSS 过滤的先后关系
若依默认带了 XSS 过滤器,它会对请求参数做转义处理,防住脚本注入。我们的解密过滤器必须排在它之前,否则 XSS 过滤器看到的是 Base64 密文(里面不会有尖括号,当然安全),但解密之后的明文就绕过了 XSS 检查,等于把防护撕开了一个口子。
用 Spring Boot 的FilterRegistrationBean显式指定 order,比@Order注解更可控,因为若依内部有些过滤器是通过@Bean注册的,注解和注册顺序混在一起容易乱。
@Configuration public class CryptoFilterConfig { @Bean public FilterRegistrationBean<DecryptFilter> decryptFilterRegistration(DecryptFilter filter) { FilterRegistrationBean<DecryptFilter> bean = new FilterRegistrationBean<>(); bean.setFilter(filter); bean.addUrlPatterns("/*"); bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 10); bean.setName("decryptFilter"); return bean; } }Ordered.HIGHEST_PRECEDENCE是Integer.MIN_VALUE,加 10 是为了给容器自身的编码过滤器留位置,同时保证排在其他业务过滤器前面。数字越小越靠前,这一点和拦截器相反,别记混了。
过滤器主体大致长这样:
@Component public class DecryptFilter extends OncePerRequestFilter { @Resource private ApiCryptoProperties props; @Resource private RedisCache redisCache; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String uri = request.getRequestURI(); if (!props.isEnabled() || MatchUtils.isWhite(uri, props.getUrlWhitelist())) { chain.doFilter(request, response); return; } String cipherText = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8); if (StringUtils.isEmpty(cipherText)) { // 无报文体的 GET 请求只校验签名 verifySign(request, ""); chain.doFilter(request, response); return; } // 1. 先验签(对密文验签,避免解密后被篡改) verifySign(request, cipherText); // 2. 校验 nonce 是否重复 checkNonce(request.getHeader("X-Nonce")); // 3. 解密 String plain = AesUtils.decrypt(cipherText, props.getAesKey()); chain.doFilter(new DecryptRequestWrapper(request, plain.getBytes(StandardCharsets.UTF_8)), response); } }注意第 1 步的顺序:对密文做验签。如果先解密再验签,攻击者可以构造任意密文让服务端先解一次,虽然大概率解密失败抛异常,但异常堆栈、错误信息本身就是信息泄露。对密文验签成本一样,安全性更好。
校验失败时统一抛一个自定义异常,交给若依的全局异常处理器GlobalExceptionHandler返回统一格式,不要在前端看到javax.crypto.BadPaddingException这种堆栈信息。
3.4 配置项与开关设计
配置项单独抽一个@ConfigurationProperties,方便后续调参和按环境覆盖。
api-crypto: enabled: true aes-key: "S3cr3tKeyForDemo0123456789ABCDEF" # 32 位,AES-256 sign-secret: "SignSecretForHmac0123456789" timestamp-tolerance: 300000 # 5 分钟,单位毫秒 nonce-ttl: 300 # Redis 过期时间,单位秒 url-whitelist: - /login - /captchaImage - /common/download - /profile/avatarenabled这个开关非常关键。开发阶段前后端联调加密会产生大量噪音,加个开关可以让前端在本地把加密关掉(VUE_APP_CRYPTO_ENABLED=false),后端也跟着关,联调效率高很多。上线前记得确认两边的开关都打开,我曾经遇到过一次测试环境关了忘了开,上线后所有的加密请求全被服务端当成"缺签名头"拒绝了,好在灰度及时发现。
签名算法本身很简单,把需要参与签名的要素按固定顺序拼成一个字符串,再用 HMAC-SHA256 算摘要:
signSource = method + "\n" + uri + "\n" + timestamp + "\n" + nonce + "\n" + body sign = Base64(HmacSHA256(signSource, signSecret))拼接顺序前后端必须完全一致,中间的分隔符也别省略,否则会出现"两边算法一样但结果永远对不上"的问题,而且这种问题极难排查,因为哈希值看起来都是乱的。我的习惯是把这套拼装逻辑写在文档里,前后端各自实现,联调时先拿同一个固定输入跑一遍对比输出,确认一致后再接业务。
4. 前端改造:axios 拦截器里的加密时序
若依前端是 Vue + axios,所有请求都走src/utils/request.js里统一的实例,拦截器已经做了 Token 注入、重复提交判断、错误码统一处理。加密代码要插进去,最忌讳的就是直接在最前面加一段,把原有的机制打乱。
前端有一个绕不开的技术细节要先说清楚:crypto-js 不支持 AES-GCM 模式,它只实现了 CBC、CFB、OFB、CTR、ECB 这几种。如果后端选了AES/GCM/NoPadding,前端用 crypto-js 根本对不上,只能改用浏览器原生的 Web Crypto API(crypto.subtle)。但crypto.subtle只在 HTTPS 或者 localhost 下可用,内网 HTTP 部署的环境直接报undefined。
所以前后端要约定同一个模式。我的选择是 CBC + PKCS7,虽然 CBC 在密码学上不如 GCM(没有内置完整性校验),但我们外层已经加了 HMAC 签名,完整性由签名保证,CBC 的弱点被补上了。
4.1 请求拦截器中加密、签名、时间戳的组装顺序
axios 的请求拦截器会按注册顺序倒序执行,若依默认就有一个拦截器做 Token 注入和重复提交判断。新增的加密拦截器必须加在它之后注册(也就是更早执行),否则加密完再判断重复提交,每次密文都不同,重复提交检查永远通过。
更稳妥的做法是干脆把加密逻辑写进现有的拦截器里,用明确的顺序控制。组装顺序是这样的:
- 判断是否需要加密(白名单 URL、开关状态)。
- 生成 13 位时间戳和 16 位随机 nonce。
- 把
config.data序列化成 JSON 字符串。 - 生成 16 字节随机 IV,AES-CBC 加密,输出
Base64(IV + 密文)。 - 用
method + uri + timestamp + nonce + 密文计算 HMAC 签名。 - 把密文替换
config.data,时间戳、nonce、签名、密钥版本塞进请求头。
前端加密的核心代码:
import CryptoJS from 'crypto-js' const AES_KEY = CryptoJS.enc.Utf8.parse(process.env.VUE_APP_AES_KEY) const SIGN_SECRET = process.env.VUE_APP_SIGN_SECRET function encryptBody(plain) { const iv = CryptoJS.lib.WordArray.random(16) const encrypted = CryptoJS.AES.encrypt(plain, AES_KEY, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }) // IV 前置拼接到密文,后端按前 16 字节取 IV return iv.concat(encrypted.ciphertext).toString(CryptoJS.enc.Base64) } function buildSign(method, uri, timestamp, nonce, body) { const source = [method.toUpperCase(), uri, timestamp, nonce, body].join('\n') return CryptoJS.HmacSHA256(source, SIGN_SECRET).toString(CryptoJS.enc.Base64) }有一个细节必须提醒:CryptoJS.lib.WordArray.random(16)生成的 IV 是 WordArray 对象,iv.concat(ciphertext)返回新对象,不会修改原 IV,这个行为是正确的。但如果你看到某些老代码里先iv.concat(...)再单独用iv,那可能会出问题,因为不同版本的 crypto-js 在concat的副作用上表现不一致,稳妥起见还是显式 clone 一份。
4.2 响应解密与 blob 下载的分流
响应这块比请求麻烦,因为 axios 的响应可能是 JSON,也可能是 blob(文件下载),还可能是纯文本。
如果后端统一做了响应加密,前端在响应拦截器里要先判断response.config.responseType:
- 是
blob或arraybuffer,直接放行,不解密。 - 是普通 JSON,取
response.data,它是一个 Base64 字符串,解密后JSON.parse回来。
service.interceptors.response.use(res => { if (res.config.responseType === 'blob' || res.config.responseType === 'arraybuffer') { return res } if (typeof res.data === 'string' && res.data.length > 0) { try { const plain = decryptBody(res.data) res.data = JSON.parse(plain) } catch (e) { // 非加密响应,保持原样 } } return handleCode(res) }, error => { ... })这里用try/catch兜底是必要的。总有一些接口没有走加密(比如第三方回调、健康检查),如果解密失败就抛异常,这些接口就全挂了。用一个宽松的 fallback,先把字符串当普通数据处理,能大幅降低上线的风险面。
还有一个更隐蔽的问题:加解密和解密失败的错误处理会互相掩盖。比如后端返回了一个 500 错误,响应体是若依统一的{code: 500, msg: 'xxx'},如果这个错误响应没走加密,前端解密失败后 fallback 到原样解析,看起来是对的。但如果错误响应走了加密,而前端在解析之前就抛了SyntaxError,用户看到的就是一个空白页面。所以错误响应的处理路径一定要和正常响应走同一套逻辑,别用两套代码。
5. 真实踩过的六个坑与排查顺序
加密这套东西,写代码可能只要两天,调通要一周,排查问题要更久。下面这几个坑都是我实际遇到过的,按"症状—定位—修复"的顺序写出来,方便你对照着排查。
5.1 请求体读两次变空:症状与定位路径
症状很典型:前端发了加密请求,后端日志显示过滤器里解密成功、明文也打印出来了,但进到 Controller 里@RequestBody参数全是 null,或者直接报Required request body is missing。
定位路径分三步:
第一步,在过滤器里打印request.getClass().getName(),确认包装是否生效。如果打出来还是org.apache.catalina.connector.RequestFacade,说明chain.doFilter传的还是原对象,包装没起作用。
第二步,检查是不是有其他过滤器在更前面读了流。若依自带的一些过滤器、日志组件、链路追踪组件都可能在早期读 body。可以用request.getInputStream().available()打印一下,如果是 0,说明已经被消费了。
第三步,检查 Content-Length。如果 Wrapper 里没重写getContentLength(),Spring 按旧的 Content-Length 读,读到一半就停了。
修复方案就是前面写的 Wrapper 完整实现,加上显式重写长度方法。如果项目里还有别的过滤器要读 body,那就统一改用ContentCachingRequestWrapper风格的缓存,或者干脆把解密过滤器提到最前面,独占读取权。
5.2 重复提交拦截失效
若依前端request.js里有一段重复提交判断,逻辑是把url + data作为 key 存进 sessionStorage,短时间内相同就拦截。加了加密之后,每次请求的 IV 随机、nonce 随机,密文完全不一样,这个判断就形同虚设了。
这个坑的影响不大(毕竟后端还有@RepeatSubmit注解兜底),但如果你的业务里前端拦截是关键防线,就得处理。我的做法是在加密之前用明文做重复判断:
const plainData = JSON.stringify(config.data || {}) if (isRepeatSubmit(config.url, plainData)) { return Promise.reject(new Error('数据正在处理,请勿重复提交')) } // 再做加密 config.data = encryptBody(plainData)后端那边,@RepeatSubmit的 key 是基于解密后的请求参数算的,所以后端拦截是正常工作的。这里要注意若依的RepeatSubmitInterceptor实现,它是拿request.getParameterMap()和 body 一起算 key 的,我们包装后的 request 里getParameterMap返回的是原始参数(加密请求通常不用 query 参数),body 是解密后的明文,算出来的 key 是对的。这一点我专门验证过,没问题。
5.3 Base64 里的加号在 URL 中变成空格
Base64 字符集里有+、/、=三个特殊字符,其中+在 URL 的 query string 里会被解码成空格。如果密文放在 URL 参数里传输(有些老接口这么干),后端收到之后解密必然失败,报Illegal base64 character或者解出来是乱码。
处理方式有两种:一是传输前做一次encodeURIComponent,后端先URLDecoder.decode再解密;二是换成 URL 安全的 Base64 变体,把+换成-、/换成_,去掉末尾的=。
Java 里用Base64.getUrlEncoder().withoutPadding(),前端用CryptoJS.enc.Base64.stringify(...).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')。两边必须严格一致,少做一步就对不上。
我现在的项目里密文一律放请求体,不放 URL,从源头上避开这个问题。但还是建议把 URL 安全的编码函数提前准备好,表单提交、重定向带参数这些场景迟早会用上。
5.4 大文件上传整体加密导致内存溢出
有人图省事,在 axios 拦截器里对所有请求统一加密,包括multipart/form-data的文件上传。结果用户传一个 200MB 的视频,浏览器直接把内存吃满,页面崩溃。
原因很直白:config.data如果是 FormData,你不能简单JSON.stringify,而如果强行把文件读成 ArrayBuffer 再加密,等于把一个 200MB 的 buffer 在内存里复制三份(原始、密文、Base64),现代浏览器也扛不住。
正确的处理是文件上传接口加入白名单,不做整体加密。如果业务上确实需要保护上传内容,那也不应该走前端加密,而是走 HTTPS + 服务端存储加密 + 短时效的上传凭证。前端加密在这个场景没有收益,只有风险。
顺带说一句,文件下载同理。若依的/common/download返回的是二进制流,如果被响应拦截器当成 Base64 字符串去解密,要么报错要么把文件搞坏。必须用responseType做分流,这个前面已经说过了。
5.5 时间戳与服务器时钟偏差
防重放的时间戳校验,最怕的就是用户设备的时钟不准。我遇到过一台测试机的时间比服务器慢了 8 分钟,所有请求都被判"时间戳过期",但设备上显示的时间看起来完全正常。
TP 容忍窗口设多大合适?太小容易误伤,太大会削弱防重放效果。我的经验值是 5 分钟(300000 毫秒),能覆盖绝大多数时钟偏差,同时把重放窗口控制在可接受范围内。
还有一个进阶处理:如果服务端发现时间戳偏差在 5 到 30 分钟之间,不要直接拒绝,而是在响应头里返回服务器的当前时间戳,前端拿到之后自动校准本地时钟(记录一个 offset,后续请求都用本地时间 + offset)。这个机制我在一个跨时区部署的项目里用过,效果不错。
要注意的是,时区并不是问题——时间戳统一用 UTC 毫秒数,前后端的Date.now()和System.currentTimeMillis()都是 UTC 基准,跟时区没关系。真正的问题只有设备时钟本身的偏差。
5.6 签名对不上时的排查顺序
签名不匹配是最让人头大的问题,因为哈希值看起来就是一堆乱码,没有任何可读信息。我总结了一个固定的排查顺序,按这个走基本十分钟内能定位:
| 排查项 | 检查方法 | 常见原因 |
|---|---|---|
| 拼接顺序 | 前后端各打印 signSource 原文 | 字段顺序不一致、少拼接或多拼接换行符 |
| 大小写 | 对比 method 是否统一大写 | 前端用了post,后端用了POST |
| 空值处理 | 空 body 时是否拼了空字符串 | 一方拼接了null字符串,另一方拼了空 |
| 字符编码 | 确认都是 UTF-8 | 中文参数在拼接时编码不一致 |
| 密钥 | 对比前后端密钥字符串长度 | 前端多了一个空格、环境变量没读到变成 undefined |
| 密钥版本 | 检查请求头 X-Key-Version | 轮换密钥期间新旧版本混用 |
最有效的手段是让前后端在开发环境把signSource完整打印出来,直接肉眼看差异。比看哈希值靠谱一百倍。生产环境记得把这个日志关掉,因为signSource里包含密文和密钥相关的中间值,泄露了等于降低了攻击门槛。
6. 一份上线前的自检清单
改造做完、联调通了,别急着上线。下面这几条是我每次都会过一遍的检查项,每一条背后都有一次教训。
开关与配置。确认api-crypto.enabled在生产环境是true,前端的VUE_APP_CRYPTO_ENABLED也是true,两边的 AES 密钥、签名密钥完全一致。我习惯在应用启动时打一行日志:API Crypto enabled: true, keyVersion: v2,上线后看日志就知道配置对不对。
白名单完整性。逐条验证白名单里的接口:登录、验证码、文件上传、文件下载、头像访问、第三方回调、健康检查、Swagger(如果生产要开的话)。漏掉一个,用户就会看到一个莫名其妙的解密失败错误。验证方法是上线前用 Postman 把所有白名单接口裸调一遍,确认都能正常返回。
异常兜底的友好性。解密失败、签名失败、时间戳过期、nonce 重复,这四类异常都要有独立错误码和清晰提示。不要把BadPaddingException的堆栈直接抛给用户。同时后端日志要记录完整的请求 URI、时间戳、客户端 IP,方便事后追溯。
与现有日志组件的兼容。若依默认有操作日志(@Log注解),它会把请求参数序列化存库。加密改造之后,@Log拿到的是解密后的明文,这是好事(日志有意义),但要注意敏感字段脱敏。若依有@Sensitive注解配合SensitiveJsonSerializer,把手机号、身份证加上脱敏,别让加密做完了、日志里全是明文。
压测数据对比。改造前后各压一轮核心接口,看 QPS 下降多少。我的实测数据是:AES-128 CBC 加解密 + HMAC 签名,在普通 4 核机器上单次开销大约 0.1 到 0.3 毫秒,QPS 下降 3% 到 8%,属于可接受范围。如果下降超过 20%,多半是哪里重复加解密了,或者 Base64 转换做了好几遍,值得查一查。
降级预案。万一上线后加密导致大面积不可用,能不能快速回滚?答案是能——api-crypto.enabled改成false重启即可,但有个前提:后端关闭加密之后,前端如果还在加密就会全部失败。所以要有平滑降级的设计,后端在关闭开关时,让解密过滤器"尝试解密,失败则按明文处理",这样前后端任何一边先切都不影响。这个逻辑加个 try-catch 成本很低,但关键时刻能救命。
最后分享一个我自己的习惯:这套加密改造,我会在项目里单独留一个docs/crypto.md,写明算法、密钥长度、拼接顺序、错误码定义、白名单维护规则。因为经验告诉我,这套东西一旦过了半年,连当初写它的人都要重新读一遍代码才能想起来签名是怎么拼的。文档写得糙一点没关系,关键是把那些"看起来理所当然但不能改"的约定写死,后面接手的人才不会随手一改就把线上搞挂。