
1. 项目概述与背景最近在重构一个基于若依框架的后台管理系统时遇到了一个老生常谈但又至关重要的安全问题登录过程中的密码明文传输。虽然项目本身已经启用了HTTPS但前端表单提交的密码在浏览器开发者工具的Network面板中依然一览无余这对于安全审计和防范中间人攻击来说始终是个隐患。之前团队也讨论过使用RSA非对称加密但考虑到国密算法在政务、金融等领域的推广要求以及其自身的安全性优势我们最终决定将登录流程的加密方案从常见的RSA迁移到国密SM2。若依RuoYi作为一个流行的开源后台管理系统其前后端分离版本提供了非常清晰的架构但默认的登录接口并未对密码进行前端加密。这意味着密码从用户输入到发送至后端都是以明文形式在网络中传输。我们的目标就是无缝集成sm-crypto这个优秀的国密算法JavaScript库在前端对密码进行SM2加密后端使用对应的Java国密库进行解密从而确保密码在传输过程中的机密性。这不仅仅是加一层加密那么简单它涉及到前后端密钥对的生成与管理、加密流程的改造、以及如何与若依原有的安全体系如验证码、登录限制协同工作。2. 核心需求与方案选型2.1 为什么选择SM2而非RSA或单纯HTTPS首先我们需要明确一点启用HTTPSTLS/SSL是必须的它提供了传输层的安全通道能有效防止窃听和篡改。我们在此基础上增加前端SM2加密属于应用层的额外安全增强。这么做主要有几个考量防御内部威胁与日志泄露即使在HTTPS环境下密码明文也可能出现在前端日志、浏览器自动填充、或某些调试工具中。前端加密后这些地方留下的都是密文即使被意外记录或泄露也无法直接使用。满足合规性要求越来越多的国内项目特别是涉及敏感数据的系统被要求或鼓励使用国密算法。SM2作为国家密码管理局认定的椭圆曲线公钥密码算法其安全性和性能得到了官方认可。算法优势相比RSASM2在相同安全强度下所需的密钥长度更短256位SM2约等于2048位RSA这意味着加密解密速度更快生成的密文也更短。与若依框架的契合度若依框架结构清晰拦截器、过滤器、安全配置模块化程度高为我们插入自定义的密码解密逻辑提供了便利的切入点。因此我们的核心需求可以归结为在若依前后端分离版登录流程中前端使用SM2公钥加密用户密码后端使用SM2私钥解密再将解密后的明文密码交由若依原有的密码验证机制处理整个过程对原有业务代码的侵入性要尽可能小。2.2 技术栈与工具选型前端Vue 2.x (若依前端技术栈) sm-crypto。sm-crypto是一个纯JavaScript实现的国密算法库支持SM2, SM3, SM4轻量且无外部依赖非常适合Web前端。后端Spring Boot (若依后端技术栈) org.bouncycastle:bcprov-jdk15to18。Bouncy Castle是一个强大的密码学提供者提供了对国密算法的完整支持。我们将使用它来生成SM2密钥对、进行解密操作。密钥管理采用“后端生成并托管密钥对前端动态获取公钥”的模式。私钥绝对不离开后端服务器最好存储在配置文件或环境变量中严禁硬编码在代码里。每次服务器启动时可以生成一对固定的密钥也可以定期更换更安全但实现更复杂。本次我们采用固定密钥对以简化流程。3. 后端核心实现密钥生成与解密服务后端的改造主要集中在两个地方一是提供一个接口用于向前端分发SM2公钥二是在登录请求被处理前对加密的密码进行解密。3.1 添加Bouncy Castle依赖首先在若依后端项目的pom.xml文件中添加Bouncy Castle依赖。dependency groupIdorg.bouncycastle/groupId artifactIdbcprov-jdk15to18/artifactId version1.78/version !-- 请使用最新稳定版本 -- /dependency3.2 创建SM2工具类我们创建一个Sm2Util工具类封装密钥对生成、加密、解密等方法。这里重点讲解密因为加密主要在前端完成。import org.bouncycastle.asn1.gm.GMNamedCurves; import org.bouncycastle.asn1.x9.X9ECParameters; import org.bouncycastle.crypto.InvalidCipherTextException; import org.bouncycastle.crypto.engines.SM2Engine; import org.bouncycastle.crypto.params.ECDomainParameters; import org.bouncycastle.crypto.params.ECPrivateKeyParameters; import org.bouncycastle.crypto.params.ECPublicKeyParameters; import org.bouncycastle.crypto.params.ParametersWithRandom; import org.bouncycastle.jcajce.provider.asymmetric.ec.BCECPrivateKey; import org.bouncycastle.jcajce.provider.asymmetric.ec.BCECPublicKey; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.jce.spec.ECParameterSpec; import org.bouncycastle.jce.spec.ECPublicKeySpec; import org.bouncycastle.math.ec.ECPoint; import org.bouncycastle.util.encoders.Base64; import org.bouncycastle.util.encoders.Hex; import java.security.*; import java.security.spec.InvalidKeySpecException; import java.security.spec.PKCS8EncodedKeySpec; import java.security.spec.X509EncodedKeySpec; /** * SM2 工具类 * 注意此类生成的密钥对和加解密格式需与前端 sm-crypto 库兼容 */ public class Sm2Util { static { Security.addProvider(new BouncyCastleProvider()); } private static final X9ECParameters X9_EC_PARAMETERS GMNamedCurves.getByName(sm2p256v1); private static final ECDomainParameters DOMAIN_PARAMETERS new ECDomainParameters(X9_EC_PARAMETERS.getCurve(), X9_EC_PARAMETERS.getG(), X9_EC_PARAMETERS.getN()); private static final ECParameterSpec EC_PARAMETER_SPEC new ECParameterSpec(X9_EC_PARAMETERS.getCurve(), X9_EC_PARAMETERS.getG(), X9_EC_PARAMETERS.getN()); /** * 生成SM2密钥对 * return KeyPair */ public static KeyPair generateKeyPair() throws NoSuchProviderException, NoSuchAlgorithmException, InvalidAlgorithmParameterException { KeyPairGenerator keyPairGenerator KeyPairGenerator.getInstance(EC, BC); keyPairGenerator.initialize(EC_PARAMETER_SPEC, new SecureRandom()); return keyPairGenerator.generateKeyPair(); } /** * 将Base64编码的SM2密文解密为明文 (兼容sm-crypto的默认加密输出格式) * param privateKeyBase64 私钥(Base64格式PKCS#8) * param cipherTextBase64 密文(Base64格式) * return 解密后的明文 */ public static String decrypt(String privateKeyBase64, String cipherTextBase64) throws Exception { // 1. 解码私钥 byte[] privateKeyBytes Base64.decode(privateKeyBase64); KeyFactory keyFactory KeyFactory.getInstance(EC, BC); PKCS8EncodedKeySpec privateKeySpec new PKCS8EncodedKeySpec(privateKeyBytes); BCECPrivateKey privateKey (BCECPrivateKey) keyFactory.generatePrivate(privateKeySpec); // 2. 解码密文 (sm-crypto默认输出为Base64且是C1C3C2顺序的ASN.1 DER编码) byte[] cipherTextBytes Base64.decode(cipherTextBase64); // 3. 使用BC库进行解密 ECPrivateKeyParameters privateKeyParameters new ECPrivateKeyParameters(privateKey.getD(), DOMAIN_PARAMETERS); SM2Engine sm2Engine new SM2Engine(SM2Engine.Mode.C1C3C2); // 注意模式必须与前端加密模式一致 sm2Engine.init(false, privateKeyParameters); byte[] decryptedBytes; try { decryptedBytes sm2Engine.processBlock(cipherTextBytes, 0, cipherTextBytes.length); } catch (InvalidCipherTextException e) { throw new RuntimeException(SM2解密失败, e); } // 4. 返回明文 return new String(decryptedBytes, UTF-8); } /** * 获取公钥的Base64编码字符串 (用于发送给前端) * param keyPair 密钥对 * return 公钥(Base64, X.509格式) */ public static String getPublicKeyBase64(KeyPair keyPair) { BCECPublicKey publicKey (BCECPublicKey) keyPair.getPublic(); return Base64.toBase64String(publicKey.getEncoded()); // X.509格式 } /** * 获取私钥的Base64编码字符串 (用于配置在服务端) * param keyPair 密钥对 * return 私钥(Base64, PKCS#8格式) */ public static String getPrivateKeyBase64(KeyPair keyPair) { BCECPrivateKey privateKey (BCECPrivateKey) keyPair.getPrivate(); return Base64.toBase64String(privateKey.getEncoded()); // PKCS#8格式 } }关键点解析模式一致性SM2Engine.Mode.C1C3C2这个参数至关重要。sm-crypto库默认的加密输出格式就是 C1C3C2 顺序的ASN.1 DER编码。前后端的模式必须严格一致否则解密必然失败。密钥格式我们使用标准的 X.509 格式编码公钥PKCS#8 格式编码私钥。sm-crypto能够直接使用这种格式的公钥进行加密。依赖注入通过Security.addProvider(new BouncyCastleProvider())将BC注册为安全提供者这是使用其算法实现的前提。3.3 创建配置类与公钥接口我们不建议在每次请求时都生成新的密钥对这样前端需要频繁获取公钥且后端需要维护会话状态。更常见的做法是在应用启动时生成一对固定的密钥。3.3.1 创建配置属性类在application.yml中配置私钥生产环境应从安全的环境变量或配置中心读取sm2: private-key: YOUR_BASE64_ENCODED_PRIVATE_KEY_HERE # 使用Sm2Util生成并填入创建一个配置类来读取import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; Component ConfigurationProperties(prefix sm2) public class Sm2Properties { private String privateKey; // getter and setter }3.3.2 创建公钥获取接口创建一个简单的Controller用于在用户访问登录页时前端获取公钥。import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; RestController public class Sm2KeyController { Autowired private Sm2Properties sm2Properties; // 假设我们有一个Service来管理公钥例如从缓存或配置中读取 Autowired private Sm2KeyService sm2KeyService; GetMapping(/publicKey) public AjaxResult getPublicKey() { // 这里返回固定的公钥。更复杂的场景可以缓存或从数据库读取。 String publicKey sm2KeyService.getPublicKey(); return AjaxResult.success(获取成功, publicKey); } }Sm2KeyService的实现可以很简单就是在项目启动时用Sm2Util生成一对密钥将公钥和私钥分别存储私钥放到配置中公钥可以放到内存或Redis。import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; Service public class Sm2KeyService implements InitializingBean { private String publicKey; Autowired private Sm2Properties sm2Properties; Override public void afterPropertiesSet() throws Exception { // 如果配置中没有私钥则生成新的密钥对并提示保存 if (sm2Properties.getPrivateKey() null || sm2Properties.getPrivateKey().isEmpty()) { KeyPair keyPair Sm2Util.generateKeyPair(); String newPrivateKey Sm2Util.getPrivateKeyBase64(keyPair); String newPublicKey Sm2Util.getPublicKeyBase64(keyPair); this.publicKey newPublicKey; // 这里应该打日志并提示管理员将生成的私钥配置到application.yml中 log.warn(未配置SM2私钥已生成新密钥对。请将以下私钥配置到sm2.private-key中); log.warn(newPrivateKey); log.warn(公钥已加载{}, newPublicKey); } else { // 如果配置了私钥则需要根据私钥推导出公钥这里简化处理实际应将公钥也持久化存储 // 为了简化我们可以在生成密钥对时将公钥也保存在一个配置项或缓存中。 // 本例假设我们同时配置了公钥 sm2.public-key this.publicKey ... // 从配置或另一个地方读取公钥 } } public String getPublicKey() { return this.publicKey; } public String getPrivateKey() { return sm2Properties.getPrivateKey(); } }实操心得 在实际部署时更安全的做法是在安全的离线环境中生成密钥对将Base64编码的私钥和公钥分别作为机密信息如Kubernetes Secret、HashiCorp Vault注入到应用环境变量中。绝对避免将私钥提交到代码仓库。3.4 改造登录接口添加密码解密过滤器/拦截器我们不希望直接修改若依的登录ControllerSysLoginController因为这样侵入性太强。更好的方式是通过一个Servlet过滤器Filter或Spring的HandlerInterceptor在请求到达Controller之前对加密的密码参数进行解密。这里我们选择使用Spring的OncePerRequestFilter因为它能确保在一次请求中只执行一次。import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.utils.StringUtils; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.BufferedReader; import java.io.IOException; import java.util.Map; /** * SM2密码解密过滤器 * 拦截登录请求对加密的密码进行解密 */ Component public class Sm2PasswordDecryptFilter extends OncePerRequestFilter { Autowired private Sm2KeyService sm2KeyService; Autowired private ObjectMapper objectMapper; // 登录请求路径与若依保持一致 private static final String LOGIN_URL /login; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { // 1. 只处理登录POST请求 if (!LOGIN_URL.equals(request.getServletPath()) || !POST.equalsIgnoreCase(request.getMethod())) { filterChain.doFilter(request, response); return; } // 2. 缓存请求体以便多次读取 CachedBodyHttpServletRequest cachedRequest new CachedBodyHttpServletRequest(request); String body getRequestBody(cachedRequest); if (StringUtils.isEmpty(body)) { filterChain.doFilter(cachedRequest, response); return; } try { // 3. 解析JSON请求体 MapString, String params objectMapper.readValue(body, Map.class); String encryptedPassword params.get(password); String username params.get(username); // 4. 如果密码参数存在且非空则尝试解密 if (StringUtils.isNotEmpty(encryptedPassword)) { String privateKey sm2KeyService.getPrivateKey(); if (StringUtils.isEmpty(privateKey)) { throw new RuntimeException(服务器SM2私钥未配置); } // 使用SM2工具类解密 String decryptedPassword Sm2Util.decrypt(privateKey, encryptedPassword); // 5. 将解密后的密码替换回参数Map params.put(password, decryptedPassword); // 6. 将修改后的参数重新写入请求体 String modifiedBody objectMapper.writeValueAsString(params); CachedBodyHttpServletRequest newRequest new CachedBodyHttpServletRequest(request, modifiedBody); filterChain.doFilter(newRequest, response); return; } } catch (Exception e) { // 解密失败可能是非法请求或密钥不匹配直接返回错误 logger.error(SM2密码解密失败, e); response.setContentType(application/json;charsetUTF-8); response.getWriter().write(objectMapper.writeValueAsString(AjaxResult.error(登录参数异常))); return; } // 如果没有加密密码参数则继续原有流程 filterChain.doFilter(cachedRequest, response); } private String getRequestBody(HttpServletRequest request) throws IOException { StringBuilder stringBuilder new StringBuilder(); BufferedReader bufferedReader request.getReader(); char[] charBuffer new char[1024]; int bytesRead; while ((bytesRead bufferedReader.read(charBuffer)) ! -1) { stringBuilder.append(charBuffer, 0, bytesRead); } return stringBuilder.toString(); } }你需要一个CachedBodyHttpServletRequest包装类来支持请求体的多次读取。这里提供一个简化版import javax.servlet.ReadListener; import javax.servlet.ServletInputStream; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequestWrapper; import java.io.*; public class CachedBodyHttpServletRequest extends HttpServletRequestWrapper { private byte[] cachedBody; private String bodyString; public CachedBodyHttpServletRequest(HttpServletRequest request) throws IOException { super(request); InputStream requestInputStream request.getInputStream(); this.cachedBody requestInputStream.readAllBytes(); this.bodyString new String(cachedBody, request.getCharacterEncoding()); } public CachedBodyHttpServletRequest(HttpServletRequest request, String newBody) throws IOException { super(request); this.bodyString newBody; this.cachedBody newBody.getBytes(request.getCharacterEncoding()); } Override public ServletInputStream getInputStream() { ByteArrayInputStream byteArrayInputStream new ByteArrayInputStream(cachedBody); return new ServletInputStream() { Override public boolean isFinished() { return byteArrayInputStream.available() 0; } Override public boolean isReady() { return true; } Override public void setReadListener(ReadListener readListener) { // 无需实现 } Override public int read() { return byteArrayInputStream.read(); } }; } Override public BufferedReader getReader() { ByteArrayInputStream byteArrayInputStream new ByteArrayInputStream(cachedBody); return new BufferedReader(new InputStreamReader(byteArrayInputStream, getCharacterEncoding())); } public String getBody() { return bodyString; } }最后需要在Spring Security配置若依通常是SecurityConfig中将这个过滤器添加到登录请求处理链中合适的位置确保它在Spring Security的认证过滤器之前执行。import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { Autowired private Sm2PasswordDecryptFilter sm2PasswordDecryptFilter; Override protected void configure(HttpSecurity http) throws Exception { http // ... 其他配置 .addFilterBefore(sm2PasswordDecryptFilter, UsernamePasswordAuthenticationFilter.class) // 在用户名密码认证前解密 // ... 其他配置 } }4. 前端核心实现集成sm-crypto与登录改造前端的工作相对清晰在登录页面加载时获取SM2公钥在用户点击登录时用公钥加密密码然后将加密后的密文发送给后端。4.1 安装sm-crypto库在若依前端项目通常是一个Vue项目的根目录下执行npm命令安装sm-crypto。npm install sm-crypto --save # 或使用 yarn yarn add sm-crypto4.2 封装加密工具函数在src/utils目录下创建一个新的工具文件例如sm2_encrypt.js。import { sm2 } from sm-crypto /** * SM2加密工具 */ const Sm2Encrypt { // 公钥将从后端获取 publicKey: , /** * 设置公钥 * param {string} key - Base64编码的SM2公钥 */ setPublicKey(key) { // sm-crypto 需要的是16进制字符串但后端通常给Base64 // 需要将Base64解码后转成16进制 // 注意后端给的公钥是X.509格式的Base64sm2需要的是04开头的非压缩公钥16进制串 // 幸运的是sm-crypto的sm2.setPublicKey方法可以自动处理PEM格式 // 我们可以直接传入PEM格式的字符串包含-----BEGIN PUBLIC KEY----- // 但更简单的是后端直接返回16进制公钥。这里我们假设后端返回的是Base64编码的X.509 DER格式。 // 我们需要将其转换为sm-crypto需要的格式。 // 一种通用做法是后端直接返回16进制公钥字符串。我们调整后端接口。 // 为了兼容性我们这里假设后端返回的就是16进制字符串。 this.publicKey key }, /** * 加密字符串 * param {string} plainText - 明文 * param {string} cipherMode - 加密模式默认 C1C3C2需与后端保持一致 * returns {string} Base64编码的密文 */ encrypt(plainText, cipherMode C1C3C2) { if (!this.publicKey) { console.error(SM2公钥未设置无法加密) throw new Error(加密服务未初始化) } // sm-crypto的encrypt方法默认输出16进制字符串我们需要Base64 // 注意publicKey需要是04开头的16进制串 const encryptedHex sm2.doEncrypt(plainText, this.publicKey, 1) // 输入输出都是16进制1表示使用C1C3C2模式 // 将16进制字符串转换为Base64 const encryptedBase64 this.hexToBase64(encryptedHex) return encryptedBase64 }, /** * 将16进制字符串转换为Base64 * param {string} hexString * returns {string} */ hexToBase64(hexString) { return btoa(hexString.match(/\w{2}/g).map(function(a) { return String.fromCharCode(parseInt(a, 16)) }).join()) }, /** * 从后端获取并设置公钥 * returns {Promisestring} 公钥 */ async fetchAndSetPublicKey() { try { // 调用后端接口获取公钥这里使用若依封装的request const response await request({ url: /publicKey, method: get }) if (response response.code 200) { const pubKey response.data this.setPublicKey(pubKey) return pubKey } else { throw new Error(获取公钥失败 (response.msg || 未知错误)) } } catch (error) { console.error(获取SM2公钥失败:, error) throw error } } } export default Sm2Encrypt注意事项 这里有一个前后端数据格式对齐的关键点。sm-crypto的sm2.doEncrypt方法默认接受和返回的都是16进制字符串。而后端Java Bouncy Castle库处理的标准格式通常是ASN.1 DER编码的字节数组我们为了方便传输将其转换为Base64。 因此我们需要统一约定方案A推荐前端加密后将16进制密文转换为Base64再传输后端收到Base64密文解码后直接解密我们的工具类Sm2Util.decrypt已经处理了Base64输入。方案B后端提供一个接口不仅返回公钥还返回公钥的格式如hex或base64和加密模式。前端根据这些信息调用sm-crypto的不同方法。 我们采用方案A因为它简单直观。但需要确保sm2.doEncrypt的第二个参数公钥的格式正确。如果后端返回的是Base64编码的X.509公钥sm-crypto可能无法直接识别。更稳妥的做法是让后端接口直接返回16进制格式的公钥字符串。修改Sm2KeyService在返回公钥时不返回Base64而是返回从BCECPublicKey中提取出的04开头的16进制坐标串。修改后的后端公钥获取逻辑返回16进制在Sm2Util中添加一个方法从BCECPublicKey中获取16进制公钥public static String getPublicKeyHex(KeyPair keyPair) { BCECPublicKey publicKey (BCECPublicKey) keyPair.getPublic(); ECPoint q publicKey.getQ(); // 04 || X || Y String xHex Hex.toHexString(q.getAffineXCoord().toBigInteger().toByteArray()).toLowerCase(); String yHex Hex.toHexString(q.getAffineYCoord().toBigInteger().toByteArray()).toLowerCase(); // 确保长度是64字节256位 xHex StringUtils.leftPad(xHex, 64, 0); yHex StringUtils.leftPad(yHex, 64, 0); return 04 xHex yHex; }然后Sm2KeyController的/publicKey接口返回这个16进制字符串。这样前端Sm2Encrypt.setPublicKey就可以直接使用这个字符串了。4.3 改造登录页面与API找到若依前端的登录页面通常是src/views/login.vue。我们需要在页面加载时获取公钥并在登录时加密密码。template !-- 原有登录页面模板 -- /template script import { getCodeImg } from /api/login; import { encrypt } from /utils/jsencrypt; // 若依原有的RSA加密我们可能不再需要 import Sm2Encrypt from /utils/sm2_encrypt; // 导入我们封装的SM2工具 import { login, getPublicKey } from /api/login; // 假设我们创建了getPublicKey API export default { name: Login, data() { return { loginForm: { username: admin, password: admin123, code: , uuid: }, // ... 其他data sm2PublicKeyLoaded: false }; }, created() { this.getPublicKeyAndInit(); this.getCode(); }, methods: { // 获取公钥并初始化加密工具 async getPublicKeyAndInit() { try { // 调用新的API获取SM2公钥16进制 const response await getPublicKey(); if (response.code 200) { Sm2Encrypt.setPublicKey(response.data); this.sm2PublicKeyLoaded true; console.log(SM2公钥加载成功); } else { this.$modal.msgError(获取加密公钥失败登录功能受限); } } catch (error) { console.error(加载SM2公钥失败:, error); this.$modal.msgError(加密服务初始化失败); } }, // 修改登录方法 handleLogin() { this.$refs.loginForm.validate(valid { if (valid) { this.loading true; // 加密密码 let submitPassword this.loginForm.password; if (this.sm2PublicKeyLoaded) { try { submitPassword Sm2Encrypt.encrypt(this.loginForm.password); console.log(密码加密完成); } catch (error) { this.$modal.msgError(密码加密失败请刷新页面重试); this.loading false; return; } } else { // 如果公钥未加载可以降级为不加密不推荐或提示用户 this.$modal.msgWarning(加密模块未就绪密码将明文传输); // 或者直接阻止登录 this.$modal.msgError(系统初始化未完成请刷新页面); this.loading false; return; } // 准备提交的数据 const loginData { username: this.loginForm.username, password: submitPassword, // 这里是加密后的密文 code: this.loginForm.code, uuid: this.loginForm.uuid }; // 调用登录API原有的login函数 login(loginData).then(response { // ... 原有的登录成功处理逻辑 this.$store.commit(SET_TOKEN, response.token); this.$router.push({ path: this.redirect || / }); }).catch(() { this.loading false; this.getCode(); }); } }); }, // ... 其他方法 } }; /script同时需要在src/api/login.js中添加获取公钥的API函数import request from /utils/request // ... 原有其他API // 获取SM2公钥 export function getPublicKey() { return request({ url: /publicKey, method: get }) }5. 部署、测试与问题排查5.1 完整部署流程后端引入bcprov-jdk15to18依赖。创建Sm2Util、Sm2Properties、Sm2KeyService、Sm2KeyController、Sm2PasswordDecryptFilter及相关配置。启动应用查看日志将自动生成的SM2密钥对中的私钥Base64复制到application.yml的sm2.private-key配置项中。公钥会通过接口/publicKey暴露。确保过滤器被正确添加到Spring Security链中。前端安装npm install sm-crypto。创建sm2_encrypt.js工具类。修改login.vue在created钩子中调用getPublicKeyAndInit。修改handleLogin方法在提交前调用Sm2Encrypt.encrypt加密密码。在login.jsAPI文件中添加getPublicKey函数。重启服务先后端再前端确保服务正常运行。5.2 端到端测试步骤打开登录页打开浏览器开发者工具F12切换到Network网络面板。检查公钥加载刷新页面应该能看到一个对/publicKey的GET请求并成功返回一串很长的16进制字符串公钥。控制台应打印“SM2公钥加载成功”。执行登录输入用户名密码点击登录。观察网络请求在Network面板中找到/login的POST请求。查看其请求体Payloadpassword字段应该是一长串Base64编码的字符密文而不是你输入的明文密码。验证登录结果登录应成功跳转到主页。5.3 常见问题与排查技巧实录即使按照步骤操作你也可能会遇到一些坑。下面是我在实施过程中遇到的一些典型问题及解决方案问题1前端加密成功但后端解密失败报错“SM2解密失败”或“Invalid ciphertext”。排查思路这是最常见的问题根本原因是前后端的加密解密参数不匹配。检查清单公钥/私钥是否配对确保前端使用的公钥和后端用来解密的私钥是同一对密钥。检查后端启动日志确认加载的私钥与生成公钥时使用的是同一对。最简单的方法重启后端使用新生成的密钥对并更新前端。加密模式Cipher Mode确保前后端都使用相同的模式。sm-crypto的doEncrypt默认使用1即C1C3C2我们的Java工具类也指定了SM2Engine.Mode.C1C3C2。必须完全一致。数据格式前端输出sm2.doEncrypt返回16进制我们通过hexToBase64转成了Base64。确保这个转换是正确的。可以尝试在加密后用atob浏览器解码Base64看是否能还原回16进制字符串。后端输入我们的Sm2Util.decrypt方法接受Base64字符串并内部进行Base64解码。确保传输过程中Base64编码没有发生意外如URL编码、换行符等。可以在过滤器解密前打印一下收到的encryptedPassword字符串。公钥格式确保后端返回给前端的是04开头的130位65字节16进制公钥字符串。如果返回的是Base64编码的X.509格式前端需要先Base64解码再解析出16进制坐标。强烈建议后端直接返回16进制串避免前端复杂解析。问题2登录请求在解密过滤器处被截断返回400或415错误。原因CachedBodyHttpServletRequest包装器可能没有正确处理字符编码或请求头导致后续的RequestBody解析失败。解决检查CachedBodyHttpServletRequest中getCharacterEncoding()是否正确继承了原始请求的编码通常是UTF-8。确保在重新构造请求体时使用正确的编码。可以在过滤器中打印原始请求和解密后的请求体对比是否一致。问题3集成后验证码或其他登录参数失效。原因若依的登录逻辑可能除了username和password还依赖uuid和code进行验证码校验。我们的过滤器只修改了password字段但如果请求体解析或重新序列化时格式有细微差别如字段顺序、空格可能导致后端反序列化对象失败。解决在过滤器中使用与若依登录Controller中RequestBody接收的参数类型完全一致的DTO对象例如LoginBody来进行JSON的读取和写入而不是通用的Map。这样可以最大程度保证兼容性。问题4性能考虑。SM2加密解密比对称加密慢会影响登录速度吗分析对于登录这种低频操作SM2的非对称加密解密开销完全可以接受。一次登录的加解密延迟通常在几十毫秒级别用户无感知。优化如果确实担心可以将公钥缓存在前端如SessionStorage避免每次刷新页面都请求。私钥在后端常驻内存无需每次解密都重新加载。问题5如何应对密钥泄露或定期更换密钥方案这是一个更高级的安全实践。可以考虑密钥轮换后端定期如每月生成新的密钥对。前端在每次登录时都获取一次公钥可缓存短期这样即使旧密钥泄露新会话也会使用新密钥。多密钥支持后端可以同时维护多组密钥对并为每个公钥分配一个Key ID。前端获取公钥时同时拿到Key ID。加密时将Key ID和密文一起发送给后端后端根据Key ID选择对应的私钥解密。这样可以在不影响现有会话的情况下轮换密钥。6. 总结与扩展思考至此我们已经完成了在若依框架中集成SM2国密算法实现登录密码前端加密的全过程。这个方案的核心价值在于在HTTPS的通道安全之上增加了应用层的密码传输安全并且符合国密算法合规要求。回顾整个实施有几个关键决策点值得再次强调一是选择前后端统一的加密模式C1C3C2和数据交换格式前端Hex转Base64后端Base64解码二是采用过滤器/拦截器的方式对密码进行解密对若依原有登录逻辑的侵入性最小三是将密钥管理生成、存储、提供完全放在后端前端只负责加密私钥永不泄露。这个方案还可以进一步扩展。例如不仅用于登录还可以用于其他敏感信息的传输如注册密码、支付密码等。sm-crypto和Bouncy Castle还支持SM3摘要算法和SM4对称加密你可以考虑用SM3来替代MD5或SHA-256做密码哈希需后端配合改造密码存储或者用SM4来加密前端本地存储的某些敏感数据。最后安全是一个整体前端加密只是其中一环。确保服务器私钥的安全存储、使用安全的随机数生成器、防范重放攻击可以加入时间戳和随机数、以及后端完善的风控机制如登录失败锁定、异地登录提醒等同样重要。将这个SM2加密方案融入到若依已有的安全体系中才能构建更坚固的防御。