若依系统登录安全改造:前端AES加密与后端解密过滤器实战

发布时间:2026/7/27 4:03:41
若依系统登录安全改造:前端AES加密与后端解密过滤器实战 1. 项目概述为什么要在若依系统中改造登录加密在任何一个企业级应用里登录模块都是安全防护的第一道大门也是攻击者最常光顾的“入口”。若依RuoYi作为一个优秀的开源后台管理系统其默认的登录方式通常是用户名或手机号密码明文传输这在现代网络安全要求下已经显得有些“裸奔”了。你可能觉得我们用了HTTPS数据在传输层已经被TLS加密了为什么还要在前端对密码再做一次加密这里有个关键区别HTTPS解决的是通道安全防止数据在传输过程中被窃听或篡改但它不解决服务端日志泄露、中间代理缓存、甚至是前端恶意脚本抓取表单数据的问题。想象一下如果某个中间环节的日志被不当记录管理员的明文密码就可能暴露无遗。因此前端加密的核心目的是确保敏感信息尤其是密码在离开用户浏览器的那一刻起就不再是明文。AES高级加密标准作为一种对称加密算法因其速度快、安全性高、标准化程度好成为实现这一目标的常见选择。但直接使用AES密钥在前端加密密钥本身又存在泄露风险。所以一个更健壮的方案通常会引入RSA进行密钥交换即“前端RSA加密AES密钥后端解密后再用该AES密钥解密登录数据”。不过根据我们的标题“用AES加密改造”我们聚焦于一个相对直接但有效的场景前后端预先协商好一个固定的AES密钥或由后端动态下发前端用它对密码进行加密后端用同样的密钥解密验证。这种方式虽然比完整的非对称交换流程稍弱需要妥善保管密钥但在很多内部系统或对安全有特定要求但非金融级的场景下已经能极大地提升安全性并且实现起来更简单直观。本次实战我们就基于若依系统手把手完成这套“手机号密码”的AES加密登录改造。你会得到从前端表单提交、加密逻辑到后端解密过滤、集成验证的完整代码并理解每一个环节的设计考量与避坑要点。2. 核心思路与架构设计在动手写代码之前我们必须把整个加密流程和数据流向想清楚。一个混乱的加密方案可能比明文传输还要危险。2.1 技术选型为什么是AES面对加密我们有几个常见选项MD5/SHA哈希、RSA非对称、AES对称。哈希是不可逆的常用于密码存储但不适用于需要还原明文进行验证的传输场景。RSA非常安全但速度慢不适合加密大量数据如未来可能扩展的整个登录请求体。AES则是在安全性和性能之间取得绝佳平衡的对称加密算法它加密解密速度快适合对实际业务数据进行加密。在我们的方案中密码是核心加密对象。手机号是否加密这取决于业务需求。从隐私角度考虑加密手机号也是个好习惯。为了保持方案的一致性和扩展性我们决定将整个登录请求体包含phone和password进行JSON序列化后整体进行AES加密。这样任何新增的登录字段如验证码、设备ID都能自动被保护起来。关键决策点AES工作模式与填充方式。AES有多种模式如ECB、CBC、GCM等。ECB模式简单但不安全相同的明文块会产生相同的密文块容易被分析。我们选择CBC模式它需要一个初始化向量IV来增加随机性即使相同明文每次加密也会产生不同密文安全性更高。填充方式选择PKCS5Padding在Java中对应PKCS7Padding这是最通用的填充方案。此外为了能在网络上安全传输我们还需要将加密后的二进制字节数组进行Base64编码转换为字符串。2.2 系统交互流程设计改造后的登录流程如下前端发起登录用户在前端页面输入手机号和密码点击登录。前端加密处理前端JavaScript将{phone: “xxx” password: “yyy”}对象转换为JSON字符串使用预先与后端约定好的AES密钥和IV进行AES-CBC加密并对结果进行Base64编码得到一个密文字符串。请求发送前端将密文字符串作为一个字段例如encryptedData通过HTTPS POST请求发送给后端登录接口。后端解密过滤后端Spring Boot拦截器或过滤器拦截登录请求从encryptedData字段中取出密文进行Base64解码然后用相同的AES密钥和IV进行解密得到原始的JSON字符串。数据解析与替换将JSON字符串解析为Map或对象从中提取出手机号和明文密码。然后将这些值“注入”到原始的HttpServletRequest中替换掉原有的参数或者直接放入请求属性中供后续的Spring Security或自定义认证逻辑使用。原有认证流程后续的认证处理器如UserDetailsService像往常一样从请求参数中获取手机号和密码进行验证完全无需感知加密解密过程实现了业务逻辑与安全逻辑的解耦。这个设计的精髓在于加密解密是一个透明的“过滤器”。它像是一个安全信封业务层只关心信封里的内容而不关心信封本身。这极大地降低了改造对原有代码的侵入性。注意固定密钥方案要求前端代码中或通过安全接口获取密钥。若密钥硬编码在前端JS中虽然仍比明文好因为攻击者需要逆向JS但并非绝对安全。在生产环境中更推荐使用“一次一密”的动态方案后端在登录页面加载时通过接口返回一个本次会话临时有效的AES密钥和IV。这能进一步提升安全性。3. 前端加密实现详解前端是加密的起点我们需要一个可靠且兼容性好的AES加密库。这里我们选择crypto-js它是一个纯JavaScript实现的加密标准库功能强大且应用广泛。3.1 环境准备与库引入首先在你的若依前端项目通常是基于Vue中安装crypto-js。你可以使用npm或yarn。npm install crypto-js # 或 yarn add crypto-js然后我们将加密逻辑封装成一个独立的工具函数例如在src/utils/encryption.js文件中。3.2 核心加密函数封装// src/utils/encryption.js import CryptoJS from crypto-js; /** * AES加密工具函数 (CBC模式 PKCS7填充) * param {string} data - 待加密的原始字符串通常是JSON.stringify后的结果 * param {string} key - 加密密钥长度必须是16/24/32字节对应AES-128/192/256 * param {string} iv - 初始化向量长度必须为16字节 * returns {string} Base64编码的密文 */ export function encryptAES(data, key, iv) { // 将密钥和IV转换为CryptoJS需要的WordArray格式 const keyWordArray CryptoJS.enc.Utf8.parse(key); const ivWordArray CryptoJS.enc.Utf8.parse(iv); // 执行AES-CBC加密 const encrypted CryptoJS.AES.encrypt(data, keyWordArray, { iv: ivWordArray, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 // 注意CryptoJS中叫Pkcs7与Java的PKCS5Padding兼容 }); // 将加密结果转换为Base64字符串 return encrypted.toString(); } /** * 生成一个随机的16字节IV十六进制字符串 * 可用于动态IV的场景 * returns {string} */ export function generateRandomIV() { const randomBytes CryptoJS.lib.WordArray.random(16); // 16字节 128位 return CryptoJS.enc.Hex.stringify(randomBytes); } // 示例预定义的密钥和IV实际项目中应从后端接口安全获取或根据更复杂的方案管理 // 注意此处仅为演示硬编码密钥存在安全风险。 export const DEFAULT_AES_KEY 1234567890123456; // 16字节用于AES-128 export const DEFAULT_AES_IV abcdefghijklmnop; // 16字节关键参数解释密钥Key我们使用16字节128位的密钥对应AES-128。你也可以使用24字节192位或32字节256位安全性更高但需前后端统一。密钥必须是字符串且长度严格符合要求。初始化向量IVCBC模式必须的参数长度必须为16字节。它的作用是使相同的明文每次加密产生不同的密文防止模式攻击。IV不需要保密但必须不可预测通常随机生成。在我们的简单示例中使用了固定IV。填充PaddingPkcs7是CryptoJS中的名称它与Java中的PKCS5Padding在AES块加密的上下文中是等效的可以互操作。3.3 登录组件改造接下来我们需要修改若依的登录页面通常是src/views/login.vue的提交逻辑。template !-- 原有登录表单结构不变 -- el-form refloginForm :modelloginForm :rulesloginRules el-form-item propphone el-input v-modelloginForm.phone placeholder手机号 / /el-form-item el-form-item proppassword el-input v-modelloginForm.password typepassword placeholder密码 / /el-form-item el-button :loadingloading typeprimary clickhandleLogin登录/el-button /el-form /template script import { encryptAES, DEFAULT_AES_KEY, DEFAULT_AES_IV } from /utils/encryption; import { login } from /api/login; // 假设这是若依原有的登录API export default { name: Login, data() { return { loginForm: { phone: , password: }, loginRules: { phone: [{ required: true, trigger: blur, message: 手机号不能为空 }], password: [{ required: true, trigger: blur, message: 密码不能为空 }] }, loading: false }; }, methods: { handleLogin() { this.$refs.loginForm.validate(valid { if (valid) { this.loading true; // 1. 构建待加密的登录数据对象 const loginData { phone: this.loginForm.phone, password: this.loginForm.password // 未来可以轻松添加其他字段如 captcha: this.loginForm.captcha }; // 2. 转换为JSON字符串 const dataString JSON.stringify(loginData); // 3. 使用AES加密 const encryptedData encryptAES(dataString, DEFAULT_AES_KEY, DEFAULT_AES_IV); // 4. 构造新的请求参数发送加密后的数据 const requestPayload { encryptedData: encryptedData // 注意原有的phone和password字段不再直接发送 }; // 5. 调用登录API传入新的参数 login(requestPayload).then(response { // ... 原有的登录成功处理逻辑如存储token、跳转首页 ... this.$router.push({ path: / }); }).catch(error { // ... 错误处理 ... console.error(登录失败, error); }).finally(() { this.loading false; }); } else { console.log(表单验证失败); return false; } }); } } }; /script改造要点移除明文传输不再将loginForm.phone和loginForm.password直接作为请求参数。构建加密对象将登录信息组装成一个标准对象并序列化。这为加密一个结构化的数据块提供了便利。单一加密字段将加密后的Base64字符串放在encryptedData字段中发送。后端将只认这个字段。API层适配需要确保调用的loginAPI函数能够接收这个新的参数结构。通常需要稍微修改src/api/login.js中的请求函数。实操心得在前端加密过程中务必确保待加密字符串的编码一致。我们使用JSON.stringify生成UTF-8编码的字符串CryptoJS.enc.Utf8.parse进行转换这是最通用的做法。如果遇到中文字符加密后后端解密乱码99%的问题都出在前后端编解码方式不匹配上。4. 后端解密过滤器实现后端需要新增一个Servlet过滤器Filter在请求到达Spring Security的认证处理器之前对登录请求进行拦截、解密和参数重写。4.1 创建AES解密工具类首先在后端Java项目中创建一个AES解密工具类确保算法参数与前端的CryptoJS匹配。// com.ruoyi.common.utils.AESUtils.java package com.ruoyi.common.utils; import javax.crypto.Cipher; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.util.Base64; /** * AES加解密工具类 (CBC模式PKCS5Padding) */ public class AESUtils { // 算法/模式/填充 private static final String ALGORITHM AES/CBC/PKCS5Padding; private static final String AES AES; // 默认密钥和IV必须与前端保持一致 // 强烈建议将这些配置移至application.yml中并通过Value注入 private static final String DEFAULT_KEY 1234567890123456; private static final String DEFAULT_IV abcdefghijklmnop; /** * 解密方法 * param encryptedData Base64编码的密文 * return 解密后的原始字符串 */ public static String decrypt(String encryptedData) throws Exception { return decrypt(encryptedData, DEFAULT_KEY, DEFAULT_IV); } public static String decrypt(String encryptedData, String key, String iv) throws Exception { // 1. Base64解码 byte[] encryptedBytes Base64.getDecoder().decode(encryptedData); // 2. 初始化密钥和IV SecretKeySpec secretKeySpec new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), AES); IvParameterSpec ivParameterSpec new IvParameterSpec(iv.getBytes(StandardCharsets.UTF_8)); // 3. 初始化Cipher为解密模式 Cipher cipher Cipher.getInstance(ALGORITHM); cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec); // 4. 执行解密 byte[] decryptedBytes cipher.doFinal(encryptedBytes); // 5. 将解密后的字节数组转换为字符串 return new String(decryptedBytes, StandardCharsets.UTF_8); } // 可选加密方法用于测试或其它场景 public static String encrypt(String data, String key, String iv) throws Exception { SecretKeySpec secretKeySpec new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), AES); IvParameterSpec ivParameterSpec new IvParameterSpec(iv.getBytes(StandardCharsets.UTF_8)); Cipher cipher Cipher.getInstance(ALGORITHM); cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec); byte[] encryptedBytes cipher.doFinal(data.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(encryptedBytes); } }代码关键点ALGORITHM “AES/CBC/PKCS5Padding”必须与前端设置的CBC模式和Pkcs7填充对应。密钥和IV的字符串转换必须使用UTF_8编码确保字节数组一致。使用Base64.getDecoder().decode进行解码对应前端的encrypted.toString()CryptoJS默认输出Base64。4.2 创建解密过滤器接下来创建一个Spring Boot的过滤器拦截登录请求。// com.ruoyi.framework.filter.LoginDecryptFilter.java package com.ruoyi.framework.filter; import com.ruoyi.common.utils.AESUtils; import com.ruoyi.common.utils.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import javax.servlet.*; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequestWrapper; import java.io.BufferedReader; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import java.util.*; /** * 登录请求解密过滤器 * 该过滤器会拦截登录接口解密encryptedData字段并将解密出的参数重新放入请求中。 */ Component Order(1) // 确保过滤器在Spring Security过滤器链之前执行 public class LoginDecryptFilter implements Filter { private static final Logger log LoggerFactory.getLogger(LoginDecryptFilter.class); // 从配置文件中读取增强安全性 Value(${aes.login.key:1234567890123456}) private String aesKey; Value(${aes.login.iv:abcdefghijklmnop}) private String aesIv; // 配置需要解密的接口路径 private static final String LOGIN_PATH /login; Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest (HttpServletRequest) request; String path httpRequest.getServletPath(); // 仅处理登录请求 if (LOGIN_PATH.equals(path) POST.equalsIgnoreCase(httpRequest.getMethod())) { try { // 包装请求以便多次读取body CachedBodyHttpServletRequest cachedBodyRequest new CachedBodyHttpServletRequest(httpRequest); // 获取加密数据 String encryptedData cachedBodyRequest.getParameter(encryptedData); if (StringUtils.isNotEmpty(encryptedData)) { log.info(拦截到登录请求开始解密...); // 解密 String decryptedJson AESUtils.decrypt(encryptedData, aesKey, aesIv); log.debug(解密后的数据: {}, decryptedJson); // 解析JSON JSONObject jsonObject JSON.parseObject(decryptedJson); // 创建新的参数Map MapString, String[] parameterMap new HashMap(httpRequest.getParameterMap()); // 移除加密字段避免干扰 parameterMap.remove(encryptedData); // 将解密出的字段加入参数Map for (String key : jsonObject.keySet()) { String value jsonObject.getString(key); parameterMap.put(key, new String[]{value}); } // 使用包装后的请求替换参数Map ParameterOverrideRequestWrapper requestWrapper new ParameterOverrideRequestWrapper(httpRequest, parameterMap); chain.doFilter(requestWrapper, response); return; } else { log.warn(登录请求未包含encryptedData字段将按原始请求处理。); } } catch (Exception e) { log.error(登录请求解密失败, e); // 解密失败可以返回特定错误这里选择继续传递原始请求由后续逻辑处理通常会认证失败 // 在实际生产中可能直接返回“请求非法”的错误响应更合适。 } } // 非登录请求或处理完毕继续过滤器链 chain.doFilter(request, response); } Override public void init(FilterConfig filterConfig) throws ServletException { log.info(登录解密过滤器初始化完成密钥长度: {} IV长度: {}, aesKey.length(), aesIv.length()); } Override public void destroy() { // 清理资源 } /** * 支持缓存请求体的HttpServletRequest包装类 */ static class CachedBodyHttpServletRequest extends HttpServletRequestWrapper { private byte[] cachedBody; public CachedBodyHttpServletRequest(HttpServletRequest request) throws IOException { super(request); // 读取并缓存请求体 BufferedReader reader request.getReader(); StringBuilder sb new StringBuilder(); String line; while ((line reader.readLine()) ! null) { sb.append(line); } cachedBody sb.toString().getBytes(StandardCharsets.UTF_8); } Override public ServletInputStream getInputStream() { ByteArrayInputStream byteArrayInputStream new ByteArrayInputStream(cachedBody); return new ServletInputStream() { Override public int read() { return byteArrayInputStream.read(); } Override public boolean isFinished() { return byteArrayInputStream.available() 0; } Override public boolean isReady() { return true; } Override public void setReadListener(ReadListener listener) { // 无需实现 } }; } Override public BufferedReader getReader() { return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8)); } /** * 从缓存的body中解析出encryptedData参数简单实现适用于form-data或x-www-form-urlencoded * 更严谨的做法是解析整个请求体为JSON或Map。 */ public String getParameter(String name) { // 简单实现假设body是标准的查询字符串格式如 encryptedDataxxxotheryyy String body new String(cachedBody, StandardCharsets.UTF_8); String[] pairs body.split(); for (String pair : pairs) { int idx pair.indexOf(); if (idx 0 name.equals(pair.substring(0, idx))) { return pair.substring(idx 1); } } return null; } } /** * 用于覆盖请求参数的包装类 */ static class ParameterOverrideRequestWrapper extends HttpServletRequestWrapper { private final MapString, String[] parameterMap; public ParameterOverrideRequestWrapper(HttpServletRequest request, MapString, String[] parameterMap) { super(request); this.parameterMap Collections.unmodifiableMap(parameterMap); } Override public String getParameter(String name) { String[] values parameterMap.get(name); return values ! null values.length 0 ? values[0] : null; } Override public MapString, String[] getParameterMap() { return parameterMap; } Override public EnumerationString getParameterNames() { return Collections.enumeration(parameterMap.keySet()); } Override public String[] getParameterValues(String name) { return parameterMap.get(name); } } }过滤器核心逻辑解析拦截判定过滤器只针对POST /login请求进行处理。请求体缓存HttpServletRequest的输入流默认只能读取一次。为了在过滤器中读取encryptedData后还能让后续的Spring Security正常读取参数我们使用CachedBodyHttpServletRequest包装类来缓存请求体。参数提取与解密从请求中获取encryptedData参数调用AESUtils.decrypt进行解密。参数重写将解密得到的JSON对象中的键值对合并到原始的请求参数Map中并移除encryptedData字段本身。然后创建一个新的ParameterOverrideRequestWrapper用新的参数Map替换原请求的参数相关方法。异常处理解密失败时记录错误日志。这里选择继续传递请求通常会因参数缺失导致登录失败但在生产环境中你可能希望直接返回一个400或401错误告知客户端请求非法。4.3 配置密钥与注册过滤器在application.yml中配置密钥避免硬编码# application.yml aes: login: key: 1234567890123456 # 16/24/32字节 iv: abcdefghijklmnop # 16字节由于我们使用了Component和Order注解Spring Boot会自动注册这个过滤器。Order(1)确保了它在Spring Security的过滤器链之前执行这样当请求到达UsernamePasswordAuthenticationFilter时参数已经是解密后的明文了。5. 集成测试与问题排查代码写完了但真正的挑战往往在联调阶段。下面我们进行端到端的测试并梳理常见问题。5.1 完整测试流程启动服务确保若依前后端项目都已正常启动。前端操作在登录页输入手机号如13800138000和密码如admin123。监控网络请求打开浏览器开发者工具的“网络”(Network)选项卡找到登录请求。检查请求负载应该看到一个encryptedData字段其值是一长串Base64字符串而不是明文的phone和password。原始请求对比可以注释掉前端加密代码对比发送的数据格式确认加密已生效。后端日志查看后端控制台日志应该能看到过滤器打印的“拦截到登录请求开始解密...”以及“解密后的数据: {...}”的调试信息注意生产环境应关闭debug日志。登录结果最终应该能成功登录并跳转。5.2 常见问题与解决方案速查表以下是我在多次实现类似方案中踩过的坑总结成表格供你快速排查问题现象可能原因排查步骤与解决方案前端加密成功后端解密失败javax.crypto.BadPaddingException: Given final block not properly padded1. 密钥/IV不匹配前后端密钥或IV字符串不一致长度、内容。2. 编码不一致前后端处理字符串到字节数组的编码不同如前端UTF-8后端ISO-8859-1。3. 密文被篡改传输过程中密文损坏或前端Base64编码有误。4. 算法/模式/填充不匹配前后端指定的AES模式、填充方式不同。1.核对密钥IV在前端和后端打印或日志输出密钥和IV的字节长度和Hex值确保完全一致。2.统一编码强制前后端都使用UTF-8编码进行字符串与字节的转换。3.检查密文前端将加密后的Base64字符串在控制台打印后端收到后也打印对比是否完全相同。注意URL编码问题如果密文包含、/、等特殊字符在HTTP传输中可能需要URL安全的Base64编码或确保传输层未对其进行处理。4.核对算法字符串后端AES/CBC/PKCS5Padding前端CBC模式Pkcs7填充。解密后得到乱码解密过程本身可能成功了但得到的字节数组用错误的字符集解码成了字符串。确保解密后new String(decryptedBytes, StandardCharsets.UTF_8)使用UTF-8。也可以先不解码为字符串直接打印字节数组的Hex值与前端加密前的原始字符串Hex值对比。过滤器未生效后端仍收到明文1. 过滤器路径配置错误未拦截到/login。2. 过滤器顺序问题在Spring Security之后才执行。3. 请求参数获取方式不对getParameter拿不到encryptedData。1. 检查过滤器日志是否打印了初始化信息和拦截信息。2. 确认使用了Order(1)或更小的值确保优先级高。3. 检查前端发送的是application/json还是application/x-www-form-urlencoded。我们的简单getParameter实现只适用于后者。如果前端发送JSON需要在过滤器中用BufferedReader读取整个body然后用FastJSON等库解析JSON对象获取encryptedData字段。登录后Spring Security无法认证用户解密后的参数未正确设置到请求中导致UsernamePasswordAuthenticationFilter获取不到username和password参数。1. 在过滤器中解密后打印出参数Map确认phone或你的用户名参数名和password字段已存在且值正确。2. 确认若依系统默认的用户名参数名是username还是phone。我们的示例解密后注入的是phone但Spring Security默认查找username。你需要要么在过滤器中同时将phone的值也设置到username参数里要么修改若依的登录表单使其用户名字段名就是phone要么自定义一个AuthenticationFilter来适配。跨域CORS请求下过滤器获取不到请求体在跨域预检请求OPTIONS时请求体为空如果过滤器不加区分地处理OPTIONS请求可能会出错。在过滤器的doFilter开头判断如果是OPTIONS方法直接放行if (“OPTIONS”.equalsIgnoreCase(httpRequest.getMethod())) { chain.doFilter(request, response); return; }避坑指南最棘手的往往是编码和密钥一致性问题。一个高效的调试方法是构造一个单元测试。在后端写一个测试方法使用AESUtils.encrypt对一个已知字符串加密然后在浏览器的开发者工具控制台里用前端的encryptAES函数对同一个字符串加密对比两个Base64结果是否完全一致。如果一致说明算法环境匹配如果不一致就聚焦在密钥、IV、编码、模式填充这几个点上逐一比对。6. 安全增强与扩展思考基本的AES加密改造完成后系统安全性已经上了一个台阶。但正如开头提到的固定密钥并非最安全的方式。下面探讨几个增强方向6.1 动态密钥方案推荐理想情况下每次会话应使用不同的密钥。流程可以优化为用户访问登录页时前端向后端发起一个/api/getAESKey的请求。后端生成一个随机的AES密钥和IV可以是一次性的也可以绑定会话ID设置较短过期时间并将其用RSA公钥加密后返回给前端。同时后端在Redis等缓存中存储这个密钥/IV对键名可以是生成的随机ID。前端用内置的RSA私钥解密得到本次会话的AES密钥和IV。前端用这个动态密钥加密登录数据并将加密数据和密钥的随机ID或会话ID一起发送给登录接口。后端根据ID从缓存中取出对应的密钥和IV进行解密。这个方案结合了RSA的非对称加密和AES的对称加密即使网络请求被截获攻击者也无法解密出当次的AES密钥从而无法解密登录数据。这更接近HTTPS中TLS的密钥交换思想。6.2 集成若依原有的密码加密逻辑若依系统本身在数据库里存储的也是加密后的密码通常是BCrypt。我们的改造只影响了传输过程。后端过滤器解密出明文密码后后续的DaoAuthenticationProvider会调用PasswordEncoder如BCryptPasswordEncoder来将用户输入的明文密码与数据库中存储的哈希值进行匹配。因此数据库存储层的安全机制完全不受影响两者是正交的。6.3 应对重放攻击即使数据被加密攻击者仍然可能截获整个加密后的请求包然后原封不动地重新发送给服务器重放攻击。要防御这种攻击需要在加密数据包中加入时间戳和随机数Nonce。前端加密时将timestamp当前时间戳和nonce一个随机字符串也放入待加密的JSON对象中。后端解密后首先检查时间戳是否在可接受的时间窗口内如5分钟内然后检查这个nonce是否在最近一段时间内被使用过可以用缓存记录如果已使用过则拒绝请求。这样即使请求被重放也会因为时间过期或nonce重复而被服务器拒绝。6.4 手机号脱敏处理这个改造主要保护了传输过程。在业务层面若依系统在日志打印、接口返回用户信息时可能仍然会输出完整的手机号。你还需要结合JsonSerialize注解或自定义序列化器对手机号等敏感信息进行脱敏处理如显示为138****8000实现端到端的隐私保护。整个改造过程从固定密钥的AES加密开始理解了其原理和实现细节再到思考动态密钥、防重放等进阶方案是一个循序渐进的安全加固过程。对于大多数内部管理系统第一步改造已经能显著提升安全性。最关键的是通过这个透明的过滤器设计业务代码保持了干净为后续更复杂的安全升级打下了良好的基础。