接手若依 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-js:
npm 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返回 401 | Spring 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 后,登录接口的自动化测试脚本基本都会挂掉,因为原来传明文密码,现在必须传密文。我在项目里是给测试环境保留了明文登录的能力,在后端加了一个开关,只有生产环境强制走解密逻辑,这样联调和自动化测试都不受影响。开关用配置项控制,默认关闭,上线前一键开启,避免了测试同学反复来找我说登录接口坏了。