☰
jsencrypt+Java RSA前后端加密解密与登录敏感字段保护
2026/10/24 5:50:02 网站建设 项目流程

简介:面向前后端开发者的 RSA 安全传输实践包,聚焦前端 jsencrypt 加密、后端 Java 解密这一常见链路,可用于登录口令、表单敏感字段等场景的加密传输验证。压缩包共 4 个文件,含 2 个 js 脚本(jsencrypt 与 jQuery 依赖)、1 个 html 入口页面和 1 个 Java 工具类,整体约 54KB,体积轻量、结构直接。已有 3168 人学习下载,适合需要快速跑通非对称加密流程的初、中级开发者。源码给出公钥加密到私钥解密的完整闭环:前端演示公钥设置与密文生成,Java 工具类演示 PKCS8 私钥解析、Base64 编解码与 Cipher 解密调用,可直接对照调试填充模式、密钥格式、字符编码等常见问题,也可作为项目内安全通信模块的起点。

1. 从一次登录接口的明文密码说起:jsencrypt 加 Java RSA 这套组合到底解决什么问题

登录接口抓包一看,密码明文躺在请求体里——评审提这一条的时候,很多人的第一反应是“上 HTTPS 不就行了”。可内网部署、混合内容页面、甲方额外加的一条“传输层之外再补一层”,最后往往还是要把 jsencrypt 配 Java RSA 这套前后端加密解密方案补上。它干的事很朴素:浏览器拿后端下发的 RSA 公钥,用 jsencrypt 把敏感字段加密成 Base64 密文发出去,Java 后端用私钥还原明文。适合正在做登录、改密、支付这类敏感接口,又不想上整套网关加密的团队。下面按我拆过的这份源码,把目录结构、核心代码、跑起来的步骤和二次改造点一次讲清楚。

2. 工程骨架与技术选型:jsencrypt 前端与 Java RSA 后端的目录、依赖和密钥文件

这套源码拆开之后体量很小,前端一个工具文件加一个请求封装,后端一个工具类加一个 Controller,剩下的都是壳子。但小归小,几个关键位置一旦放错——私钥进了前端、公钥格式不对、依赖版本用错——后面全是连环坑。先把骨架摆清楚,再谈代码。

2.1 一份能直接跑起来的最小目录结构

我把源码里的文件按职责重新排了一遍,删掉了业务无关的示例页面,留下的就是下面这些。前端按 Vue3 + Vite 举例,换成 React 或者纯 jQuery 页面,utils/rsa.js这一层可以原样搬过去,它不依赖任何框架。

rsa-demo/ ├── web/ # 前端工程 │ ├── src/ │ │ ├── utils/ │ │ │ ├── rsa.js # jsencrypt 封装:公钥缓存、分段加密 │ │ │ └── request.js # axios 实例 + 请求拦截器 │ │ ├── api/login.js # 登录接口调用 │ │ └── views/Login.vue # 表单页,只负责收集明文 │ ├── .env.development # VITE_API_BASE 等接口地址 │ └── package.json ├── server/ # 后端工程,Spring Boot 2.7 / 3.x 都行 │ ├── src/main/java/com/demo/rsa/ │ │ ├── RsaUtils.java # 私钥加载 + 分段解密(核心) │ │ ├── RsaKeyConfig.java # 从配置或文件读取 PEM 文本 │ │ ├── RsaDecrypt.java # 自定义注解,二次开发用 │ │ └── controller/LoginController.java │ └── src/main/resources/ │ ├── keys/private.pem # 私钥,只允许待在服务端 │ └── application.yml └── README.md

到这里能看出一个很明确的边界:private.pem在server/src/main/resources下,前端工程里只有公钥,公钥甚至可以不落地成文件,由后端通过接口下发。这条线一旦破了,加密就只剩心理安慰——公钥是公开的,谁拿到公钥都能自己加密任意内容发给你,所以真正的身份校验永远在后端的鉴权和会话上,RSA 只是让密文别在链路里裸奔。

2.2 依赖:前端一个包,后端零第三方库

前端的加密能力全部来自jsencrypt,后端用的是 JDK 自带的javax.crypto.Cipher和java.util.Base64,不引 BouncyCastle 也能跑通标准场景。

# 前端:唯一的第三方依赖 npm install jsencrypt@3.3.2 # 后端:Spring Boot 只需要 web 起步依赖,加密相关全部来自 JDK # spring-boot-starter-web 已包含 Jackson,用来反序列化请求体

这里有个版本差异必须提前说清楚,后面会单独拿出来当坑讲:jsencrypt3.x 是 ESM 导出,写import JSEncrypt from 'jsencrypt',实例化new JSEncrypt();2.x 及更早的版本是挂在window上的全局变量,很多老教程里那种<script>直接引 CDN 再new JSEncrypt()的写法,混进 Vite 工程里就会报JSEncrypt is not defined。另外命名导出在部分小版本里也存在,import { JSEncrypt } from 'jsencrypt'有时能过,但默认导入是最稳的写法,别在这上面赌。

2.3 密钥对怎么生成、放哪儿、用什么格式

源码里附带的是一对现成的 PEM 文件,实际项目里一定要自己生成,而且前端公钥和后端私钥必须严格配对。生成命令用 openssl 最直接:

# 1. 生成 2048 位私钥,openssl 默认输出 PKCS#1 格式 openssl genrsa -out private_pkcs1.pem 2048 # 2. 转成 PKCS#8 —— Java 的 PKCS8EncodedKeySpec 只认这个格式 openssl pkcs8 -topk8 -inform PEM -in private_pkcs1.pem \ -outform PEM -nocrypt -out private.pem # 3. 从私钥导出对应的公钥(X.509 SubjectPublicKeyInfo 格式) openssl rsa -in private_pkcs1.pem -pubout -out public.pem # 4. 自检:确认公私钥是配对的,输出为空即配对成功 openssl rsa -in private.pem -pubout | diff - public.pem

第 2 步是整个流程里最容易被跳过、也最容易翻车的一步。生成的私钥文件头是-----BEGIN RSA PRIVATE KEY-----的话,那是 PKCS#1;Java 侧用PKCS8EncodedKeySpec去解析会直接抛InvalidKeySpecException: algid parse error, not a sequence,报错信息完全看不出跟格式有关,第一次遇到能耗掉半天。转成 PKCS#8 之后文件头变成-----BEGIN PRIVATE KEY-----,注意这里没有RSA三个字母,这个细节可以当成快速判断格式的方法。

密钥长度的选择上,2048 位是当前的最低合格线,1024 位在多数安全扫描里会直接标红。4096 位理论上更安全,但jsencrypt是纯 JS 的大数运算,4096 位加密一次在普通笔记本上要十几到几十毫秒,表单里有三四个字段同时加密就能感觉到卡顿,移动端更明显。我一般就锁 2048,需要更强保护的地方换 ECC 或者直接上国密,别在 RSA 位数上钻牛角尖。

3. 核心链路拆解:从 jsencrypt 加密到 Java 私钥解密的完整代码

链路本身是一条直线:页面收集明文 → 前端加密成 Base64 密文 → JSON 请求体发出去 → 后端反序列化拿到密文字符串 → 私钥解密还原明文 → 走原有的登录校验。真正要写代码的只有两个点,前端怎么加密、后端怎么解密,中间夹着一个“超长字符串怎么办”的分段问题。

3.1 前端封装:一个 rsa.js 把公钥、加密、分段全收进去

jsencrypt的encrypt()有个硬限制:2048 位密钥在 PKCS#1 v1.5 填充下,单次最多加密2048/8 - 11 = 245字节。一个汉字在 UTF-8 下占 3 字节,八十多个汉字就顶到天花板,密码字段当然够用,但备注、地址、身份证号这类字段就会让encrypt()静默返回false。所以封装层必须做分段。

// src/utils/rsa.js import JSEncrypt from 'jsencrypt' // 2048 位密钥:256 字节块长 - 11 字节 PKCS#1 填充 = 245 字节可用 const MAX_BYTES = 245 let cachedPem = '' // 公钥由后端下发,避免前端打包时把某个环境的密钥写死 export async function loadPublicKey() { if (cachedPem) return cachedPem const res = await fetch('/api/rsa/publicKey') if (!res.ok) throw new Error('获取公钥失败') cachedPem = (await res.text()).trim() // 后端返回纯 PEM 文本 return cachedPem } // 按 UTF-8 字节数切分,而不是按字符数 // 用 for...of 遍历码点,避免把一个 emoji 或代理对截成两半 function splitByBytes(str, max) { const chunks = [] let buf = '' let size = 0 for (const ch of str) { const b = new TextEncoder().encode(ch).length if (size + b > max) { chunks.push(buf) buf = '' size = 0 } buf += ch size += b } if (buf) chunks.push(buf) return chunks } export function encrypt(raw, publicKey) { const enc = new JSEncrypt() enc.setPublicKey(publicKey) // 带不带 PEM 头都能解析 const parts = splitByBytes(raw, MAX_BYTES) if (parts.length === 1) { const c = enc.encrypt(parts[0]) if (!c) throw new Error('RSA 加密失败:内容超长或公钥无效') return c // 单块直接返回 Base64 } // 多块:每块单独加密 → 解成二进制 → 拼接 → 整体再 Base64 const binary = parts.map((p) => { const c = enc.encrypt(p) if (!c) throw new Error('RSA 加密失败:分块长度异常') return atob(c) }).join('') return btoa(binary) }

逻辑上分三层。第一层loadPublicKey管公钥来源,做了一次内存缓存,页面里调用十次也只发一次请求,公钥更新靠刷新页面或者加版本号(后面讲密钥轮换时会补)。第二层splitByBytes是关键,按字节切而不是按字符切,因为jsencrypt内部会把字符串按 UTF-8 编码再填充,你按字符数切出来的“245 个字符”在字节层面可能是 700 多字节,照样溢出。第三层是拼接方式,多块加密的结果不能直接把 Base64 字符串首尾相连——256 字节对应的 Base64 是 344 个字符且以==结尾,中间夹着的填充符会让整个串解码失败,必须先atob还原成二进制拼起来,最后统一btoa。

setPublicKey的入参两种形式都吃:完整的 PEM(带-----BEGIN PUBLIC KEY-----头和换行)和去掉头尾的纯 Base64。源码里存的是完整 PEM,我倾向于保持这样,可读性好,出问题时一眼能看出格式对不对。

3.2 请求侧:两种接法,明着加密和拦截器统一加密

最直白的写法是在 API 层把敏感字段挑出来加密,业务代码一眼能看懂哪个字段被保护了,出问题也好定位。

// src/api/login.js import request from '@/utils/request' import { encrypt, loadPublicKey } from '@/utils/rsa' export async function login(form) { const pub = await loadPublicKey() return request.post('/api/login', { username: form.username, // 用户名不敏感,明文传,方便日志排查 password: encrypt(form.password, pub), // 只加密密码 ts: Date.now() // 时间戳,配合后端做时效校验 }) }

字段一多这种写法就啰嗦了,第二个版本走 axios 请求拦截器,约定“字段名以Enc结尾就自动加密”。好处是业务代码零侵入,坏处是调试时看不出来哪个字段被处理过,得靠命名规范约束。

// src/utils/request.js 片段 import axios from 'axios' import { encrypt, loadPublicKey } from './rsa' const request = axios.create({ baseURL: import.meta.env.VITE_API_BASE, timeout: 10000 }) request.interceptors.request.use(async (config) => { const body = config.data // 只处理普通对象,FormData / Blob 直接放过 if (body && typeof body === 'object' && !(body instanceof FormData)) { const pub = await loadPublicKey() config.data = Object.fromEntries( Object.entries(body).map(([k, v]) => k.endsWith('Enc') && typeof v === 'string' ? [k, encrypt(v, pub)] : [k, v] ) ) } return config })

拦截器版本有个容易忽略的细节:config.data在真正发出前还是对象,JSON.stringify是 axios 内部做的,所以这里改对象是安全的;但如果你的项目里已经有人手动JSON.stringify过再塞进data,拦截器拿到的就是字符串,Object.entries会把它拆成一堆字符索引,直接坏掉。加一行typeof body === 'object'判断能挡掉大部分问题。

3.3 后端 RsaUtils:私钥加载与分段解密

后端这部分是整个项目的地基,两个方法,一个负责把 PEM 文本变成PrivateKey对象,一个负责把 Base64 密文还原成明文。

package com.demo.rsa; import javax.crypto.Cipher; import java.io.ByteArrayOutputStream; import java.nio.charset.StandardCharsets; import java.security.KeyFactory; import java.security.PrivateKey; import java.security.interfaces.RSAPrivateKey; import java.security.spec.PKCS8EncodedKeySpec; import java.util.Base64; public final class RsaUtils { /** 必须写全转换串,只写 "RSA" 时不同 Provider 的默认填充不一致 */ private static final String TRANSFORMATION = "RSA/ECB/PKCS1Padding"; private RsaUtils() {} /** 把 PEM 文本解析成私钥对象,应用启动时调一次即可 */ public static PrivateKey loadPrivateKey(String pem) throws Exception { String base64 = pem .replace("-----BEGIN PRIVATE KEY-----", "") .replace("-----END PRIVATE KEY-----", "") .replaceAll("\\s", ""); // 换行、空格、制表符全部清掉 byte[] der = Base64.getDecoder().decode(base64); return KeyFactory.getInstance("RSA") .generatePrivate(new PKCS8EncodedKeySpec(der)); } /** 密文 Base64 进,明文字符串出;支持前端分块加密后的拼接结果 */ public static String decrypt(String cipherBase64, PrivateKey key) throws Exception { byte[] cipherBytes = Base64.getDecoder().decode(cipherBase64.trim()); // 2048 位密钥 -> modulus 2048 bit -> 每块 256 字节 int blockSize = ((RSAPrivateKey) key).getModulus().bitLength() / 8; // Cipher 不是线程安全的,每次都新建,别抽成成员变量 Cipher cipher = Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.DECRYPT_MODE, key); ByteArrayOutputStream out = new ByteArrayOutputStream(); for (int off = 0; off < cipherBytes.length; off += blockSize) { int len = Math.min(blockSize, cipherBytes.length - off); out.write(cipher.doFinal(cipherBytes, off, len)); } return out.toString(StandardCharsets.UTF_8.name()); } }

几个参数值得单独说明。TRANSFORMATION写成RSA/ECB/PKCS1Padding,这里的ECB对 RSA 来说没有实际分组模式含义,只是 JCE 要求的占位写法,真正决定兼容性的是PKCS1Padding,它必须和前端jsencrypt用的填充对上,对不上就是BadPaddingException。blockSize用的是模长除以 8,2048 位得 256,这个值跟加密端的“块长”是同一个东西,前端按 245 字节切明文,加密后每块正好 256 字节,后端按 256 切密文,两边严丝合缝。Cipher实例放在方法内部 new,是因为它内部持有状态,多线程共享会出现解密结果错乱,而且这种错是偶发的,很难复现。

3.4 接口层:公钥下发和登录解密怎么接

LoginController要做的事只有两件,把公钥吐给前端,把收到的密文解回明文再交给原有的登录逻辑。

@RestController @RequestMapping("/api") public class LoginController { private final PrivateKey privateKey; private final RsaKeyConfig keyConfig; // 构造注入,私钥只在启动时解析一次,避免每次请求都读文件 public LoginController(RsaKeyConfig keyConfig) throws Exception { this.keyConfig = keyConfig; this.privateKey = RsaUtils.loadPrivateKey(keyConfig.getPrivateKeyPem()); } @GetMapping(value = "/rsa/publicKey", produces = MediaType.TEXT_PLAIN_VALUE) public String publicKey() { // 直接返回 PEM 文本,不要包成 {"key": "..."} 的 JSON return keyConfig.getPublicKeyPem(); } @PostMapping("/login") public Result login(@RequestBody LoginDTO dto) throws Exception { // 先解密,再走原有的账号密码校验 String plainPwd = RsaUtils.decrypt(dto.getPassword(), privateKey); return authService.login(dto.getUsername(), plainPwd); } }

produces = TEXT_PLAIN_VALUE这行别省,前端那边res.text()拿到的才是干净的 PEM;如果被 Jackson 包成 JSON 字符串,反序列化之后换行会变成字面量\n两个字符,replaceAll("\\s", "")清不掉,Base64.decode直接抛IllegalArgumentException。另外decrypt放在参数校验之前:LoginDTO上如果给password加了@Size(min = 6)之类的注解,校验的是密文长度(344 个字符),永远不会失败,但也没校验到任何东西,这类“看着有校验其实没生效”的问题比报错更麻烦。

4. 安全边界与前后端对齐:填充模式、编码、密钥长度这三件事

加密解密跑通只是开始,真正让这套东西在线上不出事的是三个对齐点:填充模式对齐、编码对齐、密钥规格对齐。任何一处错位,症状都是同一个BadPaddingException,排查起来像在黑匣子里摸开关。

4.1 填充模式:jsencrypt 只实现了 PKCS#1 v1.5

这是整套方案里最硬的一条约束。jsencrypt内部用的是 PKCS#1 v1.5 填充,不支持 OAEP。所以 Java 侧绝对不能图新鲜写 OAEP,哪怕它在密码学上更安全。

前端实现后端Cipher.getInstance结果
jsencryptRSA/ECB/PKCS1Padding正常
jsencryptRSASunJCE 下等价于 PKCS1,换 Provider 就可能变,不建议
jsencryptRSA/ECB/OAEPWithSHA-256AndMGF1Padding必抛BadPaddingException
WebCrypto(OAEP)RSA/ECB/PKCS1Padding同样失败,两端填充必须一致

想上 OAEP 就得换前端库,或者直接用浏览器原生的crypto.subtle,但那套 API 是异步的、只接受ArrayBuffer,还得处理 IE 兼容和 HTTPS 安全上下文限制,改造成本不小。这份源码的定位就是“够用、好接”,PKCS#1 v1.5 在这个场景下完全够。

4.2 Base64、字符集与 URL 传输:加号变空格是最经典的翻车

Base64 字母表里有+和/,还有结尾的=填充。走 JSON 请求体时这些字符都是安全的,一旦有人把密文塞进 query string 或者application/x-www-form-urlencoded表单,+在 URL 解码时会被还原成空格,后端拿到手里就是一段带空格的 Base64,Base64.getDecoder().decode()抛IllegalArgumentException: Illegal base64 character 20。

// 密文要塞进 query 或 form-urlencoded 时,必须手动编码 const cipher = encrypt(password, pub) const body = `pwd=${encodeURIComponent(cipher)}` // 少这一步,+ 会变成空格
// 后端容错写法:先用 MIME 解码器兜住换行,失败再退回标准解码器 byte[] bytes; try { bytes = Base64.getMimeDecoder().decode(cipherBase64); // 容忍 \r\n 和空格 } catch (IllegalArgumentException e) { bytes = Base64.getDecoder().decode(cipherBase64.replace(' ', '+')); }

字符集这块相对简单,前端jsencrypt内部按 UTF-8 编码,后端new String(bytes, StandardCharsets.UTF_8)对上就行。但别用new String(bytes)这种依赖平台默认编码的写法,Windows 开发机默认 GBK、Linux 服务器默认 UTF-8,本地测出来正常、上线全是乱码,这类问题排查起来毫无性价比。

4.3 密钥长度、性能代价,以及这套方案挡不住的东西

性能上做个体感参考:2048 位 RSA 在服务端解密一次通常在 0.1 到 1 毫秒量级,登录这类低频接口完全无感;4096 位前端加密一次十毫秒往上,表单里字段一多就有明显卡顿。所以密钥长度不是越长越好,2048 是性价比拐点。

更重要的是,要清楚这套方案的能力边界。它能做到的只有一件事:让抓包工具和浏览器 Network 面板里看不到明文密码。做不到的包括——攻击者拿到公开的公钥后,可以自己加密任意内容构造请求,所以服务端必须保留账号锁定、验证码、限流这些原有防护;密文可以被原样重放,所以请求里要带时间戳和一次性 nonce,服务端做五分鐘窗口校验;没有 HTTPS 的话中间人可以整页替换,把你的加密逻辑换成明文提交,前端加密在中间人面前形同虚设。我自己一直把 RSA 这一层定位成“合规项 + 纵深防御的一层”,从不把它当成唯一的防线。

5. 常见问题与排查:六个真实翻车现场

这套东西的报错信息高度同质化,十次里有八次都是BadPaddingException,剩下两次是InvalidKeySpecException和encrypt()返回false。所以排查不能靠猜,得按“密钥对不对 → 填充对不对 → 编码对不对 → 环境对不对”这个顺序一层层排除。

5.1 解密直接抛异常:九成是填充模式或密钥格式对不上

现象一:后端抛javax.crypto.BadPaddingException: Decryption error,前后端代码看着都没问题。原因:绝大多数情况是填充模式不一致,比如后端写了 OAEP;少部分情况是公私钥根本不配对——两套密钥混用了,或者公钥是重新生成的但私钥没换。解决:先确认后端Cipher.getInstance的完整转换串里带PKCS1Padding,再确认配对:用私钥反推公钥,跟手上的公钥文件做一次diff,输出为空就说明配对。openssl rsa -in private.pem -pubout | diff - public.pem这条命令我几乎是条件反射地敲,比读代码快十倍。

现象二:启动或首次请求就抛InvalidKeySpecException: IOException: algid parse error, not a sequence。原因:私钥文件是 PKCS#1 格式(文件头-----BEGIN RSA PRIVATE KEY-----),却被塞进了只认 PKCS#8 的PKCS8EncodedKeySpec。解决:用openssl pkcs8 -topk8 -nocrypt转一次格式,或者干脆用 Java 的KeyPairGenerator在项目里生成密钥对,一把梭哈不涉及格式转换:

// 应急方案:Java 直接生成密钥对,省掉格式转换的麻烦 KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA"); gen.initialize(2048); KeyPair pair = gen.generateKeyPair(); // 公钥、私钥都以 X.509 / PKCS#8 的 DER 形式存在,Base64 一下就是 PEM String pubPem = Base64.getMimeEncoder(64, "\n".getBytes()) .encodeToString(pair.getPublic().getEncoded());

5.2 加密返回 false 或解出乱码:都是长度和编码的问题

现象三:前端enc.encrypt(str)返回false,控制台没有任何报错。原因:明文超过 245 字节。这个不会抛异常,就是静默失败,然后传给后端的是一个布尔值false,接口报 400 或者密码错误,前端完全看不出问题在哪。解决:在封装层做长度判断和分段,并且encrypt()返回值必须显式判空。我一般会在rsa.js里直接throw,让错误在控制台暴露出来,比让它变成“账号密码错误”好查得多。

现象四:分段加密能跑通,但解密结果是乱码,或者只解出前面一段。原因:前端把多块的 Base64 字符串直接拼起来了。256 字节对应 344 个 Base64 字符且以==结尾,中间夹填充符会导致整体解码失败;另一种原因是按字符数切分而非字节数,中英文混排时切出来块的长度不一,加密出来的块长不固定,后端按固定 256 字节切就错位了。解决:前端先atob还原成二进制再拼、最后btoa;切分一律按 UTF-8 字节数算,用TextEncoder().encode(ch).length累加。

5.3 环境相关:本地能跑,一上线就崩

现象五:本地开发一切正常,测试环境所有登录请求 400。原因:公钥在前端写死成了本地那一套,测试环境后端换了一对密钥,公私钥不配对;或者公钥走 Nginx 转发时被压缩、被过滤,换行丢了。解决:公钥一律走接口下发,前端只缓存不硬编码。如果非要在前端内置,至少通过环境变量注入,让不同构建产物带不同的公钥。

现象六:偶发解密失败,重启服务就恢复正常,过一阵又出现。原因:把Cipher实例抽成了成员变量或放进了静态工具类字段。Cipher内部维护状态,多线程并发调用会串数据。解决:每次解密Cipher.getInstance()新建一个。开销可以忽略,比偶发故障划算得多。顺带说一句,MessageDigest、SimpleDateFormat也是同一类问题,看到静态字段就该警惕。

还有一种环境问题不在源码范围内,但很常见:Spring Boot 打成 fat jar 之后用ClassPathResource读keys/private.pem,本地 IDE 里能读到,jar 包里路径会变。稳妥做法是new ClassPathResource("keys/private.pem").getInputStream(),或者干脆把 PEM 内容放进环境变量或配置中心,按字符串注入,省掉文件读取这一环。

6. 二次开发:把解密抽成注解,同时给密钥轮换留个口子

照着上面的写法,每个接口都得在 Controller 里手动decrypt一次,字段一多就变成体力活,还容易漏。这份源码的接口少所以无所谓,二次开发时我一般会把它抽成注解,用RequestBodyAdvice在反序列化之后统一处理。

// 1. 定义注解,只作用于字段 @Target(ElementType.FIELD) @Retention(RetentionPolicy.RUNTIME) public @interface RsaDecrypt {} // 2. 全局处理器:请求体反序列化成对象之后,把所有带注解的字段就地解密 @RestControllerAdvice public class RsaRequestBodyAdvice extends RequestBodyAdviceAdapter { private final PrivateKey privateKey; // 构造注入,见 3.4 @Override public boolean supports(MethodParameter p, Type t, MediaType mt, Class<? extends HttpMessageConverter<?>> c) { return true; // 全量挂上,靠字段上有没有注解来决定动不动 } @Override public Object afterBodyRead(Object body, HttpInputMessage in, MethodParameter p, Type t, Class<? extends HttpMessageConverter<?>> c) { ReflectionUtils.doWithFields(body.getClass(), f -> { if (!f.isAnnotationPresent(RsaDecrypt.class)) return; f.setAccessible(true); try { f.set(body, RsaUtils.decrypt((String) f.get(body), privateKey)); } catch (Exception e) { // 解密失败要明确报错,别让它继续往下走成"密码错误" throw new IllegalArgumentException("字段解密失败: " + f.getName(), e); } }); return body; } }

改造完之后 Controller 就干净了,LoginDTO的password字段打上@RsaDecrypt,剩下的跟普通接口一模一样。要注意的是,RequestBodyAdvice只对@RequestBody生效,如果项目里还有 form 表单提交的接口,得改用Filter或HandlerInterceptor从request.getParameter里取。

第二个改造点是密钥轮换。公钥泄露本身不致命(它本来就是公开的),但私钥一旦有暴露风险就必须能换,而前端缓存了旧公钥、后端换了新私钥,中间那几分钟就是全线登录失败。我的做法是公钥接口返回带版本号的结构,前端缓存时带上keyId,请求体里一并回传,后端根据keyId选对应私钥解密,保留新旧两把密钥一段时间再下线。

阶段后端前端风险
轮换前只有 key-v1缓存 key-v1无
灰度中key-v1 + key-v2 并存按 keyId 使用无
收尾下线 key-v1缓存自动刷新老页面会失败,需强制刷新

验证方法我是这么走的:先用openssl pkeyutl -decrypt拿命令行对密文解一次,确认密钥没问题;再写一个 JUnit 用固定向量(同一段明文 + 同一把公钥)跑加密解密的往返断言;最后用 Postman 直接粘一段前端生成的密文打接口。这三步走完,问题基本只可能出在传输层,而不是加密逻辑本身。

踩过最大的坑是早期图省事,把公钥硬编码在rsa.js里,换环境忘了改,测试环境整整排查了一个下午,最后发现是公私钥不配对。从那以后,我每次接这类加密需求,都强制先跑一遍openssl的配对校验再动业务代码,宁可多花两分钟,也不再去猜那个BadPaddingException到底是谁的锅。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询