Spring Boot与Vue3前后端RSA加密登录实战:原理、实现与安全优化

1. 项目概述:为什么需要前后端分离的RSA加密?

在前后端分离的架构里,数据安全是个绕不开的话题。特别是登录、支付、敏感信息传输这些环节,明文传输密码就像用明信片寄银行卡密码,风险不言而喻。虽然HTTPS已经普及,为传输通道加了一把大锁,但有些场景下,我们希望对数据本身再加一层“贴身防护”。这就是我们今天要聊的,在Spring Boot后端和Vue3前端之间,实现非对称加密RSA的完整方案。

你可能听过AES,它是对称加密,加解密用同一把钥匙,速度快,适合加密大量数据。但对称加密有个致命问题:钥匙怎么安全地交给前端?总不能把密钥硬编码在JS文件里吧,那等于把钥匙挂在门上。RSA的非对称特性正好解决了这个“钥匙分发”难题。它有一对密钥:公钥和私钥。公钥可以放心地交给前端用于加密,而解密用的私钥则牢牢攥在后端手里。前端用公钥加密的数据,只有持有私钥的后端才能解开。这样,即使传输过程被窥探,攻击者没有私钥也无法得知原始内容。

这个项目要做的,就是构建一个从后端生成密钥对、提供公钥接口,到前端获取公钥、加密数据,再到后端接收密文、解密验证的完整闭环。它不仅仅是调用几个API,更涉及到密钥管理、前后端数据交互格式、错误处理等工程实践。接下来,我会带你一步步拆解,把每个环节的原理、代码和踩过的坑都讲清楚。

2. 核心原理与架构设计

2.1 RSA算法原理简述

在动手写代码前,我们得先搞明白RSA是怎么工作的。你不用成为数学家,但理解核心概念能帮你更好地处理异常和进行调试。

RSA的安全性基于一个简单的数论事实:将两个大质数相乘很容易,但将其乘积因式分解还原为原来的两个质数却极其困难。整个算法围绕三个关键数字展开:

  1. n (模数):由两个大质数p和q相乘得到,即n = p * q。n的长度就是密钥长度,比如2048位。
  2. e (公钥指数):一个与φ(n)(即(p-1)*(q-1)) 互质的数,通常取65537。因为它二进制表示中1很少,计算效率高。
  3. 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. 展示登录成功/失败

这个流程有几个关键设计点:

  1. 按需生成与缓存:后端不应每次请求都生成新密钥对,这会造成性能浪费和密钥管理混乱。我们采用“首次请求生成,后续缓存复用”的策略。通常可以将密钥对放在应用内存(如ConcurrentHashMap)或分布式缓存(如Redis)中,并设置一个较短的过期时间(如5分钟)。
  2. 公钥标识:为了支持多密钥轮换或集群环境,后端返回公钥时,可以附带一个唯一标识(如keyId)。前端提交密文时,将此标识一并传回,方便后端查找对应的私钥。
  3. 前端密钥管理:前端获取公钥后,应将其存储在内存(如Vue的响应式状态、Pinia store)或SessionStorage中,避免每次加密前都重复请求。

3. 后端实现:Spring Boot密钥服务

3.1 环境准备与依赖引入

首先创建一个Spring Boot项目。我习惯使用Spring Initializr,选择Spring WebLombok依赖。对于加密操作,Java标准库java.security已经足够强大,我们不需要额外引入像Bouncy Castle这样的重型库,除非有特殊算法需求。

pom.xml中,确保有以下依赖:

<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <!-- 可选,用于更规范的API响应 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> </dependencies>

3.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 Map<String, 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(); Map<String, 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); } }

关键点解析与避坑指南:

  1. 密钥长度KEY_SIZE设置为2048。1024位密钥现在已被认为不够安全,3072或4096位更安全但计算更耗时。对于绝大多数Web应用,2048位是安全与性能的平衡点。
  2. 填充模式CIPHER_ALGORITHM使用了RSA/ECB/PKCS1Padding。这是最广泛兼容的模式。
    • 为什么是PKCS1Padding?这是历史最久、支持最广的填充方案。虽然存在潜在的理论弱点(如Bleichenbacher攻击),但在正确的实现和使用下(如结合OAEP),对于登录加密这种场景是安全且合适的。RSA/ECB/OAEPWithSHA-256AndMGF1Padding是更安全的选项,但前端JavaScript库的兼容性需要额外测试。
    • “NoPadding”是陷阱:绝对不要使用NoPadding,这会导致严重的安全漏洞。
  3. 密钥格式:公钥使用X509格式编码,私钥使用PKCS#8格式编码。这是Java标准库的默认格式,也与其他平台(如OpenSSL)交互时最常用的格式。getEncoded()方法返回的就是这种DER编码的字节数组。
  4. 异常处理:工具类中抛出了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 Map<String, 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 { Map<String, 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 Result<String> 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 Result<LoginVO> 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 vue@latest my-rsa-frontend # 按照提示选择TypeScript, Pinia等 cd my-rsa-frontend npm install # 安装加密库和HTTP客户端 npm install jsencrypt axios

4.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: Promise<string> | null = null; async function ensurePublicKey(): Promise<void> { if (rsaEncryptor.getPublicKey()) { return; // 已有公钥,直接返回 } // 如果正在获取,等待同一个Promise if (!fetchPublicKeyPromise) { fetchPublicKeyPromise = (async () => { try { const { data } = await axios.get<string>('/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 class="login-container"> <el-form :model="form" :rules="rules" ref="formRef" label-width="80px"> <el-form-item label="用户名" prop="username"> <el-input v-model="form.username" placeholder="请输入用户名" /> </el-form-item> <el-form-item label="密码" prop="password"> <el-input v-model="form.password" type="password" placeholder="请输入密码" show-password /> </el-form-item> <el-form-item> <el-button type="primary" :loading="loading" @click="handleLogin">登录</el-button> </el-form-item> </el-form> </div> </template> <script setup lang="ts"> 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 = ref<FormInstance>(); const loading = ref(false); const form = reactive<LoginForm>({ username: '', password: '', }); const rules = reactive<FormRules<LoginForm>>({ 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.tsutils/request.ts即可。

5. 联调测试、常见问题与进阶优化

5.1 完整联调测试步骤

  1. 启动后端:确保Spring Boot应用启动,/api/rsa/public-key/api/auth/login接口可访问。
  2. 启动前端:运行npm run dev,确保代理配置正确(或直接配置后端地址)。
  3. 获取公钥:打开浏览器开发者工具(Network),访问登录页。应能看到一个对/api/rsa/public-key的请求,并成功返回一串Base64格式的公钥字符串。
  4. 测试加密:在登录页输入用户名密码,点击登录。观察Network中发出的登录请求:
    • 请求Payload中的password字段应该是一长串与之前明文完全不同的Base64字符串。
    • 请求体大小会比明文密码大很多(因为RSA加密输出是固定长度的,2048位密钥加密后Base64字符串长度约为344字符)。
  5. 验证解密:后端收到请求后,应能正确解密出原始密码,并进行后续验证。可以在后端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中的密钥刷新逻辑。例如,使用双重检查锁,或为每个密钥对增加唯一ID(keyId),前端提交密文时附带此ID,后端根据ID查找对应私钥。
控制台警告:RSA加密数据超长明文数据长度超过了RSA密钥长度和填充模式允许的最大值。RSA 2048 with PKCS1Padding最大加密明文长度约为245字节。密码通常不会超长。如果加密其他长数据,必须采用“RSA加密AES密钥,AES加密数据”的混合加密模式。

一个典型的PEM格式公钥拼接方法(后端调整):

@GetMapping("/public-key") public Result<Map<String, String>> getPublicKey() { String publicKeyBase64 = rsaService.getCurrentPublicKey(); // 拼接成标准的PEM格式,方便前端jsencrypt直接使用 String pemPublicKey = "-----BEGIN PUBLIC KEY-----\n" + publicKeyBase64.replaceAll("(.{64})", "$1\n") + // 每64字符换行,增强可读性 "\n-----END PUBLIC KEY-----"; Map<String, String> result = new HashMap<>(); result.put("key", pemPublicKey); // result.put("keyId", "some-id"); // 如果需要多密钥支持 return Result.success(result); }

5.3 进阶优化与安全考量

  1. 密钥轮换与集群部署

    • 为每个密钥对生成唯一keyId(如UUID)。
    • 后端返回公钥时包含keyId和过期时间。
    • 前端加密时存储keyId,提交登录请求时将其放在请求头或请求体中。
    • 后端根据keyId从缓存(如Redis)中查找对应的私钥进行解密。这样可以安全地在集群中共享密钥状态,并实现定时自动轮换密钥。
  2. 更安全的填充模式:如前所述,考虑将后端的CIPHER_ALGORITHM改为RSA/ECB/OAEPWithSHA-256AndMGF1Padding。但需要注意,前端jsencrypt库默认可能不支持OAEP。你可能需要寻找支持OAEP的前端库(如node-rsa的浏览器版本)或使用Web Crypto API(较新浏览器支持)。

  3. 防御重放攻击:仅加密不能防御重放攻击(攻击者截获密文后直接重放)。需要在请求中加入时间戳和随机数(Nonce),后端校验请求的时效性和唯一性。

  4. 使用HTTPS!:这是最重要的。RSA加密保护了密码明文,但整个登录请求(包括用户名、密文)仍然需要在HTTPS的保护下传输,以防止中间人攻击和会话劫持。RSA是应用层加密,HTTPS是传输层加密,两者是互补关系,而非替代。

  5. 性能考量:RSA加解密是CPU密集型操作。如果登录QPS非常高,需要考虑:

    • 使用连接池等技术避免频繁创建解密Cipher对象。
    • 监控服务器CPU使用率。
    • 对于极端高并发场景,或许可以考虑仅在首次登录或异地登录时使用RSA,后续使用session或token机制。

这套从原理到实践,从后端到前端的RSA加密解密方案,已经能覆盖大多数前后端分离项目的登录安全增强需求。核心在于理解非对称加密的钥匙分发优势,并妥善处理密钥的生命周期和前后端的协同。在实际项目中,根据安全等级和性能要求,选择合适的填充模式、密钥长度和管理策略,才能真正筑牢数据安全的第一道防线。