1. 项目概述为什么需要前后端分离的RSA加密在前后端分离的架构里数据安全是个绕不开的话题。特别是登录、支付、敏感信息传输这些环节明文传输密码就像用明信片寄银行卡密码风险不言而喻。虽然HTTPS已经普及为传输通道加了一把大锁但有些场景下我们希望对数据本身再加一层“贴身防护”。这就是我们今天要聊的在Spring Boot后端和Vue3前端之间实现非对称加密RSA的完整方案。你可能听过AES它是对称加密加解密用同一把钥匙速度快适合加密大量数据。但对称加密有个致命问题钥匙怎么安全地交给前端总不能把密钥硬编码在JS文件里吧那等于把钥匙挂在门上。RSA的非对称特性正好解决了这个“钥匙分发”难题。它有一对密钥公钥和私钥。公钥可以放心地交给前端用于加密而解密用的私钥则牢牢攥在后端手里。前端用公钥加密的数据只有持有私钥的后端才能解开。这样即使传输过程被窥探攻击者没有私钥也无法得知原始内容。这个项目要做的就是构建一个从后端生成密钥对、提供公钥接口到前端获取公钥、加密数据再到后端接收密文、解密验证的完整闭环。它不仅仅是调用几个API更涉及到密钥管理、前后端数据交互格式、错误处理等工程实践。接下来我会带你一步步拆解把每个环节的原理、代码和踩过的坑都讲清楚。2. 核心原理与架构设计2.1 RSA算法原理简述在动手写代码前我们得先搞明白RSA是怎么工作的。你不用成为数学家但理解核心概念能帮你更好地处理异常和进行调试。RSA的安全性基于一个简单的数论事实将两个大质数相乘很容易但将其乘积因式分解还原为原来的两个质数却极其困难。整个算法围绕三个关键数字展开n (模数)由两个大质数p和q相乘得到即n p * q。n的长度就是密钥长度比如2048位。e (公钥指数)一个与φ(n)(即(p-1)*(q-1)) 互质的数通常取65537。因为它二进制表示中1很少计算效率高。d (私钥指数)满足e * d ≡ 1 (mod φ(n))的数需要通过扩展欧几里得算法计算得出。这是私钥的核心。加密过程假设明文是数字m文本需要先转成数字使用公钥(n, e)加密计算密文c m^e mod n。解密过程使用私钥(n, d)解密计算明文m c^d mod n。在实际应用中我们直接操作的是经过编码如Base64的密钥字符串和密文。Java和JavaScript的加密库帮我们处理了底层的大数运算。注意RSA算法本身有长度限制。对于2048位的密钥能加密的数据块长度有限例如使用PKCS#1 v1.5填充时明文长度不能超过245字节。因此RSA通常不用于直接加密长数据而是用来加密一个随机的AES密钥即“会话密钥”再用这个AES密钥去加密实际数据。本项目聚焦登录场景密码长度有限直接使用RSA加密是可行且常见的。2.2 系统交互流程设计一个健壮的RSA加密交互流程不能只是前端加密、后端解密那么简单。我们需要考虑密钥的生成、存储、获取和更新。下图清晰地展示了我们设计的核心流程sequenceDiagram participant User as 用户/浏览器(Vue3) participant Frontend as 前端应用 participant Backend as 后端服务(Spring Boot) User-Frontend: 1. 访问登录页 Frontend-Backend: 2. 请求获取RSA公钥 Backend-Backend: 3. 生成/读取密钥对 Backend--Frontend: 4. 返回公钥字符串 Frontend-Frontend: 5. 存储公钥等待加密 User-Frontend: 6. 输入账号密码点击登录 Frontend-Frontend: 7. 使用公钥加密密码 Frontend-Backend: 8. 提交账号 密文密码 Backend-Backend: 9. 使用私钥解密密码 Backend-Backend: 10. 验证账号密码 Backend--Frontend: 11. 返回登录结果 Frontend--User: 12. 展示登录成功/失败这个流程有几个关键设计点按需生成与缓存后端不应每次请求都生成新密钥对这会造成性能浪费和密钥管理混乱。我们采用“首次请求生成后续缓存复用”的策略。通常可以将密钥对放在应用内存如ConcurrentHashMap或分布式缓存如Redis中并设置一个较短的过期时间如5分钟。公钥标识为了支持多密钥轮换或集群环境后端返回公钥时可以附带一个唯一标识如keyId。前端提交密文时将此标识一并传回方便后端查找对应的私钥。前端密钥管理前端获取公钥后应将其存储在内存如Vue的响应式状态、Pinia store或SessionStorage中避免每次加密前都重复请求。3. 后端实现Spring Boot密钥服务3.1 环境准备与依赖引入首先创建一个Spring Boot项目。我习惯使用Spring Initializr选择Spring Web和Lombok依赖。对于加密操作Java标准库java.security已经足够强大我们不需要额外引入像Bouncy Castle这样的重型库除非有特殊算法需求。在pom.xml中确保有以下依赖dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency !-- 可选用于更规范的API响应 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency /dependencies3.2 核心工具类RSA密钥对生成与管理这是后端的核心。我们将创建一个RsaUtils工具类负责生成密钥对、加密、解密以及密钥的持久化格式转换。import lombok.extern.slf4j.Slf4j; import org.apache.tomcat.util.codec.binary.Base64; import javax.crypto.Cipher; import java.security.*; import java.security.interfaces.RSAPrivateKey; import java.security.interfaces.RSAPublicKey; import java.security.spec.PKCS8EncodedKeySpec; import java.security.spec.X509EncodedKeySpec; import java.util.HashMap; import java.util.Map; Slf4j public class RsaUtils { // 算法名称 private static final String KEY_ALGORITHM RSA; // 密钥长度建议2048位 private static final int KEY_SIZE 2048; // 加密填充模式使用最广泛的PKCS1Padding private static final String CIPHER_ALGORITHM RSA/ECB/PKCS1Padding; /** * 生成RSA密钥对 * return 包含公钥和私钥Base64编码字符串的Map */ public static MapString, String generateKeyPair() throws NoSuchAlgorithmException { KeyPairGenerator keyPairGen KeyPairGenerator.getInstance(KEY_ALGORITHM); keyPairGen.initialize(KEY_SIZE, new SecureRandom()); KeyPair keyPair keyPairGen.generateKeyPair(); RSAPublicKey publicKey (RSAPublicKey) keyPair.getPublic(); RSAPrivateKey privateKey (RSAPrivateKey) keyPair.getPrivate(); MapString, String keyMap new HashMap(2); // 使用X509格式编码公钥 keyMap.put(publicKey, Base64.encodeBase64String(publicKey.getEncoded())); // 使用PKCS#8格式编码私钥 keyMap.put(privateKey, Base64.encodeBase64String(privateKey.getEncoded())); log.info(RSA密钥对生成成功公钥长度{}, publicKey.getEncoded().length); return keyMap; } /** * 使用公钥加密 * param data 待加密数据 * param publicKeyBase64 Base64编码的公钥字符串 * return Base64编码的密文 */ public static String encrypt(String data, String publicKeyBase64) throws Exception { byte[] keyBytes Base64.decodeBase64(publicKeyBase64); X509EncodedKeySpec keySpec new X509EncodedKeySpec(keyBytes); KeyFactory keyFactory KeyFactory.getInstance(KEY_ALGORITHM); PublicKey publicKey keyFactory.generatePublic(keySpec); Cipher cipher Cipher.getInstance(CIPHER_ALGORITHM); cipher.init(Cipher.ENCRYPT_MODE, publicKey); byte[] encryptedBytes cipher.doFinal(data.getBytes()); return Base64.encodeBase64String(encryptedBytes); } /** * 使用私钥解密 * param encryptedDataBase64 Base64编码的密文 * param privateKeyBase64 Base64编码的私钥字符串 * return 解密后的明文 */ public static String decrypt(String encryptedDataBase64, String privateKeyBase64) throws Exception { byte[] keyBytes Base64.decodeBase64(privateKeyBase64); PKCS8EncodedKeySpec keySpec new PKCS8EncodedKeySpec(keyBytes); KeyFactory keyFactory KeyFactory.getInstance(KEY_ALGORITHM); PrivateKey privateKey keyFactory.generatePrivate(keySpec); Cipher cipher Cipher.getInstance(CIPHER_ALGORITHM); cipher.init(Cipher.DECRYPT_MODE, privateKey); byte[] encryptedBytes Base64.decodeBase64(encryptedDataBase64); byte[] decryptedBytes cipher.doFinal(encryptedBytes); return new String(decryptedBytes); } }关键点解析与避坑指南密钥长度KEY_SIZE设置为2048。1024位密钥现在已被认为不够安全3072或4096位更安全但计算更耗时。对于绝大多数Web应用2048位是安全与性能的平衡点。填充模式CIPHER_ALGORITHM使用了RSA/ECB/PKCS1Padding。这是最广泛兼容的模式。为什么是PKCS1Padding这是历史最久、支持最广的填充方案。虽然存在潜在的理论弱点如Bleichenbacher攻击但在正确的实现和使用下如结合OAEP对于登录加密这种场景是安全且合适的。RSA/ECB/OAEPWithSHA-256AndMGF1Padding是更安全的选项但前端JavaScript库的兼容性需要额外测试。“NoPadding”是陷阱绝对不要使用NoPadding这会导致严重的安全漏洞。密钥格式公钥使用X509格式编码私钥使用PKCS#8格式编码。这是Java标准库的默认格式也与其他平台如OpenSSL交互时最常用的格式。getEncoded()方法返回的就是这种DER编码的字节数组。异常处理工具类中抛出了Exception在实际控制器中一定要捕获并妥善处理。特别是解密失败时可能是密文被篡改或使用了错误的私钥。3.3 服务层与控制器提供公钥与处理解密接下来我们需要一个服务来管理当前有效的密钥对并提供给控制器使用。3.3.1 密钥对服务 (RsaService)import org.springframework.stereotype.Service; import javax.annotation.PostConstruct; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; Service public class RsaService { // 用于存储密钥对key可以是UUID或时间戳这里简单用固定key private final MapString, KeyPairHolder keyPairCache new ConcurrentHashMap(); private static final String CURRENT_KEY_ID current; Data AllArgsConstructor private static class KeyPairHolder { private String publicKey; private String privateKey; private long generateTime; } /** * 初始化或定期刷新密钥对 */ PostConstruct public void initKeyPair() { refreshKeyPair(); } /** * 刷新密钥对 */ public synchronized void refreshKeyPair() { try { MapString, String keyMap RsaUtils.generateKeyPair(); keyPairCache.put(CURRENT_KEY_ID, new KeyPairHolder(keyMap.get(publicKey), keyMap.get(privateKey), System.currentTimeMillis())); log.info(RSA密钥对已刷新); } catch (Exception e) { log.error(刷新RSA密钥对失败, e); throw new RuntimeException(系统密钥服务异常, e); } } /** * 获取当前公钥 */ public String getCurrentPublicKey() { KeyPairHolder holder keyPairCache.get(CURRENT_KEY_ID); if (holder null) { refreshKeyPair(); holder keyPairCache.get(CURRENT_KEY_ID); } // 可选检查密钥是否过期例如2小时后强制刷新 if (System.currentTimeMillis() - holder.getGenerateTime() 2 * 60 * 60 * 1000) { refreshKeyPair(); holder keyPairCache.get(CURRENT_KEY_ID); } return holder.getPublicKey(); } /** * 使用当前私钥解密 */ public String decryptWithCurrentPrivateKey(String encryptedData) throws Exception { KeyPairHolder holder keyPairCache.get(CURRENT_KEY_ID); if (holder null) { throw new RuntimeException(未找到有效的密钥对); } return RsaUtils.decrypt(encryptedData, holder.getPrivateKey()); } }3.3.2 控制器 (RsaController AuthController)首先提供一个获取公钥的接口RestController RequestMapping(/api/rsa) public class RsaController { Autowired private RsaService rsaService; GetMapping(/public-key) public ResultString getPublicKey() { // 简单返回公钥字符串也可以包装成JSON对象包含keyId等信息 String publicKey rsaService.getCurrentPublicKey(); return Result.success(publicKey); } }然后在登录控制器中处理解密RestController RequestMapping(/api/auth) public class AuthController { Autowired private RsaService rsaService; Autowired private UserService userService; // 假设的用户服务 PostMapping(/login) public ResultLoginVO login(RequestBody Valid LoginDTO loginDTO) { try { // 1. 解密前端传来的密文密码 String plainPassword rsaService.decryptWithCurrentPrivateKey(loginDTO.getPassword()); // 2. 进行正常的用户名密码验证 User user userService.findByUsername(loginDTO.getUsername()); if (user ! null user.getPassword().equals(encodePassword(plainPassword))) { // 注意数据库存储的应是哈希值非明文 // 3. 生成Token等后续逻辑... return Result.success(new LoginVO(...)); } else { return Result.fail(用户名或密码错误); } } catch (Exception e) { log.error(登录处理异常用户名{}, loginDTO.getUsername(), e); // 特别注意解密失败也可能是攻击不要返回具体错误信息如“解密失败” return Result.fail(登录失败请检查输入); } } Data public static class LoginDTO { NotBlank private String username; NotBlank private String password; // 这里接收的是前端RSA加密后的Base64字符串 } }重要安全实践在login方法中捕获异常后返回了通用的“登录失败”信息。这是为了防止通过错误信息进行侧信道攻击。如果返回“解密错误”攻击者可能会利用这一点来判断系统状态。4. 前端实现Vue3加密交互前端我们使用Vue3 TypeScript Vite的组合并选择jsencrypt这个库来进行RSA加密它兼容性好API简单。4.1 项目初始化与依赖安装# 创建Vue3项目 npm create vuelatest my-rsa-frontend # 按照提示选择TypeScript, Pinia等 cd my-rsa-frontend npm install # 安装加密库和HTTP客户端 npm install jsencrypt axios4.2 封装加密工具与HTTP服务4.2.1 加密工具 (utils/rsa.ts)import { JSEncrypt } from jsencrypt; // 单例模式管理加密实例和公钥 class RsaEncryptor { private encryptor: JSEncrypt | null null; private publicKey: string ; // 设置公钥 setPublicKey(publicKey: string): void { this.publicKey publicKey; this.encryptor new JSEncrypt({ default_key_size: 2048 }); this.encryptor.setPublicKey(publicKey); } // 获取当前公钥 getPublicKey(): string { return this.publicKey; } // 加密方法 encrypt(data: string): string { if (!this.encryptor) { throw new Error(RSA加密器未初始化请先设置公钥); } const encrypted this.encryptor.encrypt(data); if (!encrypted) { // 加密失败可能原因数据过长、公钥格式错误 throw new Error(RSA加密失败请检查数据或公钥); } return encrypted; } // 清空公钥用于退出登录等场景 clear(): void { this.encryptor null; this.publicKey ; } } // 导出单例 export const rsaEncryptor new RsaEncryptor();4.2.2 HTTP服务与拦截器 (utils/request.ts)我们封装axios并在请求拦截器中自动为登录请求的密码字段加密。import axios from axios; import { rsaEncryptor } from ./rsa; import { ElMessage } from element-plus; // 假设使用Element Plus UI const request axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 10000, }); // 用于存储获取公钥的Promise避免并发请求时重复获取 let fetchPublicKeyPromise: Promisestring | null null; async function ensurePublicKey(): Promisevoid { if (rsaEncryptor.getPublicKey()) { return; // 已有公钥直接返回 } // 如果正在获取等待同一个Promise if (!fetchPublicKeyPromise) { fetchPublicKeyPromise (async () { try { const { data } await axios.getstring(/api/rsa/public-key); if (!data) { throw new Error(获取公钥失败响应为空); } rsaEncryptor.setPublicKey(data); return data; } catch (error) { console.error(获取RSA公钥失败:, error); ElMessage.error(系统初始化失败请刷新页面); throw error; // 重新抛出让调用方处理 } finally { fetchPublicKeyPromise null; // 重置 } })(); } await fetchPublicKeyPromise; } // 请求拦截器 request.interceptors.request.use( async (config) { // 如果是登录请求且请求体中有password字段则进行加密 if (config.url?.includes(/auth/login) config.method post) { const data config.data; if (data typeof data.password string data.password) { try { await ensurePublicKey(); // 确保已有公钥 const encryptedPassword rsaEncryptor.encrypt(data.password); config.data { ...data, password: encryptedPassword }; } catch (error) { // 加密失败取消请求 return Promise.reject(new Error(密码加密失败无法登录)); } } } // 可以在这里添加token等通用请求头 // const token localStorage.getItem(token); // if (token) { // config.headers.Authorization Bearer ${token}; // } return config; }, (error) { return Promise.reject(error); } ); // 响应拦截器处理通用错误 request.interceptors.response.use( (response) { // 根据你的后端统一响应格式处理 const res response.data; if (res.code 200 || res.code 0) { // 假设成功码为200或0 return res.data; } else { ElMessage.error(res.message || 请求失败); return Promise.reject(new Error(res.message || Error)); } }, (error) { console.error(请求错误:, error); if (error.response) { switch (error.response.status) { case 401: ElMessage.error(未授权请重新登录); // 跳转到登录页 break; case 403: ElMessage.error(拒绝访问); break; case 500: ElMessage.error(服务器内部错误); break; default: ElMessage.error(请求错误: ${error.response.status}); } } else if (error.request) { ElMessage.error(网络错误请检查网络连接); } else { ElMessage.error(error.message || 未知错误); } return Promise.reject(error); } ); export default request;4.3 登录页面组件集成现在在登录页面组件中我们只需要像平常一样调用登录API加密过程已经在拦截器中自动完成。template div classlogin-container el-form :modelform :rulesrules refformRef label-width80px el-form-item label用户名 propusername el-input v-modelform.username placeholder请输入用户名 / /el-form-item el-form-item label密码 proppassword el-input v-modelform.password typepassword placeholder请输入密码 show-password / /el-form-item el-form-item el-button typeprimary :loadingloading clickhandleLogin登录/el-button /el-form-item /el-form /div /template script setup langts import { ref, reactive } from vue; import { ElMessage, type FormInstance, type FormRules } from element-plus; import request from /utils/request; interface LoginForm { username: string; password: string; } const formRef refFormInstance(); const loading ref(false); const form reactiveLoginForm({ username: , password: , }); const rules reactiveFormRulesLoginForm({ username: [{ required: true, message: 请输入用户名, trigger: blur }], password: [{ required: true, message: 请输入密码, trigger: blur }], }); const handleLogin async () { if (!formRef.value) return; const valid await formRef.value.validate(); if (!valid) return; loading.value true; try { // 直接调用登录接口password字段会在请求拦截器中被自动加密 const result await request.post(/api/auth/login, { username: form.username, password: form.password, // 这里是明文将被拦截器替换为密文 }); ElMessage.success(登录成功); // 处理登录成功后的逻辑如存储token、跳转首页等 console.log(登录结果:, result); } catch (error) { // 错误信息已在拦截器中统一提示这里可以处理特定逻辑 console.error(登录失败:, error); } finally { loading.value false; } }; /script这样设计的好处登录组件完全无需关心加密细节逻辑清晰。加密作为基础设施对业务代码透明。当需要更换加密方式或调整密钥获取逻辑时只需修改utils/rsa.ts和utils/request.ts即可。5. 联调测试、常见问题与进阶优化5.1 完整联调测试步骤启动后端确保Spring Boot应用启动/api/rsa/public-key和/api/auth/login接口可访问。启动前端运行npm run dev确保代理配置正确或直接配置后端地址。获取公钥打开浏览器开发者工具Network访问登录页。应能看到一个对/api/rsa/public-key的请求并成功返回一串Base64格式的公钥字符串。测试加密在登录页输入用户名密码点击登录。观察Network中发出的登录请求请求Payload中的password字段应该是一长串与之前明文完全不同的Base64字符串。请求体大小会比明文密码大很多因为RSA加密输出是固定长度的2048位密钥加密后Base64字符串长度约为344字符。验证解密后端收到请求后应能正确解密出原始密码并进行后续验证。可以在后端RsaUtils.decrypt方法前后打日志观察解密结果。5.2 常见问题与排查技巧下面是一个快速排查问题指南问题现象可能原因排查步骤与解决方案前端加密失败控制台报错1. 公钥格式错误。2. 待加密数据过长。1. 检查后端返回的公钥是否包含-----BEGIN PUBLIC KEY-----头尾jsencrypt需要这种格式。我们的工具类生成的是纯Base64需要前端拼接。解决方案在后端返回公钥时拼接头尾或修改前端setPublicKey方法。建议后端返回标准PEM格式。后端解密失败抛出异常1. 密文被篡改或编码错误。2. 使用了错误的私钥密钥不匹配。3. 密文格式不对如未Base64解码。1. 确保前端传输的密文是Base64字符串且未进行URL编码等额外处理。2.关键确认后端解密时使用的私钥与生成该密文所用的公钥是配对的。检查密钥缓存逻辑确保没有在获取公钥后、提交登录前密钥被刷新。3. 在后端解密方法入口打印密文长度和私钥进行比对。登录一直失败但解密日志显示成功1. 前端提交的用户名错误。2. 后端密码验证逻辑问题如数据库存储的是哈希值但解密后直接比对。1. 核对前端提交的用户名。2.重要解密得到的是明文密码不能直接与数据库存储的密码通常是bcrypt、PBKDF2等算法的哈希值比对。必须用相同的哈希算法处理解密后的明文再与数据库值比对。高并发下登录偶尔失败密钥对在登录请求过程中被刷新导致公钥私钥不匹配。优化RsaService中的密钥刷新逻辑。例如使用双重检查锁或为每个密钥对增加唯一IDkeyId前端提交密文时附带此ID后端根据ID查找对应私钥。控制台警告RSA加密数据超长明文数据长度超过了RSA密钥长度和填充模式允许的最大值。RSA 2048 with PKCS1Padding最大加密明文长度约为245字节。密码通常不会超长。如果加密其他长数据必须采用“RSA加密AES密钥AES加密数据”的混合加密模式。一个典型的PEM格式公钥拼接方法后端调整GetMapping(/public-key) public ResultMapString, String getPublicKey() { String publicKeyBase64 rsaService.getCurrentPublicKey(); // 拼接成标准的PEM格式方便前端jsencrypt直接使用 String pemPublicKey -----BEGIN PUBLIC KEY-----\n publicKeyBase64.replaceAll((.{64}), $1\n) // 每64字符换行增强可读性 \n-----END PUBLIC KEY-----; MapString, String result new HashMap(); result.put(key, pemPublicKey); // result.put(keyId, some-id); // 如果需要多密钥支持 return Result.success(result); }5.3 进阶优化与安全考量密钥轮换与集群部署为每个密钥对生成唯一keyId如UUID。后端返回公钥时包含keyId和过期时间。前端加密时存储keyId提交登录请求时将其放在请求头或请求体中。后端根据keyId从缓存如Redis中查找对应的私钥进行解密。这样可以安全地在集群中共享密钥状态并实现定时自动轮换密钥。更安全的填充模式如前所述考虑将后端的CIPHER_ALGORITHM改为RSA/ECB/OAEPWithSHA-256AndMGF1Padding。但需要注意前端jsencrypt库默认可能不支持OAEP。你可能需要寻找支持OAEP的前端库如node-rsa的浏览器版本或使用Web Crypto API较新浏览器支持。防御重放攻击仅加密不能防御重放攻击攻击者截获密文后直接重放。需要在请求中加入时间戳和随机数Nonce后端校验请求的时效性和唯一性。使用HTTPS这是最重要的。RSA加密保护了密码明文但整个登录请求包括用户名、密文仍然需要在HTTPS的保护下传输以防止中间人攻击和会话劫持。RSA是应用层加密HTTPS是传输层加密两者是互补关系而非替代。性能考量RSA加解密是CPU密集型操作。如果登录QPS非常高需要考虑使用连接池等技术避免频繁创建解密Cipher对象。监控服务器CPU使用率。对于极端高并发场景或许可以考虑仅在首次登录或异地登录时使用RSA后续使用session或token机制。这套从原理到实践从后端到前端的RSA加密解密方案已经能覆盖大多数前后端分离项目的登录安全增强需求。核心在于理解非对称加密的钥匙分发优势并妥善处理密钥的生命周期和前后端的协同。在实际项目中根据安全等级和性能要求选择合适的填充模式、密钥长度和管理策略才能真正筑牢数据安全的第一道防线。