1. 项目概述与背景最近在重构一个政务类项目的登录模块客户明确要求必须使用国密算法来保障数据传输安全。我们团队的技术栈是基于若依RuoYi这套前后端分离的框架所以很自然地任务就变成了如何在若依框架里把SM2这套非对称加密算法给无缝整合进去实现从前端加密密码、到后端解密验密的完整闭环。这活儿听起来就是加个加密库的事儿但真动起手来从算法选型、密钥管理到前后端加解密逻辑的对接每一步都有不少细节要抠。特别是若依本身已经有一套成熟的Spring Security JWT的认证流程我们要做的不是推倒重来而是像做外科手术一样把SM2这个“新器官”精准地移植进去还不能影响原有系统的“血液循环”。如果你也在做类似的安全升级或者对若依框架的深度定制感兴趣那这篇从踩坑到填坑的实录应该能给你省下不少折腾的时间。2. 技术选型与核心组件解析2.1 为什么是SM2而非SM4或SM3在国密算法家族里SM2、SM3、SM4各司其职。SM3是哈希算法用于完整性校验比如生成摘要SM4是对称加密算法加解密用同一把密钥适合大量数据的加密传输。而SM2是非对称加密算法基于椭圆曲线密码学它有一对密钥公钥和私钥。公钥可以公开用于加密私钥必须严格保密用于解密。登录场景的核心需求是解决“密码在传输过程中明文暴露”的问题这是一个典型的“客户端加密服务端解密”的场景公钥可以放心地交给前端私钥牢牢握在后端手里这正是非对称加密的用武之地。所以选择SM2是业务场景驱动的必然结果。相比之下如果用SM4密钥如何安全地分发给前端就成了一个先有鸡还是先有蛋的安全悖论。2.2 后端算法库Hutool的SM2模块Java后端的国密算法库有不少选择比如Bouncy CastleBC的国密Provider或者一些独立的实现。我最终选择了Hutool工具包里的hutool-crypto模块。原因有几个首先Hutool在国内Java开发者中口碑很好API设计得非常友好对国标的支持也很积极其次它底层其实也依赖了BC但做了更上层的封装比如直接支持PEM格式的密钥对读取这比直接操作BC的ECPrivateKeySpec和ECPublicKeySpec要省心太多最后若依框架本身也集成了Hutool引入它不会有额外的依赖冲突风险。在pom.xml里加入以下依赖即可dependency groupIdcn.hutool/groupId artifactIdhutool-crypto/artifactId version5.8.22/version !-- 请使用最新稳定版 -- /dependency注意确保你的项目JDK版本在8及以上。Hutool的SM2实现需要依赖BC但通常它已经打包好了无需单独引入。2.3 前端加密库sm-crypto前端方面搜索“vue2 sm2加密”出现频率最高的就是sm-crypto。这是一个专门针对国密算法实现的JavaScript库支持SM2、SM3、SM4而且体积小巧无外部依赖。它提供了非常清晰的异步和同步API完美适配Vue 2若依前端默认基于Vue 2。我们可以通过npm安装npm install sm-crypto --save或者如果项目没有用npm管理也可以直接下载其UMD格式的js文件通过script标签引入。对于若依这样已经配置好Webpack的前端工程用npm安装是更规范的做法。2.4 密钥对生成与管理策略这是整个流程的安全基石。绝对不要在前端动态生成密钥对也绝对不要将私钥的任何部分硬编码在前后端代码或配置文件中。正确的做法是在安全的服务器环境比如运维后台或首次部署时下使用可靠的国密工具或Hutool库生成一对SM2密钥对通常包含公钥publicKey和私钥privateKey。公钥通常是一串以04开头的130位十六进制字符串或经过压缩的66位字符串可以配置给前端使用。私钥则必须被妥善保管我的做法是环境变量存储将私钥字符串存入服务器的环境变量中应用启动时读取。配置中心托管如果公司有配置中心如Nacos、Apollo将私钥作为加密配置项存储。文件系统加密存储将私钥保存在服务器本地一个权限严格控制的文件中并对该文件进行加密。在我们的若依项目中我选择将公钥放在前端的配置文件如vue.config.js或一个专门的config.js中通过全局变量或Axios拦截器注入。私钥则放在后端应用的application.yml里但利用Jasypt或类似库进行加密解密密钥通过环境变量传入实现“配置中看不到明文私钥”。3. 前端Vue 2加密改造实战3.1 引入sm-crypto并封装工具函数首先在若依前端项目的src/utils目录下创建一个新的文件比如sm2-encrypt.js。在这个文件里我们引入sm-crypto并封装一个易于调用的加密函数。// src/utils/sm2-encrypt.js import { sm2 } from sm-crypto // 这里填入从后端配置获取的公钥示例公钥切勿直接使用 const publicKey 04你的130位十六进制公钥字符串... /** * 使用SM2公钥加密文本 * param {string} plainText - 待加密的明文 * param {string} cipherMode - 加密模式默认为C1C3C2即旧标准可选C1C2C3新标准 * returns {string} 加密后的十六进制字符串 */ export function encryptWithSM2(plainText, cipherMode C1C3C2) { if (!publicKey) { console.error(SM2公钥未配置) throw new Error(加密公钥不可用) } try { // sm2.doEncrypt 默认输出16进制字符串这正是我们网络传输需要的格式 const encryptedData sm2.doEncrypt(plainText, publicKey, cipherMode) return encryptedData } catch (error) { console.error(SM2加密失败:, error) throw new Error(数据加密处理异常) } } // 可选导出公钥方便其他地方引用如登录页 export { publicKey }这里有几个关键点第一cipherMode参数很重要。国密SM2标准早期是C1C3C2即密文由曲线点C1、杂凑值C3、密文C2拼接新标准是C1C2C3。必须确保前后端使用的模式一致否则解密会失败。Hutool默认支持两种但需要明确指定。第二加密后的数据是十六进制字符串长度会远长于原文这是正常的。3.2 改造若依登录页的提交逻辑若依的登录页通常位于src/views/login.vue。我们需要找到表单提交的处理方法通常是handleLogin。改造的核心思路是在发起登录请求前拦截密码字段用SM2加密后再赋值回提交的数据对象。// 在login.vue的script部分 import { encryptWithSM2 } from /utils/sm2-encrypt export default { name: Login, data() { return { loginForm: { username: admin, password: admin123, // ... 其他字段如验证码 }, // ... } }, methods: { handleLogin() { this.$refs.loginForm.validate(valid { if (valid) { this.loading true // 关键步骤加密密码 const encryptedPassword encryptWithSM2(this.loginForm.password) // 构造提交数据使用加密后的密码 const loginData { username: this.loginForm.username, password: encryptedPassword, // 这里已经是加密字符串 // ... 其他如验证码字段原样传递 } // 调用若依封装的登录API this.$store.dispatch(user/login, loginData).then(() { // ... 登录成功后的跳转逻辑 this.loading false }).catch(() { this.loading false }) } else { console.log(表单验证失败) return false } }) } } }实操心得加密操作是同步的但考虑到可能存在的性能开销虽然对登录场景微乎其微最好在用户点击登录按钮后、请求发出前给一个“加密中”的视觉反馈比如禁用按钮或显示loading避免用户重复点击。同时务必做好异常捕获加密失败时应给用户明确的提示而不是让表单悄无声息地卡住。3.3 处理可能的编码与传输问题加密后的十六进制字符串直接作为JSON的一部分通过HTTP POST发送通常不会有问题。但有一种边缘情况需要注意如果密码原文或加密后的字符串中包含某些特殊字符可能会被一些老旧的系统或中间件错误处理。虽然现代Web框架如Spring Boot和HTTP客户端如Axios都能很好地处理但为了绝对稳健可以在加密后再进行一次Base64编码。// 在加密函数中增加选项 export function encryptWithSM2(plainText, cipherMode C1C3C2, encodeBase64 false) { // ... 加密逻辑同上 const encryptedHex sm2.doEncrypt(plainText, publicKey, cipherMode) if (encodeBase64) { // 将16进制字符串转换为Buffer再编码为Base64 return Buffer.from(encryptedHex, hex).toString(base64) } return encryptedHex }相应地后端在解密前需要先判断接收到的密文是十六进制还是Base64格式并做对应的解码。我个人更倾向于直接传输十六进制因为它本来就是SM2加密的标准输出格式可读性用于调试和兼容性都更好。4. 后端Spring Boot解密与集成4.1 创建SM2工具类与配置读取在后端若依项目中我们首先创建一个SM2解密的工具类。这个类负责加载私钥并提供解密方法。// 在 com.ruoyi.common.utils 包下创建 Sm2Util.java package com.ruoyi.common.utils; import cn.hutool.core.util.HexUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.crypto.BCUtil; import cn.hutool.crypto.SmUtil; import cn.hutool.crypto.asymmetric.KeyType; import cn.hutool.crypto.asymmetric.SM2; import org.bouncycastle.crypto.engines.SM2Engine; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import javax.annotation.PostConstruct; import java.security.PrivateKey; Component public class Sm2Util { /** * 从配置文件读取经过加密的私钥。 * 实际配置示例在application.yml中 * sm2: * private-key: ENC(加密后的私钥字符串) # 使用Jasypt加密 */ Value(${sm2.private-key}) private String encryptedPrivateKeyStr; /** 解密时使用的模式需与前端的cipherMode一致 */ private static final SM2Engine.Mode DECRYPT_MODE SM2Engine.Mode.C1C3C2; private SM2 sm2; /** * 初始化方法在Bean构造后执行用于构建SM2实例。 * 这里假设私钥是PEM格式的字符串且已经过解密处理。 * 实际项目中你需要先对encryptedPrivateKeyStr进行解密得到明文私钥。 */ PostConstruct public void init() { // 第一步解密配置中的私钥这里简化假设encryptedPrivateKeyStr已经是解密后的明文 // 实际应调用你的配置解密服务例如 // String plainPrivateKey configDecryptService.decrypt(encryptedPrivateKeyStr); String plainPrivateKey encryptedPrivateKeyStr; // 此处仅为演示实际必须解密 if (StrUtil.isBlank(plainPrivateKey)) { throw new IllegalArgumentException(SM2私钥未配置或配置为空); } try { // Hutool支持直接通过PEM格式的私钥字符串创建PrivateKey对象 PrivateKey privateKey BCUtil.decodeECPrivateKey(plainPrivateKey); // 创建SM2实例并指定解密模式 this.sm2 SmUtil.sm2(privateKey, null); // 第二个参数是公钥解密不需要传null this.sm2.setMode(DECRYPT_MODE); } catch (Exception e) { throw new RuntimeException(初始化SM2解密工具失败请检查私钥格式是否正确, e); } } /** * SM2解密方法 * param cipherData 密文数据16进制字符串 * return 解密后的明文 */ public String decrypt(String cipherData) { if (sm2 null) { throw new IllegalStateException(SM2工具未正确初始化); } if (StrUtil.isBlank(cipherData)) { return cipherData; // 或者抛异常根据业务定 } try { // Hutool的decrypt方法内部会处理16进制转换 // KeyType.PrivateKey 表示使用私钥解密 return sm2.decryptStr(cipherData, KeyType.PrivateKey); } catch (Exception e) { // 记录日志这里可以包装成自定义的业务异常 throw new RuntimeException(SM2解密失败密文可能已损坏或密钥不匹配, e); } } /** * 提供一个便捷方法用于判断字符串是否为有效的SM2密文16进制格式 */ public static boolean isValidCipherHex(String str) { if (StrUtil.isBlank(str)) { return false; } // 简单判断长度较长且为偶数16进制字符数为偶数 // 更严谨的做法是尝试解码 return str.length() 128 str.matches(^[0-9a-fA-F]$); } }这个工具类的关键在于init方法它保证了私钥在应用启动时就被安全地加载并初始化好SM2实例。decrypt方法则是核心对外提供解密服务。4.2 自定义AuthenticationProvider接管密码验证若依默认使用Spring Security的DaoAuthenticationProvider它从数据库取出用户密码通常是BCrypt加密的后与用户输入的密码进行比对。现在用户输入的是SM2加密后的密文我们需要先解密再走后续的BCrypt比对流程。因此我们需要自定义一个AuthenticationProvider。// 创建 com.ruoyi.framework.security.filter 包下的自定义Provider package com.ruoyi.framework.security.filter; import com.ruoyi.common.utils.Sm2Util; import com.ruoyi.framework.security.service.UserDetailsServiceImpl; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; /** * 自定义认证提供者在原有密码验证前插入SM2解密步骤 */ Component public class Sm2AuthenticationProvider extends DaoAuthenticationProvider { Autowired private Sm2Util sm2Util; /** * 设置UserDetailsService这部分在配置类中注入见下文 */ Autowired public void setUserDetailsService(UserDetailsServiceImpl userDetailsService) { super.setUserDetailsService(userDetailsService); } Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { String username authentication.getName(); String presentedPassword (String) authentication.getCredentials(); // 前端传过来的加密密码 if (!StringUtils.hasText(presentedPassword)) { throw new BadCredentialsException(密码不能为空); } // 关键步骤SM2解密 String decryptedPassword; try { // 判断是否是SM2密文可根据业务约定例如所有登录请求密码都加密 // 这里简单判断是否为长十六进制字符串 if (Sm2Util.isValidCipherHex(presentedPassword)) { decryptedPassword sm2Util.decrypt(presentedPassword); } else { // 如果不是密文可能是其他情况如内部调用、测试直接使用原密码 // 但生产环境建议严格只接受密文这里根据安全策略调整 decryptedPassword presentedPassword; logger.warn(接收到非SM2密文格式的密码请检查前端加密是否生效。用户名 username); } } catch (Exception e) { logger.error(用户[ username ]登录时SM2解密失败, e); throw new BadCredentialsException(登录凭证处理失败); } // 使用解密后的密码构造一个新的Authentication对象交给父类进行后续的UserDetails查询和密码比对 Authentication newAuth new UsernamePasswordAuthenticationToken(username, decryptedPassword); return super.authenticate(newAuth); } Override public boolean supports(Class? authentication) { // 指定这个Provider支持用户名密码类型的认证 return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication); } }这个自定义Provider拦截了认证流程在调用父类的authenticate方法该方法会调用UserDetailsService加载用户并比对密码之前先对凭证密码进行SM2解密。解密后的明文密码才会被用于和数据库存储的BCrypt哈希值比对。4.3 修改Security配置启用自定义Provider最后我们需要修改若依的安全配置类通常位于com.ruoyi.framework.config.SecurityConfig让Spring Security使用我们自定义的Sm2AuthenticationProvider而不是默认的。// 在SecurityConfig类中注入并配置 Configuration EnableGlobalMethodSecurity(prePostEnabled true, securedEnabled true) public class SecurityConfig extends WebSecurityConfigurerAdapter { Autowired private UserDetailsServiceImpl userDetailsService; Autowired private Sm2AuthenticationProvider sm2AuthenticationProvider; // 注入自定义Provider /** * 认证失败处理类 */ Autowired private AuthenticationEntryPointImpl unauthorizedHandler; // ... 其他已有的Bean如Token过滤器、密码编码器等 /** * 强认证管理器配置 */ Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { // 关键使用我们自定义的Provider auth.authenticationProvider(sm2AuthenticationProvider); // 也可以保留原有的userDetailsService配置作为备用或用于其他场景 // auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder()); } // 确保UserDetailsService和PasswordEncoder的Bean存在 Bean public UserDetailsService userDetailsService() { return userDetailsService; } Bean public BCryptPasswordEncoder bCryptPasswordEncoder() { return new BCryptPasswordEncoder(); } // ... 剩下的configure(HttpSecurity http)方法通常不需要大改 }通过这样的配置Spring Security的认证管理器(AuthenticationManager)在处理登录请求时就会优先使用我们注入的Sm2AuthenticationProvider。这个Provider完成了SM2解密然后将解密后的用户名密码传递给父类DaoAuthenticationProvider后者会调用我们熟悉的UserDetailsService来加载用户并用BCryptPasswordEncoder比对密码。整个流程就无缝衔接上了。5. 完整流程联调与问题排查5.1 端到端调试步骤密钥准备与配置在服务器生成SM2密钥对。公钥配置到前端项目的全局变量或配置文件中。私钥加密后存入后端application.yml并确保Sm2Util能正确解密加载。前端验证在浏览器打开登录页打开开发者工具F12的“网络(Network)”选项卡。输入用户名密码点击登录查看发出的POST请求的payload。确认password字段的值是一长串十六进制字符形如04a2b3c4...而不是明文。这证明前端加密已生效。后端日志监控在后端应用日志中确保Sm2AuthenticationProvider的decrypt方法被调用。你可以临时在decrypt方法开始和结束处打印日志看到“开始解密...”和“解密结果...”这样的信息。解密结果应该是你的明文密码。认证流程跟踪解密后的密码会进入Spring Security的默认流程。若依默认会查询sys_user表比对password字段的BCrypt哈希值。确保数据库中的密码哈希值是你明文密码经过BCrypt加密的结果。这个比对由Spring Security自动完成。结果验证登录成功跳转到首页并能在localStorage或Cookie中看到若依返回的token。5.2 常见问题与解决方案速查表问题现象可能原因排查步骤与解决方案前端控制台报错sm2.doEncrypt is not a function1.sm-crypto库未正确安装或引入。2. 在非模块化环境使用了import语法。1. 检查node_modules下是否有sm-crypto目录package.json中版本是否正确。2. 若使用传统script引入确保库已全局加载并通过window.sm2调用。登录失败后端日志显示“SM2解密失败”或“Invalid point encoding”1.前后端加密/解密模式不匹配最常见。2. 公钥私钥不配对。3. 传输的密文被篡改或编码出错如多了一次URL解码。1.核对cipherMode前端sm2.doEncrypt的第三个参数与后端Sm2Util中DECRYPT_MODE常量必须一致都是C1C3C2或都是C1C2C3。2. 重新生成一对密钥确保使用的是同一对。3. 打印接收到的原始密文与前端发送的对比看是否一致。检查是否有全局的Http拦截器对请求体做了额外处理。解密成功但密码比对失败Bad credentials1. 数据库存储的密码哈希值与解密后的明文密码不匹配。2. 用户不存在。1. 确认数据库sys_user表的password字段是解密后明文的BCrypt哈希值。可以通过BCryptPasswordEncoder的encode方法手动生成一个更新到数据库进行测试。2. 检查用户名是否正确。后端启动报错提示私钥格式错误1. 私钥字符串格式不正确不是有效的PEM或原始十六进制。2. 私钥字符串中包含多余字符如换行符、-----BEGIN PRIVATE KEY-----头尾标记。1. 使用Hutool的BCUtil.decodeECPrivateKey方法对私钥字符串进行解码测试。2. 确保配置的私钥是纯粹的Base64编码内容如果使用PEM格式需要去除头尾标记和换行符。Hutool的SmUtil和BCUtil对格式要求比较灵活但最好保持一致。加密后密码长度异常导致数据库字段长度不足若依默认的sys_user表password字段是varchar(100)而SM2加密后的十六进制字符串长度可能超过128位。1. 确认加密后的字符串长度。SM2密文长度与明文无关固定输出长度如使用C1C3C2模式未压缩公钥加密后约为130位十六进制字符。2.修改数据库字段长度将password字段改为varchar(256)或更长确保能存下加密后的密文。注意这里存储的是前端传来的密文仅在自定义认证流程的早期存在最终比对的还是解密后的明文对应的BCrypt哈希值所以数据库字段长度不影响最终存储。但如果你打算在认证前持久化这个密文用于审计则需要考虑长度。登录请求响应慢SM2加解密是CPU密集型操作特别是后端解密。1. 这是正常现象非对称加密本就比对称加密慢。一次登录操作的额外开销在可接受范围内通常增加几十到几百毫秒。2. 确保Sm2Util是单例的且私钥加载和SM2实例初始化只进行一次PostConstruct。3. 对于超高并发登录场景可以考虑对SM2解密操作进行简单的连接池化或异步处理但这会引入复杂度需权衡。5.3 性能与安全考量性能SM2解密操作确实有开销。在我的实测中在一台普通开发机上单次SM2解密解密约100字符的密文耗时大约在10-30毫秒。对于登录这种低频操作这个开销完全可以接受。如果登录QPS极高可以考虑将解密服务稍微异步化或者确保Sm2Util的实现是线程安全的Hutool的SM2对象是非线程安全的但我们在Component单例中每次解密都使用同一个实例好在Hutool的最新版本中其加解密方法内部做了同步处理但最好查阅其文档或源码确认。稳妥起见可以在decrypt方法上加synchronized关键字或者使用ThreadLocal为每个线程创建独立的SM2实例。安全加固防重放攻击目前的流程只解决了密码传输的机密性没有防重放。攻击者截获加密后的密码请求包可以直接重放登录。解决方案是在登录请求中加入时间戳和随机数Nonce后端校验请求的时效性比如5分钟内有效和Nonce的唯一性。密钥轮转应制定计划定期如每季度或每半年更换SM2密钥对。更换时需要同时更新前后端配置并考虑新旧密钥的短暂共存期以实现平滑过渡。监控与告警在Sm2AuthenticationProvider的解密失败异常处加入详细的日志监控。如果短时间内出现大量解密失败可能意味着有攻击者在尝试伪造或篡改密文应触发安全告警。6. 扩展思考更优雅的集成方案上述方案是直接侵入式地修改了认证核心流程。另一种更解耦、更清晰的做法是将SM2解密作为一个独立的过滤器Filter或Spring MVC的拦截器Interceptor在请求到达UsernamePasswordAuthenticationFilter之前就对请求体中的密码字段进行解密和替换。这样做的好处是职责更清晰认证提供者只关心用户名密码的验证不关心传输加密。易于复用其他需要传输加密参数的接口也可以复用这个过滤器。便于开关可以通过配置轻松开启或关闭SM2加密特性。实现思路是创建一个Sm2DecryptFilter继承OncePerRequestFilter在doFilterInternal方法中检查请求URI是否为登录路径/login然后读取HttpServletRequest的body解析JSON找到password字段调用Sm2Util解密再将修改后的JSON写回请求体最后放行过滤器链。不过这需要小心处理请求流的读取和缓存问题Spring的ContentCachingRequestWrapper可以帮忙实现起来比直接改AuthenticationProvider稍复杂一些但架构上更优美。最后关于“若依框架登录报no static”这个热搜词它通常与Spring Security的静态资源放行配置有关或者前端请求的路径不对。这与SM2整合本身关系不大但如果你在整合后遇到登录页的CSS/JS加载不了记得检查SecurityConfig中configure(WebSecurity web)方法是否放行了/static/**、/login等路径以及configure(HttpSecurity http)方法中是否允许匿名访问登录页。