接手若依 Vue3 前端分离版3.8.8项目做安全加固时需求其实很朴素登录密码不能在请求里裸奔。技术选型也没什么好纠结的前端集成 jsencrypt后端下发 RSA 公钥前端加密密码后再提交后端用私钥解密这是目前最主流的方案。听起来就是装个包、改两行代码的事但真正落地时问题一件接一件最头疼的是原有的“记住密码”功能在加密之后直接失灵了。这篇文章把从密钥生成、后端解密封装、前端登录改造到记住密码修复的完整链路梳理一遍给同样在若依 Vue3 上做登录加密的同学一个可以直接参考的落地记录。1. 为什么要在登录链路里加一层 RSA 加密1.1 密码明文传输的真实风险先说一个经常被忽略的事实很多项目虽然上了 HTTPS但登录接口在请求日志、网关日志、代理日志、抓包工具里仍然能直接看到完整密码。HTTPS 解决的是传输过程中被窃听的问题可一旦请求进了内网比如 Nginx access log、后端 Access Log、甚至某些中间件的请求记录POST body 里的明文密码几乎是“敞开的”。我前阵子排查一个问题发现生产环境网关日志居然把请求体原样打印了密码就这么躺在日志文件里运维、开发、外包谁有服务器权限都能翻到。若依自带登录接口 /login 接收的就是 username 和 password 的明文 JSON如果项目内部或合作方走的是 HTTP 或者经过多层代理密码泄露风险确实不小。所以在这个项目里需求方明确提了一句“密码至少不能明文过网关”。于是目标就变成了前端把密码加密后再提交后端拿到密文解出明文走原有的认证逻辑认证成功后返回 token。1.2 加密方案选型为何是 RSA jsencrypt做密码传输加密可选方案不少我简单对比一下对称加密 AES前端和后端共用一个密钥逻辑简单但密钥必须在前端代码里存放只要有人解包 JS 就能拿到密钥安全性等于没有。它更适合做本地数据加密而不是传输加密。哈希 MD5/SHA哈希是不可逆的登录时后端把收到的哈希和自己的哈希比对。理论上可行但需要改后端密码存储逻辑还要处理盐值、防重放等问题对存量若依项目来说改造太大而且哈希之后的密码同样可以被离线彩虹表碰撞必须加盐才勉强能看。非对称加密 RSA公钥可以随便暴露加密后的数据只有持有私钥的后端能解开正好符合登录场景前端负责加密后端负责解密。jsencrypt 又是老牌 JS RSA 库API 简单Java 后端用 JDK 的 Cipher 或 hutool 的 RSA 工具类都能无缝对接。所以最终选择就是 RSA 2048 jsencrypt。1.3 整体加密流程设计改造后的登录流程是这样的前端加载登录页时向后端请求公钥也可以缓存避免每次都请求。用户点击登录时前端用公钥对密码字段执行 RSA 加密得到密文然后把 username、密文密码、验证码、uuid 提交到 /login。后端拿到请求后先用私钥把密文密码解密成明文再调原有的登录服务走账号密码校验。校验通过后返回 token后续请求继续走原有的 token 认证。这里有两个关键设计点一是公钥下发接口必须放进 Spring Security 的匿名访问白名单否则前端拿不到公钥二是解密动作放在 Controller 层统一处理service 层和后端原有逻辑完全不用动改动面最小。2. 核心细节密钥格式与前后端能力封装2.1 安装 jsencrypt 与 Vite 兼容性若依 Vue3 版用的是 Vite安装 jsencrypt 只需要一条命令npm install jsencryptjsencrypt 本身是比较老的库代码是 CommonJS 规范Vite 在预构建依赖时会自动处理。引入方式建议直接在需要的位置写import JSEncrypt from jsencrypt不过有一点值得注意如果在某些构建环境下打包报错或者运行时报JSEncrypt is not a constructor大概率是模块解释有问题。可以先删掉 node_modules/.vite 缓存重新 dev 一次如果还不行可以改成这样引入import { JSEncrypt } from jsencrypt不同版本和打包配置下表现不完全一样我项目里用第一种方式在 Vite 4 Vue 3.4 下是正常的但不排除新版本依赖升级后有兼容变化。封装一个加密函数是值得的登录页、注册页、忘记密码页都可能用// src/utils/rsa.js import JSEncrypt from jsencrypt export function encryptPassword(password, publicKey) { const encryptor new JSEncrypt() encryptor.setPublicKey(publicKey) const encrypted encryptor.encrypt(password) if (encrypted false) { throw new Error(密码加密失败请检查公钥配置) } return encrypted }jsencrypt 的 encrypt 方法如果执行失败会返回 false而不是抛异常所以在函数里判断一下更容易定位问题。2.2 RSA 密钥对生成与格式坑密钥对生成有几种方式最简单的就是项目里用 Java 生成一次然后把私钥和公钥写进配置。也可以用 openssl 命令# 生成私钥PKCS8 格式 openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048 # 提取公钥 openssl pkey -in private_key.pem -pubout -out public_key.pem这里有一个非常关键的格式问题。Java 侧的 Cipher 和 hutool 工具类默认支持 PKCS8 私钥也就是以-----BEGIN PRIVATE KEY-----开头的格式而有些在线生成的工具会给 PKCS1 格式开头是-----BEGIN RSA PRIVATE KEY-----这种格式 Java 需要额外转换才能解析。所以生成私钥时建议一步到位用 PKCS8省得后面解密时报各种格式异常。公钥给前端时规格上推荐带完整的 PEM 头尾也就是这样-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A... -----END PUBLIC KEY-----jsencrypt 的 setPublicKey 方法设计上就是解析完整 PEM 字符串的虽然某些版本直接传裸 Base64 也能识别但为了兼容性和稳定性后端接口返回时最好拼上头尾不要偷懒只扔一段 Base64。这个我在开发时踩过后端返回裸 key、前端加密失败排查了半天才发现是格式问题。另外提醒一下私钥不要硬编码在前后端代码里尤其是不要提交到代码仓库。若依项目一般有 application.yml把私钥放到配置中心或环境变量里公钥放前端或通过接口下发都行。我项目的做法是公钥通过接口下发私钥放在后端 configuration 里权限严格控制。2.3 后端 RSA 工具类的两种写法若依后端自带 hutool 依赖所以直接用 hutool 的 RSA 对象最省事。新建一个 RsaUtils 工具类import cn.hutool.core.util.CharsetUtil; import cn.hutool.crypto.asymmetric.KeyType; import cn.hutool.crypto.asymmetric.RSA; import org.springframework.stereotype.Component; Component public class RsaUtils { // 生产环境建议放到配置中心或环境变量不要直接写死在代码里 private static final String PRIVATE_KEY -----BEGIN PRIVATE KEY-----\n 你的私钥Base64内容\n -----END PRIVATE KEY-----; private static final String PUBLIC_KEY -----BEGIN PUBLIC KEY-----\n 你的公钥Base64内容\n -----END PUBLIC KEY-----; private final RSA rsa new RSA(PRIVATE_KEY, PUBLIC_KEY); public String decrypt(String cipherText) { if (cipherText null || cipherText.length() 0) { throw new ServiceException(密码不能为空); } try { return rsa.decryptStr(cipherText, KeyType.PrivateKey, CharsetUtil.CHARSET_UTF_8); } catch (Exception e) { throw new ServiceException(密码解密失败); } } public String getPublicKey() { return PUBLIC_KEY; } }如果不依赖 hutool用 JDK 原生实现也不复杂import javax.crypto.Cipher; import java.security.KeyFactory; import java.security.PrivateKey; import java.security.spec.PKCS8EncodedKeySpec; import java.util.Base64; public class JDKRsaUtil { public static String decrypt(String cipherText, String privateKeyPem) throws Exception { String base64Key privateKeyPem .replace(-----BEGIN PRIVATE KEY-----, ) .replace(-----END PRIVATE KEY-----, ) .replaceAll(\\s, ); byte[] keyBytes Base64.getDecoder().decode(base64Key); PKCS8EncodedKeySpec keySpec new PKCS8EncodedKeySpec(keyBytes); KeyFactory keyFactory KeyFactory.getInstance(RSA); PrivateKey privateKey keyFactory.generatePrivate(keySpec); Cipher cipher Cipher.getInstance(RSA/ECB/PKCS1Padding); cipher.init(Cipher.DECRYPT_MODE, privateKey); byte[] decrypted cipher.doFinal(Base64.getDecoder().decode(cipherText)); return new String(decrypted, UTF-8); } }两种都能用项目里已经有 hutool 就直接用 hutool 版本少写不少代码。注意 Cipher 算法要写RSA/ECB/PKCS1Padding这是 JSEncrypt 默认的填充方式两侧必须一致。2.4 前端加密函数封装前端加密函数刚才已经给了实际调用时要保证公钥已经加载。建议在登录页 onMounted 时就主动请求一次公钥并缓存到变量里避免用户点登录时还要先等网络请求。如果登录页有“记住密码”逻辑公钥还要在记住密码保存时用所以尽量提前加载。若依 Vue3 的登录 API 位于 src/api/login.js在这里新增一个公钥接口方法export function getPublicKey() { return request({ url: /login/publicKey, method: get }) }后端接口返回结构是{ code: 200, publicKey: -----BEGIN PUBLIC KEY-----... }前端拿到后直接用。这个路径要和后端接口保持一致同时确保 SecurityConfig 放行了该路径。3. 实操过程若依 Vue3 3.8.8 改造全流程3.1 后端新增公钥获取接口在 LoginController 里新增一个 GET 接口用来下发公钥GetMapping(/login/publicKey) public AjaxResult getPublicKey() { return AjaxResult.success(publicKey, rsaUtils.getPublicKey()); }然后修改 SecurityConfig把/login/publicKey加到匿名访问名单.antMatchers(/login, /login/publicKey, /captchaImage).permitAll()这一步非常容易遗漏漏掉之后前端请求公钥会被 Spring Security 拦截返回 401登录页面表现就是莫名其妙登录不了。3.2 后端登录接口密码解密修改 LoginController 的 login 方法接收密文后先解密PostMapping(/login) public AjaxResult login(RequestBody SysLoginBody loginBody) { // 对密码进行 RSA 解密 String decryptPassword rsaUtils.decrypt(loginBody.getPassword()); return loginService.login(loginBody.getUsername(), decryptPassword, loginBody.getCode(), loginBody.getUuid()); }这里有个细节SysLoginBody 的 password 字段已经被赋值为密文解密后得到明文再传给 loginService。service 层完全不需要改动认证逻辑、登录日志、锁定策略全部正常。如果项目里有多个登录入口比如手机号登录、单点登录也要确认各自的密码字段是否同样需要解密避免有的入口还没处理。3.3 前端登录页与用户状态改造登录页在 src/views/login.vue主要改动在 handleLogin。const publicKey ref() const getPublicKey async () { if (publicKey.value) { return publicKey.value } const res await getPublicKeyApi() publicKey.value res.publicKey return publicKey.value } const handleLogin () { formRef.value.validate(async (valid) { if (valid) { loading.value true try { const key await getPublicKey() // 若当前密码还不是加密后的密文则执行加密 if (!loginForm.value.passwordEncrypted) { loginForm.value.password encryptPassword(loginForm.value.password, key) loginForm.value.passwordEncrypted true } await userStore.login(loginForm.value) } finally { loading.value false } } }) }注意这里 loginForm 里多维护了一个 passwordEncrypted 字段这个字段的用途在第 4 章讲记住密码时会详细说明。如果不处理记住密码可以直接 pass 掉这个字段每次登录都走加密逻辑。store/modules/user.js 中原来的 login action 不需要改只是传过去的 password 现在是密文const login (userInfo) { const username userInfo.username.trim() const password userInfo.password const code userInfo.code const uuid userInfo.uuid return new Promise((resolve, reject) { loginApi(username, password, code, uuid).then(res { setToken(res.token) resolve() }).catch(error { reject(error) }) }) }3.4 验证效果抓包与联调改造完成后打开浏览器 DevTools 的 Network 面板点击登录查看 /login 请求的 Payload。正常情况下 Payload 里的 password 字段已经是一长串 Base64 密文开头可能是类似MIIBRgKCAQEA...的内容长度在 344 个字符左右2048 位 RSA 加密后 base64 编码的标准长度。如果还能看到原始密码说明加密没生效去检查 handleLogin 里是否有改动或者浏览器缓存了旧代码。后端验证方式更直接在 LoginController 的 login 方法里加一行日志打印解密后的密码和数据库里的明文密码一致就说明链路通了。测试通过后记得把日志删掉或降级为 debug 级别不要一路把明文密码打进日志。4. 补充修复记住密码失效问题4.1 原版若依的记住密码实现若依 Vue3 版默认的记住密码逻辑核心是把用户名和密码塞进 localStorage。大致流程是登录成功后如果用户勾选了 rememberMe就把 loginForm.username 和 loginForm.password 写入login_username、login_password两个 key下次打开登录页时初始化表单时读出来回填。这个逻辑在明文时代没有任何问题因为回填到密码框的就是原密码用户点登录时再次提交的内容始终是同一个明文。但引入 RSA 加密之后情况变了。4.2 为什么集成 jsencrypt 后记住密码会失效失效的原因可以拆成两种场景。场景一如果保存动作发生在登录成功后此时 loginForm.password 已经被替换成了 RSA 密文保存到 localStorage 的是密文。下次打开登录页回填到密码框里的也是密文用户直接点登录时handleLogin 会对密码框里的“密文”再次执行 RSA 加密相当于用公钥加密了一段已经加密过的密文后端拿到后用私钥解出的是双重加密后的内容根本解不出原始密码直接抛BadPaddingException或者解密失败。场景二如果把保存逻辑放在加密前或者手动在 watch rememberMe 的地方保存明文密码那下次回填的是明文再加密登录倒是能通但 localStorage 里存了明文密码加密的意义就打了个大折扣。更麻烦的是如果公钥更换过之前保存的密文回填后使用新公钥解密也对不上。还有一层问题RSA 的 PKCS1Padding 填充有随机性同一个明文每次加密出来的密文都不一样所以不能指望通过“比较密文是否一致”来判断用户有没有修改密码。这就让记住密码的实现不能简单地“存什么就回填什么”。4.3 修复方案一密文存储 加密状态标记这个方案的核心思路是记住密码时存的就是加密后的密文同时在 localStorage 里加一个标记记录“当前密码框里的值已经是密文”。每次初始化回填时把标记也恢复用户一旦手动修改了密码框内容就清除标记提交登录时看标记决定是否执行加密。登录页初始化const loginForm ref({ username: , password: , rememberMe: false, code: , uuid: , passwordEncrypted: false }) const initLoginForm () { loginForm.value.username localStorage.getItem(login_username) || loginForm.value.password localStorage.getItem(login_password) || loginForm.value.rememberMe localStorage.getItem(login_remember_me) true // 关键恢复密文标记 loginForm.value.passwordEncrypted localStorage.getItem(login_password_encrypted) 1 }监听密码变化watch( () loginForm.value.password, () { // 如果密码值发生变化并且不是程序回填导致的就清除“已加密”标记 loginForm.value.passwordEncrypted false } )不过这里有个坑回填也会触发 watch所以需要加一个只读标志位在回填期间忽略变化let isRestoring false const initLoginForm () { isRestoring true loginForm.value.username localStorage.getItem(login_username) || loginForm.value.password localStorage.getItem(login_password) || loginForm.value.rememberMe localStorage.getItem(login_remember_me) true loginForm.value.passwordEncrypted localStorage.getItem(login_password_encrypted) 1 isRestoring false } watch( () loginForm.value.password, () { if (isRestoring) { return } loginForm.value.passwordEncrypted false } )登录成功后保存记住密码此时保存的一定是密文const saveRememberMeInfo () { if (loginForm.value.rememberMe) { localStorage.setItem(login_username, loginForm.value.username) localStorage.setItem(login_password, loginForm.value.password) localStorage.setItem(login_password_encrypted, 1) localStorage.setItem(login_remember_me, true) } else { localStorage.removeItem(login_username) localStorage.removeItem(login_password) localStorage.removeItem(login_password_encrypted) localStorage.removeItem(login_remember_me) } }handleLogin 里已经有 passwordEncrypted 判断这样就能兼顾保存的密文回填后不会被二次加密用户重新输入的新密码会正常加密。这个方案有一个边界场景要注意如果用户回填密文后不小心按了个退格键watch 会立刻清除标记提交时会把“残缺密文”当成明文去加密后端解密失败。这个问题在实际使用中概率不高但如果产品对这个细节敏感就需要更复杂的“首次用户输入”监听或者干脆考虑方案二。4.4 修复方案二AES 本地加密存储方案二选择绕开 RSA 密文回填的问题思路是localStorage 里存的是用 AES 对称加密后的密文页面加载回填时用 AES 解密成明文密码框里始终是明文提交登录时统一走 RSA 加密。逻辑上最直观几乎不需要额外状态标记。安装 crypto-jsnpm install crypto-js封装两段函数import CryptoJS from crypto-js // 16 位密钥实际项目中可以拆成多段拼接混淆但记住密码本身是便利性功能安全性有限 const AES_KEY my-custom-aes-key export function encryptForStorage(text) { return CryptoJS.AES.encrypt(text, AES_KEY).toString() } export function decryptFromStorage(cipherText) { const bytes CryptoJS.AES.decrypt(cipherText, AES_KEY) return bytes.toString(CryptoJS.enc.Utf8) }页面初始化时从 localStorage 读出 AES 密文解成明文回填到密码框然后正常走 RSA 加密登录。保存时把明文密码用 AES 加密后写入 localStorage。这样登录流程里只有一条加密链路不会出现双重加密问题记住密码逻辑和原来几乎一致。但必须坦诚说明这个方案的安全边界AES 密钥写在前端 JS 里任何一个懂前端的人打开 DevTools 都能挖出来。如果担心 localStorage 泄露出明文密码这个方案的意义就是把“明文暴露”降级成“静态固定密钥加密后的密文暴露”安全性提升有限但确实比裸存明文好一些至少不识字面意义的“明文密码”。4.5 两个方案怎么选维度方案一 密文存储状态标记方案二 AES 本地加密存储存储内容RSA 密文AES 密文密码框回填内容RSA 密文AES 解密后的明文登录时是否二次加密不加密直接提交密文正常 RSA 加密本地泄露风险无私钥无法还原明文密钥在前端可被逆向提取实现复杂度需要维护状态标记和回填保护逻辑简单清晰边界场景用户误改密文会导致解密失败几乎无边界问题我在实际项目里给客户做的是方案一因为客户的安全审计要求比较严明确不接受前端存在可逆对称密钥。但如果只是普通项目后端解密逻辑不想处理太多边界方案二会明显省心。还有一种降级做法是记住密码只存用户名、不存密码体验降一点安全性反而最高适合保密等级较高的内部系统。5. 常见问题与排查技巧实录5.1 高频报错速查表现象可能原因解决方案前端报JSEncrypt is not a constructor引入方式不对或 Vite 预构建缓存异常确认 import 方式删除 node_modules/.vite 后重新 dev后端报BadPaddingException密钥不匹配、密文被二次加密、公钥格式错误核对密钥对检查记住密码是否回填密文后又加密公钥统一为带 PEM 头尾格式密码加密后得到字符串false公钥为空或格式不正确打印公钥确认包含-----BEGIN PUBLIC KEY-----头请求/login/publicKey返回 401Spring Security 未放行在 SecurityConfig 的 permitAll 中补充该路径记住密码回填后登录失败回填的是密文且被二次加密使用方案一的 passwordEncrypted 标记或方案二 AES 存储某些机器能登录某些机器不行浏览器缓存了旧版前端代码还是明文提交强制清缓存或灰度发版验证localStorage 里出现 false 值encrypt 返回 false 时被直接 setItem在保存前判断加密结果失败则提示而不是直接保存5.2 几个容易忽视的细节公钥接口的返回结构要留意。若依的 AjaxResult.success(String key, Object value) 会返回{ code: 200, 你的key: 你的value }所以前端取 res.publicKey 即可。但不要想当然取 res.data.publicKey不同版本封装可能不一样写代码前先看一眼返回体。RSA 加密密文长度要对心里有数。2048 位密钥加密后密文经过 Base64 编码约 344 字符虽然 localStorage 和 POST JSON 都放得下但如果有人误把密码字段长度校验配置到 50 字符以内加密后的值就过不了校验这种问题查起来还挺隐蔽的。私钥和解密逻辑的位置要谨慎。不要把 rsaUtils 放在会被频繁调用的工具类扫描路径之外的地方也不要在报警系统里把异常堆栈打到日志后还把密文一起打印出来。密文本身不怕泄露但把解密后的明文密码打印到日志就属于重大失误了务必避免。如果项目之前已经存在一批用户名密码缓存在 localStorage 里的存量用户版本上线前要做兼容。最简单的方式是前端代码里加版本号 key比如login_password_v2新逻辑只读取新 key旧 key 在第一次登录成功后清除避免新旧数据互相干扰。最后再分享一个小技巧。前端加密方式改成 RSA 后登录接口的自动化测试脚本基本都会挂掉因为原来传明文密码现在必须传密文。我在项目里是给测试环境保留了明文登录的能力在后端加了一个开关只有生产环境强制走解密逻辑这样联调和自动化测试都不受影响。开关用配置项控制默认关闭上线前一键开启避免了测试同学反复来找我说登录接口坏了。