1. 先搞清楚:RuoYi 前后端分离版为什么需要自己加接口加密
1.1 明文 JSON 暴露的三个真实风险
RuoYi 前后端分离版默认的交互形态很朴素:前端 axios 把业务参数塞进 JSON,后端@RequestBody接住,AjaxResult回一个 JSON,中间除了 JWT 的Authorization头,报文本身是完全明文的。框架自带的 RSA 开关只处理登录密码那一个字段,业务接口一个都没覆盖。我见过太多项目在上线评审时被问到"接口报文能不能被改",然后临时抱佛脚加一层。
风险不是理论上的。第一类是参数篡改:抓包工具挂在代理上,把userId从 1001 改成 1002、把roleIds数组里塞一个管理员角色 ID,后端如果只做了"登录校验"没做"数据归属校验",这一改就是一个越权漏洞。第二类是敏感字段裸奔:用户列表接口返回手机号、身份证、住址、银行卡尾号,这些内容在浏览器 Network 面板、网关访问日志、APM 链路追踪里全是明文,随便一个能看日志的人就能捞出来。第三类是重放:把提交订单的请求原样存下来,第二天再发一遍,如果后端没有幂等控制,就是一条重复业务数据。
这三类问题里,第二类靠字段级脱敏能挡一部分,第一类和第三类必须靠报文层的加密加签名。这就是我们要做的事情。
1.2 HTTPS 与应用层加密的边界在哪里
很多人第一反应是"我上了 HTTPS 不就完了"。HTTPS 解决的是链路问题,不是数据问题。请求到了 Nginx、到了网关、到了应用进程内部,它就是明文了。网关的 access log、后端的log.info("入参: {}", JSON.toJSONString(dto))、链路追踪平台抓的 span 属性,这些地方全是明文落地。只要有一个环节的日志权限没收紧,加密就等于没做。
再一个现实问题是多端接入。浏览器端抓包要装根证书,这需要用户主动配合,成本高;但移动端、小程序端、第三方对接方,抓包环境是他们自己控制的,你没法保证对方的客户端环境是干净的。应用层加密的价值就在于:数据在离开进程之前就已经变形了,中间任何一段落地的都是密文。
我不建议把应用层加密当成 HTTPS 的替代品,它是叠加层。正确的认知是:HTTPS 保证"路上没人看",应用层加密保证"落地了也看不懂、改了也用不了"。两层各管一段,谁也别想省。
1.3 三种改造路线的取舍
在 RuoYi 上做加密,实际能落地的路线有三条,我先把对比摊开,你再决定选哪条。
| 路线 | 实现成本 | 防读取 | 防篡改 | 对现有代码侵入 | 适用场景 |
|---|---|---|---|---|---|
| 全链路报文加密(请求体+响应体整体加密) | 中 | 是 | 需配合签名 | 低(过滤器/Advice 层) | 业务接口整体防护,本文方案 |
| 字段级加密(注解标记敏感字段) | 低 | 是 | 否 | 中(DTO 加注解) | 手机号、身份证、银行卡等定点脱敏 |
| 纯签名验签(只加 sign 不加解密) | 低 | 否 | 是 | 低 | 只关心参数不被改,可读性可接受 |
字段级加密的好处是精准、对调试友好,缺点是要一个个 DTO 去标,漏一个就漏一个,而且防不住"整体报文被读"这件事。纯签名验签最轻,前端加个 sign,后端验一下,但报文还是明文,日志里照样能看到手机号。
我最终选的是全链路报文加密 + HMAC 签名组合:报文整体加密解决"看不懂",签名解决"改不了",时间戳加随机数解决"重放"。这套组合在 RuoYi 这种标准 Spring MVC 结构上改造成本可控,主要动两个地方——后端一个 Filter 加一个 ResponseBodyAdvice,前端 request.js 里的两个拦截器。
提醒一句:加密不是免费的。它会带来看不到报文、联调变难、日志全废、性能多几次 Base64 和 AES 运算的代价。所以一定要做成可开关、可灰度、可按路径配置的,别一上来全局硬编码。
2. 加密方案怎么定:AES 打底 + RSA 换钥 + 签名防篡改
2.1 为什么不让前端直接持有 RSA 私钥
先说一个我在代码评审里见过不止一次的写法:前端硬编码一个 RSA 私钥,后端用公钥解密。这个方向是反的,而且极其危险。非对称加密的规则是公钥加密、私钥解密,公钥可以公开,私钥必须只在服务端。前端能拿到的东西,等于全世界都能拿到。
所以正确的分工是:RSA 只用来传密钥,AES 用来传数据。原因很直接——RSA 一次只能加密很短的明文(2048 位密钥下,PKCS1 填充大概只能塞 245 字节),而且运算慢,用它加密一个几十 KB 的响应体会直接把接口拖垮。AES 是块加密,速度快几个数量级,适合处理任意长度的报文。
那么问题来了:AES 的密钥怎么安全地给到前端?这就是 RSA 的用武之地。前端生成一个随机的 AES 密钥,用 RSA 公钥加密后传给后端,后端用私钥解出来存起来,后续所有报文都用这个 AES 密钥。整个过程里,AES 密钥在网络上出现过一次,而且是密文形态。这套流程在业内叫混合加密,TLS 握手用的也是同一个思路。
我见过有人图省事,直接把 AES 密钥硬编码在前端 JS 里,前后端用同一个固定密钥。短期能用,但只要有人把打包后的 JS 翻一遍,密钥就暴露了,加密瞬间变成"防君子不防小人"。如果项目对安全有要求,别走这条路。
2.2 会话密钥的生成、下发与生命周期
完整的握手流程我拆成四步,串起来看就清楚了。
第一步,前端拿公钥。提供一个免鉴权接口/api/crypto/publicKey,返回 RSA 公钥的 Base64。这个接口可以加缓存,公钥本身不敏感,泄露了也没事。
第二步,前端生成 AES 密钥并上报。前端用CryptoJS.lib.WordArray.random(16)生成 16 字节随机数作为 AES-128 密钥,用 RSA 公钥加密后 POST 给/api/crypto/handshake。这个接口需要 JWT 鉴权,因为密钥是跟用户会话绑定的。
第三步,后端解密并绑定会话。后端用私钥解出 AES 密钥,存进 Redis,key 用crypto:session:{userId}:{tokenId}这种格式,value 就是密钥的 Base64,TTL 设成跟 JWT 过期时间一致,比如 30 分钟。JWT 里本来就有login_user_key,直接拿来当 Redis key 的一部分,天然做到"一人一密"。
第四步,后续请求带密钥标识。前端每个请求头里带上X-Session-Id(其实就是 JWT 里的那个 key,或者干脆复用Authorization),后端从 Redis 取密钥解密。
这个设计的好处是密钥跟会话同生命周期。用户退出登录,Redis 里的 key 删掉,即使有人截获了旧密文也解不开了。密钥轮换也很自然,重新握手一次就行,不需要重启服务。
2.3 签名、时间戳、随机数三件套怎么组合
光加密不够。AES 的 CBC 模式本身不防篡改——攻击者虽然看不懂内容,但可以把密文块挪位置、截断,产生可控的破坏。所以必须再加一层签名。
我的做法是报文结构统一长这样:
{ "data": "Base64(IV + 密文)", "ts": 1735689600000, "nonce": "8f3a2c1e-...", "sign": "HmacSHA256 的十六进制小写" }签名原文按固定顺序拼:data + "|" + ts + "|" + nonce + "|" + sessionKey,用 HmacSHA256 算。这里有个细节——签名密钥用会话 AES 密钥本身,不用另外生成,省一次下发。有人会担心"同一把密钥既加密又签名不安全",在会话级短周期场景下这个风险可以接受;如果要求更高,从 AES 密钥派生一把签名子密钥(比如对密钥做一次 SHA-256 取前 16 字节),成本也很低,我倾向于派生,代码里就一行。
校验三件事:签名对不对、ts跟服务器时间差是不是在 5 分钟内、nonce有没有在 Redis 里出现过。三条全过才算合法请求。nonce存 Redis,key 是crypto:nonce:{nonce},TTL 设 5 分钟,跟时间窗对齐,过期自动清理,不用额外维护。
时间窗别设太大。设成 30 分钟等于给重放攻击留了 30 分钟的窗口,设成 5 分钟是比较常见的折中。如果客户端时钟不准,让前端在握手响应里同步一次服务器时间做一个偏移量修正,别靠放宽窗口来解决。
2.4 关键参数对照表
参数定死之前很容易反复改,我直接把最终选型列出来,你照着配就行。
| 参数项 | 取值 | 选择理由 |
|---|---|---|
| 对称算法 | AES/CBC/PKCS5Padding | JDK 原生支持,前端 crypto-js 完全兼容,比 GCM 在旧环境上兼容性好 |
| 密钥长度 | 128 位(16 字节) | 会话级短周期足够,密钥短一点 Base64 后报文头开销小 |
| IV 长度 | 128 位(16 字节) | 与分组长度一致,每次加密随机生成,随密文一起传输 |
| IV 传输方式 | 拼接在密文前 16 字节,整体 Base64 | 省一个字段,解析简单 |
| 非对称算法 | RSA/ECB/PKCS1Padding,2048 位 | 只加密 16 字节密钥,够用;前端 jsencrypt 库直接支持 |
| 签名算法 | HmacSHA256 | 比 MD5 安全,比 SHA256 带密钥,前端 crypto-js 一行搞定 |
| 时间窗 | ±5 分钟 | 平衡时钟误差和重放窗口 |
| 会话密钥 TTL | 与 JWT 有效期一致 | 密钥随会话消亡,无需单独清理 |
| 字符编码 | 全程 UTF-8 | 避免中文乱码,这条必须写死 |
3. 后端落地:在 SpringBoot 请求链路里插入加解密
3.1 改造点定位:RuoYi 的过滤器顺序与模块划分
RuoYi 前后端分离版的后端分了ruoyi-admin、ruoyi-framework、ruoyi-system、ruoyi-common几个模块。加解密相关的代码,我建议这样放:工具类和配置类放ruoyi-common的utils和config包下,过滤器、Advice、握手 Controller 放ruoyi-framework的web包下。这样ruoyi-system的业务代码一行都不用改,将来抽出来做成 starter 也方便。
关键是要搞清楚过滤器链的顺序。RuoYi 里已经有一串过滤器:XssFilter(防 XSS,会包装 request 读 body)、RepeatableFilter(让 body 可重复读)、JwtAuthenticationTokenFilter(Spring Security 的 JWT 校验),还有 Spring Security 自己的一堆。我们的解密 Filter 必须放在XssFilter 之前,也就是 order 更小。原因很直白:XssFilter 会读一遍 body 做转义,如果它先跑,读到的就是密文,转义一遍之后密文结构可能被破坏(比如 Base64 里的字符一般没事,但加上签名 JSON 的引号处理就难说了);更重要的是,解密之后再让 XSS 过滤生效,安全性才到位。
所以注册的时候这么写:
@Bean public FilterRegistrationBean<DecryptFilter> decryptFilterRegistration(DecryptFilter filter) { FilterRegistrationBean<DecryptFilter> registration = new FilterRegistrationBean<>(filter); registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 5); registration.addUrlPatterns("/*"); registration.setName("decryptFilter"); return registration; }Ordered.HIGHEST_PRECEDENCE + 5这个值比 RuoYi 默认的 XssFilter(一般是HIGHEST_PRECEDENCE + 10左右)更靠前。具体数值你要在自己项目里打日志确认,别照抄。
3.2 请求解密:Filter + 自定义 HttpServletRequestWrapper
这里是第一个坑:RequestBodyAdvice的beforeBodyRead里拿到的HttpInputMessage是只读流,你没法把解密后的内容"塞回去"让后面的@RequestBody重新读。虽然有办法通过返回一个新的HttpInputMessage实现,但类型转换链路很绕,出了问题很难查。
更稳的做法是Filter+HttpServletRequestWrapper:在 Filter 里把原始 inputStream 完整读出来,解密,然后用一个包装类把新的字节数组作为 body 返回。这样后面的@RequestBody走的是标准流程,完全无感。
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 boolean isFinished() { return bais.available() == 0; } @Override public boolean isReady() { return true; } @Override public void setReadListener(ReadListener listener) { } @Override public int read() { return bais.read(); } }; } @Override public BufferedReader getReader() { return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8)); } @Override public int getContentLength() { return body.length; } @Override public long getContentLengthLong() { return body.length; } }Filter 主体逻辑分四步走:判断是否要处理、读原始报文、校验签名、解密并包装。
public class DecryptFilter extends OncePerRequestFilter { @Resource private ApiCryptoProperties properties; @Resource private SessionKeyService sessionKeyService; @Resource private StringRedisTemplate redisTemplate; @Override protected boolean shouldNotFilter(HttpServletRequest request) { if (!properties.isEnabled()) { return true; } String method = request.getMethod(); // 只处理写操作,GET 一般不带 body if ("GET".equalsIgnoreCase(method) || "OPTIONS".equalsIgnoreCase(method)) { return true; } String uri = request.getRequestURI(); for (String pattern : properties.getExcludePaths()) { if (PathMatcherHolder.match(pattern, uri)) { return true; } } return !PathMatcherHolder.matchAny(properties.getIncludePaths(), uri); } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String raw = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8); if (StringUtils.isEmpty(raw)) { chain.doFilter(request, response); return; } try { CryptoEnvelope envelope = JsonUtils.parse(raw, CryptoEnvelope.class); String sessionKey = sessionKeyService.getKeyByRequest(request); if (sessionKey == null) { writeError(response, 401, "会话密钥不存在,请重新握手"); return; } // 1. 时间窗校验 long diff = Math.abs(System.currentTimeMillis() - envelope.getTs()); if (diff > properties.getTimeWindowMs()) { writeError(response, 400, "请求已过期"); return; } // 2. nonce 防重放 String nonceKey = "crypto:nonce:" + envelope.getNonce(); Boolean first = redisTemplate.opsForValue() .setIfAbsent(nonceKey, "1", Duration.ofMillis(properties.getTimeWindowMs())); if (!Boolean.TRUE.equals(first)) { writeError(response, 400, "重复请求"); return; } // 3. 验签 if (!CryptoUtils.verifySign(envelope, sessionKey)) { writeError(response, 400, "签名校验失败"); return; } // 4. 解密并包装 String plain = CryptoUtils.aesDecrypt(envelope.getData(), sessionKey); chain.doFilter(new DecryptRequestWrapper(request, plain.getBytes(StandardCharsets.UTF_8)), response); } catch (CryptoException e) { writeError(response, 400, "报文解密失败"); } } }writeError这个方法要注意,返回的格式必须是前端能解析的未加密结构,否则前端解密会直接崩。我在里面固定返回{"code":400,"msg":"..."}这种极简 JSON,前端响应拦截器里判断如果响应体没有data字段就当作错误帧直接处理。
3.3 响应加密:ResponseBodyAdvice 的实现与 String 类型坑
响应侧比请求侧简单,因为 Spring 给了一个ResponseBodyAdvice,在beforeBodyWrite里可以拿到已经转换好的对象,替换掉返回值即可。但这里有一个我踩过的坑,值得单独说。
坑在哪:beforeBodyWrite的返回值会被选定的HttpMessageConverter再序列化一次。如果 Controller 方法返回的是AjaxResult(一个 POJO),选中的是MappingJackson2HttpMessageConverter,你返回一个Map也没问题,Jackson 会把它序列化成 JSON 对象。但如果 Controller 返回的是String,选中的就是StringHttpMessageConverter,这时候你返回一个 String,它不会加引号,正好;可如果你返回一个 Map,它会报类型转换异常。
反过来更麻烦:如果你统一返回加密后的字符串,当原始方法返回 POJO 时,Jackson 会把那个字符串再包一层引号,变成"{\"data\":\"...\"}",前端拿到的是一个带引号的字符串,解析直接失败。
解决办法有两个。第一个是统一返回Map<String,Object>,然后在supports里排除掉返回String的方法(在 RuoYi 里基本没有,因为所有接口都返回AjaxResult)。第二个是显式检查converterType,如果不是 Jackson 就跳过加密。我用的是第一种,同时在supports里加了三重判断。
@RestControllerAdvice public class EncryptResponseAdvice implements ResponseBodyAdvice<Object> { @Resource private ApiCryptoProperties properties; @Resource private SessionKeyService sessionKeyService; @Override public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) { if (!properties.isEnabled()) { return false; } // 只处理 Jackson 序列化的响应 if (!MappingJackson2HttpMessageConverter.class.isAssignableFrom(converterType)) { return false; } // 方法或类上标了 @IgnoreEncrypt 就跳过 if (returnType.hasMethodAnnotation(IgnoreEncrypt.class) || returnType.getContainingClass().isAnnotationPresent(IgnoreEncrypt.class)) { return false; } return true; } @Override public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) { if (body == null) { return null; } String sessionKey = sessionKeyService.getKeyByRequest(request); if (sessionKey == null) { // 密钥不存在时直接返回明文,通常是握手接口本身或异常兜底 return body; } try { String json = JsonUtils.toJson(body); String encrypted = CryptoUtils.aesEncrypt(json, sessionKey); long ts = System.currentTimeMillis(); String nonce = UUID.randomUUID().toString().replace("-", ""); String sign = CryptoUtils.sign(encrypted, ts, nonce, sessionKey); Map<String, Object> envelope = new LinkedHashMap<>(4); envelope.put("data", encrypted); envelope.put("ts", ts); envelope.put("nonce", nonce); envelope.put("sign", sign); return envelope; } catch (Exception e) { throw new CryptoException("响应加密失败", e); } } }还有一个细节:文件下载、流式响应必须排除。ResponseEntity<byte[]>或者void里手动写 outputStream 的接口,被这个 Advice 拦一下会直接坏掉。所以我在supports里加了路径判断,/common/download、/common/download/resource这些 RuoYi 自带的下载接口全部进黑名单。
3.4 加解密工具类的关键实现
工具类里最容易被写错的是 IV 的拼接顺序和 Base64 的处理。前端 crypto-js 的iv.concat(ciphertext).toString(Base64)是先 IV 后密文,后端必须严格对应,顺序反了就永远解不开,而且报错信息是"填充块损坏",根本看不出来是顺序问题。
public final class CryptoUtils { private static final String AES_MODE = "AES/CBC/PKCS5Padding"; private static final int IV_LEN = 16; public static String aesEncrypt(String plain, String sessionKeyBase64) { try { byte[] keyBytes = Base64.getDecoder().decode(sessionKeyBase64); SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES"); byte[] iv = new byte[IV_LEN]; new SecureRandom().nextBytes(iv); Cipher cipher = Cipher.getInstance(AES_MODE); cipher.init(Cipher.ENCRYPT_MODE, keySpec, new IvParameterSpec(iv)); byte[] enc = cipher.doFinal(plain.getBytes(StandardCharsets.UTF_8)); byte[] out = new byte[IV_LEN + enc.length]; System.arraycopy(iv, 0, out, 0, IV_LEN); System.arraycopy(enc, 0, out, IV_LEN, enc.length); return Base64.getEncoder().encodeToString(out); } catch (Exception e) { throw new CryptoException("AES 加密失败", e); } } public static String aesDecrypt(String base64, String sessionKeyBase64) { try { byte[] all = Base64.getDecoder().decode(base64); byte[] keyBytes = Base64.getDecoder().decode(sessionKeyBase64); SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES"); byte[] iv = Arrays.copyOfRange(all, 0, IV_LEN); byte[] enc = Arrays.copyOfRange(all, IV_LEN, all.length); Cipher cipher = Cipher.getInstance(AES_MODE); cipher.init(Cipher.DECRYPT_MODE, keySpec, new IvParameterSpec(iv)); return new String(cipher.doFinal(enc), StandardCharsets.UTF_8); } catch (Exception e) { throw new CryptoException("AES 解密失败", e); } } public static String sign(String data, long ts, String nonce, String sessionKeyBase64) { // 派生一把签名子密钥,避免加密和签名共用同一把原始密钥 byte[] subKey = sha256(Base64.getDecoder().decode(sessionKeyBase64)); String origin = data + "|" + ts + "|" + nonce; return hmacSha256Hex(Arrays.copyOf(subKey, 16), origin); } public static boolean verifySign(CryptoEnvelope envelope, String sessionKeyBase64) { String expect = sign(envelope.getData(), envelope.getTs(), envelope.getNonce(), sessionKeyBase64); return MessageDigest.isEqual( expect.getBytes(StandardCharsets.UTF_8), envelope.getSign().getBytes(StandardCharsets.UTF_8)); } }注意verifySign里用了MessageDigest.isEqual而不是equals。字符串比较是短路比较,理论上存在时序侧信道,虽然在实际业务里风险极低,但这是零成本的事,没理由不做。
3.5 排除清单:登录、验证码、文件下载、Swagger 怎么办
这是实操里最容易漏的一环。我的排除清单是这样的:
| 路径 | 排除原因 |
|---|---|
/login | 登录时还没有会话密钥,且密码字段 RuoYi 已有单独的 RSA 加密 |
/captchaImage | 验证码图片是流式响应,加密会破坏二进制 |
/crypto/publicKey | 握手前置接口,必须免密钥访问 |
/common/download** | 文件下载走ResponseEntity<byte[]> |
/profile/avatar | 头像上传是multipart/form-data,不是 JSON |
/swagger-resources/**、/v3/api-docs/** | 文档接口,加密后没法用 |
/actuator/** | 健康检查,加密会破坏探测逻辑 |
multipart/form-data这条要重点说。文件上传的请求 body 里混着二进制和文本边界,你按 JSON 去解析必然炸。所以凡是Content-Type不是application/json的请求,Filter 里一律直接放行。判断写在shouldNotFilter里最省事。
4. 前端配合:axios 拦截器里的对称处理
4.1 请求拦截器的封装位置
RuoYi 前端把 axios 封装在src/utils/request.js里,导出一个service实例,所有的 API 调用都走它。加解密逻辑直接挂在这个实例的拦截器上最省事,业务代码一行都不用动。
但要注意顺序。RuoYi 原本的请求拦截器里已经做了 token 注入、GET 请求参数序列化、防重复提交。加密必须在最后一步做,也就是在所有参数处理完之后,把最终的config.data拿来做加密。否则你先加密了,后面的拦截器再去动config.data,就全乱了。
请求拦截器里还要处理一个情况:config.data可能是对象,也可能是已经被JSON.stringify过的字符串。统一转成对象再加密,避免二次序列化。
import CryptoJS from 'crypto-js' import { getSessionKey } from '@/utils/crypto' service.interceptors.request.use( config => { // ... RuoYi 原有的 token 注入、防重提交逻辑保持不变 const key = getSessionKey() const isJson = config.headers['Content-Type'] && config.headers['Content-Type'].indexOf('application/json') > -1 if (key && isJson && config.data) { const plain = typeof config.data === 'string' ? config.data : JSON.stringify(config.data) const ts = Date.now() const nonce = generateNonce() const encrypted = aesEncrypt(plain, key) const sign = hmacSign(encrypted, ts, nonce, key) config.data = { data: encrypted, ts, nonce, sign } } return config }, error => Promise.reject(error) )aesEncrypt的实现要点:IV 必须每次随机,用CryptoJS.lib.WordArray.random(16),然后把 IV 和密文拼起来转 Base64。
export function aesEncrypt(plain, keyBase64) { const key = CryptoJS.enc.Base64.parse(keyBase64) const iv = CryptoJS.lib.WordArray.random(16) const encrypted = CryptoJS.AES.encrypt(plain, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }) return iv.concat(encrypted.ciphertext).toString(CryptoJS.enc.Base64) } export function aesDecrypt(cipherBase64, keyBase64) { const key = CryptoJS.enc.Base64.parse(keyBase64) const all = CryptoJS.enc.Base64.parse(cipherBase64) const iv = CryptoJS.lib.WordArray.create(all.words.slice(0, 4), 16) const ciphertext = CryptoJS.lib.WordArray.create(all.words.slice(4), all.sigBytes - 16) const decrypted = CryptoJS.AES.decrypt( { ciphertext: ciphertext }, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 } ) return decrypted.toString(CryptoJS.enc.Utf8) }这段有一个很隐蔽的坑:all.words.slice(0, 4)里的 4 是32 位字数,不是字节数。16 字节 = 4 个 word。同理slice(4)之后的长度要用sigBytes - 16。我第一次写的时候用了slice(0, 16),结果 IV 取成了 64 字节,解密出来全是乱码,排查了大半天。
4.2 响应拦截器解密与全局错误处理
响应拦截器里要先判断是不是加密信封,再决定解不解。判断依据是"有没有data和sign两个字段并且data是字符串"。如果解密失败,不能直接抛异常给业务层,要走统一的错误提示。
service.interceptors.response.use( response => { const res = response.data if (res && typeof res.data === 'string' && res.sign && res.ts) { try { const key = getSessionKey() const plain = aesDecrypt(res.data, key) res = JSON.parse(plain) } catch (e) { Message.error('响应解析失败,请刷新页面重试') return Promise.reject(new Error('decrypt error')) } } // ... 后续走 RuoYi 原有的 code 判断逻辑 return res }, error => { // 401、500 等未加密的错误帧在这里统一处理 return Promise.reject(error) } )RuoYi 原本在响应拦截器里处理了code === 401跳登录、code === 500弹错误、code === 601弹警告,这些逻辑在解密之后照常走,因为它们看到的是解密后的code。这一点很重要——加密层不能改变业务层看到的响应结构,否则整个前端的错误处理都要跟着改,改造范围会失控。
4.3 密钥获取时机与刷新
握手发生在登录成功之后、进入首页之前。我一般在permission.js的路由守卫里做,登录成功跳转时先查一下 store 里有没有密钥,没有就调握手接口。
export async function ensureSessionKey() { let key = sessionStorage.getItem('CryptoKey') if (key) return key const res = await handshake() key = res.data sessionStorage.setItem('CryptoKey', key) return key }密钥存sessionStorage而不是localStorage,这样关掉标签页就清空了,下次进来重新握手。虽然握手成本很低(一次 RSA 解密),但这份谨慎是有必要的。
还有一个必须处理的情况:密钥过期。JWT 过期或者用户在别处登出,Redis 里的密钥就没了,此时前端还在用旧密钥加密,后端会返回"会话密钥不存在"。响应拦截器里要识别这个错误码,自动重新握手并重放一次原请求。重放要加一个标志位防止无限循环,我一般限制重试一次。
service.interceptors.response.use(null, async error => { const { response, config } = error if (response && response.data && response.data.code === 401 && response.data.msg && response.data.msg.indexOf('会话密钥') > -1 && !config._retried) { config._retried = true await handshake(true) return service(config) } return Promise.reject(error) })5. 联调踩坑实录与问题速查表
5.1 十个典型问题与排查路径
这套东西联调的时候问题会集中爆发,我把遇到过的整理成表,按现象倒查效率最高。
| 现象 | 大概率原因 | 排查动作 |
|---|---|---|
| 后端报 "Given final block not properly padded" | IV 取值错位,或前后端 Key 字节数不一致 | 打印两侧 Key 的 Base64 字符串对比,逐字节看 |
| 解密出来是乱码但不是报错 | 编码不是 UTF-8,或 JS 侧toString(CryptoJS.enc.Utf8)前密文取错 | 先解密一个纯英文的短字符串验证链路 |
前端解密后JSON.parse报错 | 成功解密但内容被二次序列化,多了层引号 | 看解密后的原始字符串开头是不是" |
| 所有接口都返回加密信封,前端拿不到 code | ResponseBodyAdvice把错误响应也加密了 | 检查全局异常处理器是否绕过了 Advice |
| 文件下载变成一堆乱码 | 下载接口没进排除清单 | 看响应体是不是{"data":"...开头 |
| 登录接口 400 | 登录路径没排除,或者Content-Type判断失效 | 看 Filter 的shouldNotFilter日志 |
| 请求偶发"重复请求" | nonce 生成用了时间戳,同一毫秒重复 | 换成 UUID,别用毫秒数 |
| 上传头像失败 | multipart/form-data被当 JSON 解析了 | 加 Content-Type 白名单 |
| 部分接口正常部分接口 500 | 个别接口返回String类型,converter 不匹配 | 看supports里的 converterType 判断 |
| 生产环境偶尔解密失败,重启就好 | 多实例部署,Redis key 没做统一前缀 | 确认所有实例连的是同一个 Redis |
5.2 断点调试的实用手法
加密层最大的痛苦是"看不见报文"。我常用的三个手法,按成本从低到高。
第一个,加一个调试开关。配置项api.crypto.debug=true的时候,Filter 里把解密后的明文log.info出来,ResponseBodyAdvice 里把加密前的明文也打出来。记住这个开关必须默认关闭,而且要加在配置中心里能远程关掉。我在测试环境开了两周,上生产前忘了关,结果日志里刷了一堆用户手机号,好在是内测环境,赶紧回滚。这个教训我记到现在。
第二个,用单测跑通算法层。写一个CryptoUtilsTest,用固定的 Key 和固定的 IV 测一遍加密解密的往返,再拿前端 JS 生成的密文硬编码进去,测后端能不能解出来。这一步做完,前后端的算法兼容性问题基本就清零了,剩下的都是流程问题。
第三个,浏览器端直接看。前端在拦截器里用console.log打解密前后的内容,配合 Network 面板里的原始密文对照。注意生产构建时要把这些日志去掉,用process.env.NODE_ENV判断一下。
5.3 性能开销实测与取舍建议
我在一台 4 核 8G 的开发机上压过一轮,纯加解密的开销大概是这样的:1KB 的请求体,AES-128-CBC 加密加 HmacSHA256 签名,单次大约 0.05 毫秒;10KB 的响应体解密加验签,单次大约 0.3 毫秒。相比数据库查询和业务逻辑,这个开销可以忽略。
真正有影响的是两件事:Base64 膨胀(密文长度约为原文的 1.37 倍)和Redis 的 nonce 写入。前者对带宽影响有限,后者在 QPS 高的接口上会明显增加 Redis 压力。如果某个接口 QPS 上万,可以考虑对读接口关掉 nonce 校验(读接口本身不产生副作用,重放危害小),只保留时间窗和签名。
另外要提醒一句:别对大文件走这套。文件上传下载、报表导出这类接口,本来就应该走独立通道,加进去只会让传输体积翻倍还容易出错。
6. 上线前的加固与灰度建议
6.1 密钥配置外置与轮换
AES 会话密钥是运行时生成的,不涉及配置,但 RSA 密钥对是写死在配置里的,这块必须处理。私钥绝对不能进代码仓库,哪怕仓库是私有的。我的做法是放在配置中心或者环境变量里,本地开发用一套单独的测试密钥对,生产用另一套,两套互相不能通用。
RSA 密钥对也需要轮换。公钥接口返回的时候带上一个keyId,前端握手时把keyId一起传上来,后端按keyId找对应的私钥。这样轮换的时候新旧密钥可以并存一段时间,等前端全部更新完再下掉旧的,不会出现"服务端换密钥,所有在线用户掉线"的事故。
6.2 日志脱敏与异常兜底
加密做了之后,还有一个大坑是日志。很多项目的全局异常处理器里会打log.error("请求参数: {}", JSON.toJSONString(args)),这个 args 在某些切面里可能是解密后的明文。加密做了半天,日志里全暴露了,等于白干。
处理办法是配置 logback 的脱敏规则,或者干脆在切面里不打参数,改打请求的 URI 和 traceId。我倾向于后者,日志里只留"谁在什么时候调了哪个接口、耗时多少、结果码多少",具体参数去链路追踪平台按 traceId 查,那里有权限控制。
异常兜底也要注意。@RestControllerAdvice的全局异常处理器返回的AjaxResult会经过我们的EncryptResponseAdvice,如果此时会话密钥已经失效(比如 JWT 过期),getKeyByRequest返回 null,Advice 里要能优雅降级到明文返回,否则用户看到的不是"登录过期",而是一堆看不懂的报错。
6.3 灰度开关设计
最后说灰度。这套改造我不建议一次性全量上线,风险太集中。配置里设三个开关:全局开关、按路径的 include 列表、按用户的白名单。上线顺序是:先在测试环境全量跑通,然后生产环境按路径灰度——先挑一两个不核心的查询接口开,观察一周错误率和耗时,没问题再扩到写接口,最后全量。
路径配置建议用正则,别用精确匹配,否则 RuoYi 里那些带路径参数的接口(比如/system/user/{userId})会漏掉。配置示例:
api: crypto: enabled: true time-window-ms: 300000 include-paths: - /system/** - /monitor/** exclude-paths: - /login - /captchaImage - /crypto/** - /common/download** - /profile/avatar我在实际项目里推这套方案的时候,最大的阻力不是技术,是联调期大家的抱怨——"看不到报文了怎么排查"。后来我加了一个只在开发环境生效的/crypto/decrypt-debug接口,把密文贴进去直接返回明文,抱怨少了一大半。这类"给人用的工具"看着不起眼,但它是这套方案能不能在团队里活下去的关键。
还有一点体会:加密本身不产生业务价值,它是成本项。所以在设计的时候一定要把"哪些接口不需要加密"想清楚,宁可少加也别乱加。全量一把梭的结果通常是上线第三天就有人提工单要求关掉。