1. 项目概述与核心价值最近在重构一个对安全性要求比较高的后台管理系统登录模块的安全加固是首要任务。明文传输密码这种“裸奔”行为在当下是绝对不可接受的尤其是在涉及敏感数据的场景。虽然常见的RSA非对称加密方案已经能解决传输安全的问题但在一些特定行业如金融、政务或对自主可控有要求的项目中采用国家密码管理局认定的国产密码算法国密算法成为了一个硬性要求或更优选择。SM2作为国密算法体系中的非对称加密算法其安全强度等同于甚至优于RSA-2048且运算效率更高。若依RuoYi作为一个流行的开源后台管理系统其前后端分离的架构非常清晰为我们集成SM2提供了良好的基础。但官方文档或社区中关于SM2完整集成从前端加密到后端解密验签的实践指南并不多且散落在各处。这次实践的目标就是打通这条链路用户在登录页输入密码前端使用SM2公钥加密后端使用SM2私钥解密并验证最终完成安全的身份认证。整个过程不仅是为了替换算法更是深入理解非对称加密在Web登录场景下的应用细节包括密钥管理、异常处理和性能考量。2. 技术选型与核心组件解析在开始动手之前明确技术栈和工具选型至关重要。这直接决定了实现的复杂度和后期的可维护性。2.1 前端加密库sm-crypto前端JavaScript环境无法直接处理复杂的加密运算我们需要一个可靠的库。sm-crypto是一个纯JavaScript实现的国密算法库支持SM2、SM3、SM4且不依赖任何本地插件通过npm即可安装非常适合Web前端。选择它的理由很充分首先它足够轻量且活跃度较高其次它的API设计对前端开发者友好加密结果通常输出为16进制字符串或Base64方便通过HTTP传输。这里有一个关键点需要注意SM2加密后的密文并不是简单的“加密数据”它实际上是一个结构化的数据块包含了用于解密的必要信息如曲线点、哈希值等。sm-crypto的sm2.doEncrypt方法默认输出的是C1C3C2拼接格式的16进制字符串这是国密标准规定的格式后端必须使用兼容此格式的库进行解密。2.2 后端解密库Bouncy Castle 或 HutoolJava后端的选择相对多一些。最专业的选择是Bouncy CastleBCProvider它是一个功能强大的密码学提供者对国密算法有完善的支持。但BC的API相对底层直接使用需要处理很多密码学细节。为了提升开发效率我选择了国人开发的Hutool工具包。Hutool在5.8.0版本中对国密算法进行了封装提供了SmUtil和SecureUtil等工具类让SM2的加解密、签名验签变得像调用普通工具方法一样简单。它的底层同样依赖Bouncy Castle但屏蔽了复杂性。例如SmUtil.sm2()方法可以快速初始化一个SM2对象用私钥解密前端传来的密文字符串非常方便。需要注意的是要确保Hutool-core和Hutool-crypto的版本一致并且引入BC的依赖。2.3 密钥对生成与管理策略SM2非对称加密的基础是一对密钥公钥Public Key和私钥Private Key。公钥用于加密可以公开私钥用于解密必须严格保密。生成方式离线生成推荐在安全的服务器环境如后端部署的机器下使用OpenSSL需支持国密或Java代码生成密钥对。将公钥通常为65字节的未压缩格式或33字节的压缩格式以04开头提供给前端。私钥则以PEM或PKCS#8格式妥善保存并配置到后端应用的配置文件如application.yml或密钥管理服务中绝对不要提交到代码仓库。在线生成仅用于测试网上有一些SM2密钥对生成工具但仅适用于开发和测试环境生产环境严禁使用。密钥格式前端sm-crypto通常使用16进制字符串格式的公钥。后端Hutool支持多种格式的私钥加载包括原始的私钥值d16进制字符串、PEM文件或PKCS#8编码的字符串。在配置文件中我倾向于使用去掉头尾标记和换行符的PEM格式字符串便于管理。注意公私钥必须配对使用。用A密钥对生成的公钥加密的数据只能用A密钥对对应的私钥解密。一旦密钥对确定前后端必须同步。3. 前端Vue集成与加密实现若依前端基于Vue 2/3和Element UI登录逻辑集中在src/views/login.vue组件中。我们的改造目标是在用户点击登录、发起请求之前将表单中的密码字段用SM2公钥加密。3.1 安装与引入sm-crypto首先在前端项目根目录下安装依赖npm install sm-crypto --save然后在登录组件中引入SM2模块。我习惯在login.vue的script标签顶部引入import { sm2 } from sm-crypto;3.2 获取并配置SM2公钥公钥不能硬编码在代码里。我采取的方式是在应用初始化或登录页加载时通过一个安全的API从后端获取公钥。这个接口本身不需要认证因为它公开的就是用于加密的公钥。可以在Vue的created()或mounted()生命周期钩子中调用async fetchPublicKey() { try { const response await this.$axios.get(/auth/publicKey); // 假设后端提供此接口 this.publicKey response.data; // 假设返回的是16进制字符串 } catch (error) { console.error(获取加密公钥失败, error); // 可以降级处理例如使用一个内置的测试公钥仅用于开发或提示用户网络错误 } }将获取到的公钥字符串存储在组件的data中。3.3 改造登录提交逻辑找到登录表单的提交方法通常是handleLogin在组装登录请求数据loginForm时对密码进行加密handleLogin() { this.$refs.loginForm.validate(valid { if (valid) { this.loading true; // 对密码进行SM2加密 const encryptedPassword sm2.doEncrypt(this.loginForm.password, this.publicKey, 1); // 第3个参数1表示输出16进制 // 组装请求参数使用加密后的密码 const loginParams { username: this.loginForm.username, password: encryptedPassword, // 这里已经是密文 rememberMe: this.loginForm.rememberMe, code: this.loginForm.code, uuid: this.loginForm.uuid }; // 调用登录APIloginParams中的password已是密文 this.$store.dispatch(user/login, loginParams).then(() { // ... 登录成功后的跳转逻辑 }).catch(() { this.loading false; // ... 刷新验证码等逻辑 }); } }); }关键点解析sm2.doEncrypt(明文, 公钥, 输出编码)这是核心加密方法。公钥就是上一步获取的16进制字符串。输出编码1代表输出为16进制字符串hex这是最常用的便于JSON传输。也可以选0Base64。非常重要加密操作是同步的但对于较长的密码它仍然是瞬间完成的。加密后的密文长度会远长于原密码通常超过200个字符。3.4 前端注意事项与踩坑点公钥格式确保从后端获取的公钥格式与sm-crypto要求的格式一致。通常是04开头的130位16进制字符串未压缩公钥。如果后端提供的是Base64或其他格式前端需要先进行转换。错误处理加密过程可能因公钥格式错误而失败。务必用try...catch包裹加密代码并给用户友好的提示例如“加密服务异常请刷新页面重试”。性能SM2加密是CPU密集型操作但在现代浏览器中加密一个密码的耗时在毫秒级用户无感知。不过要避免在循环或高频事件中滥用。降级方案考虑极端情况如果获取公钥的接口失败是否要有降级方案例如可以提示用户“安全模块加载失败无法登录”并引导其检查网络或联系管理员。切勿在加密失败时回退到明文传输。Vue3/TypeScript若依Vue3版本用法类似注意sm-crypto的TypeScript类型定义可能需要自己补充或使用types/sm-crypto如果存在。4. 后端Spring Boot集成与解密处理后端的任务是接收前端传来的密文密码用保管好的SM2私钥将其解密得到明文密码然后再走原有的密码校验流程通常是加盐哈希后与数据库存储的密文比对。4.1 引入依赖与配置私钥在pom.xml中添加Hutool和Bouncy Castle依赖dependency groupIdcn.hutool/groupId artifactIdhutool-all/artifactId version5.8.22/version !-- 请使用最新稳定版 -- /dependency !-- Bouncy Castle 提供者Hutool国密功能依赖它 -- dependency groupIdorg.bouncycastle/groupId artifactIdbcprov-jdk15to18/artifactId version1.75/version /dependency在application.yml中配置私钥。私钥信息敏感建议放在配置中心或环境变量中这里以配置文件示例sm2: private-key: | -----BEGIN PRIVATE KEY----- MIGTAgEAMBMGByqGSM49AgEGCCqBHM9VAYItBHkwdwIBAQQg0XoavwN0p3QYV7ML ... (您的私钥PEM内容去除头尾和换行符后的字符串也可以) ... -----END PRIVATE KEY----- public-key: 04xxxxxxxx... (前端使用的公钥可用于接口返回)4.2 编写SM2工具类创建一个Sm2Utils工具类专门负责加解密和密钥读取。这里使用Hutool简化操作。import cn.hutool.core.codec.Base64; import cn.hutool.core.util.HexUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.crypto.SmUtil; import cn.hutool.crypto.asymmetric.KeyType; import cn.hutool.crypto.asymmetric.SM2; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import javax.annotation.PostConstruct; Component public class Sm2Utils { Value(${sm2.private-key}) private String privateKeyStr; private SM2 sm2; /** * 初始化SM2实例使用配置的私钥。 * 假设配置的私钥是PEM格式字符串。 */ PostConstruct public void init() { // Hutool 能自动识别PEM格式的私钥字符串 this.sm2 SmUtil.sm2(privateKeyStr, null); // 如果私钥是纯16进制字符串不含PEM头尾则使用 // this.sm2 new SM2(privateKeyStr, null); } /** * SM2解密 * param encryptedDataHex 前端加密后的16进制密文字符串 * return 解密后的明文 */ public String decrypt(String encryptedDataHex) { if (StrUtil.isBlank(encryptedDataHex)) { throw new IllegalArgumentException(密文不能为空); } try { // Hutool的SM2解密方法自动识别C1C3C2格式的16进制密文 String decrypted sm2.decryptStr(encryptedDataHex, KeyType.PrivateKey); return decrypted; } catch (Exception e) { throw new RuntimeException(SM2解密失败密文格式或密钥可能不正确, e); } } /** * 获取SM2公钥16进制字符串用于提供给前端 * return 公钥16进制字符串 */ public String getPublicKeyHex() { // 从SM2对象中获取公钥点并转换为未压缩的04开头格式 return sm2.getPublicKey().toString(16); } }4.3 改造登录认证逻辑若依的登录处理通常在SysLoginService的login方法中。我们需要在验证密码之前插入解密步骤。找到处理用户名密码登录的方法可能是login方法中调用authenticationManager.authenticate的地方或者是自定义的UserDetailsService修改其逻辑拦截加密密码从前端登录参数LoginBody中获取的password字段现在已经是SM2密文。解密调用Sm2Utils.decrypt(password)得到明文密码。后续流程将解密后的明文密码交给Spring Security的DaoAuthenticationProvider进行校验它会调用UserDetailsService加载用户并用配置的PasswordEncoder比对密码。关键代码示例在登录Service中Service public class SysLoginService { Autowired private Sm2Utils sm2Utils; public String login(LoginBody loginBody) { String username loginBody.getUsername(); String encryptedPassword loginBody.getPassword(); // 前端传过来的密文 // 1. SM2解密 String plainPassword; try { plainPassword sm2Utils.decrypt(encryptedPassword); } catch (Exception e) { log.error(用户[{}]登录密码解密失败, username, e); throw new ServiceException(登录信息异常); } // 2. 构造Spring Security可识别的认证令牌 UsernamePasswordAuthenticationToken authenticationToken new UsernamePasswordAuthenticationToken(username, plainPassword); // 3. 交给AuthenticationManager进行认证内部会调用UserDetailsService和PasswordEncoder Authentication authentication authenticationManager.authenticate(authenticationToken); // 4. 后续生成Token等逻辑... } }4.4 提供获取公钥的接口为了让前端能动态获取公钥需要暴露一个APIRestController RequestMapping(/auth) public class AuthController { Autowired private Sm2Utils sm2Utils; GetMapping(/publicKey) public AjaxResult getPublicKey() { String publicKeyHex sm2Utils.getPublicKeyHex(); return AjaxResult.success(publicKeyHex); } }这个接口通常不需要认证但可以考虑加入简单的限流或缓存机制避免被恶意刷取。5. 完整流程串联与测试现在让我们把前后端串联起来走一遍完整的登录流程并看看测试中需要注意什么。5.1 端到端流程梳理页面加载用户打开登录页前端Vue组件初始化调用/auth/publicKey接口获取SM2公钥并存储在内存中。输入与提交用户输入用户名、密码点击登录。前端加密前端JavaScript调用sm2.doEncrypt(明文密码, 公钥)将密码加密成16进制密文。发起请求前端将用户名、密文密码、验证码等参数通过HTTPS POST请求发送到后端登录接口如/login。后端解密后端SysLoginService接收到请求从LoginBody中取出密文密码调用Sm2Utils.decrypt(密文)使用配置的私钥进行解密得到明文密码。密码验证Spring Security使用PasswordEncoder如BCrypt将解密得到的明文密码进行哈希与数据库中存储的哈希值进行比对。认证与响应密码正确则生成Token并返回登录成功密码错误或解密失败则返回登录失败信息。5.2 测试要点与常见问题排查测试不能只测“正确路径”更要覆盖各种异常和边界情况。测试用例设计测试场景前端操作后端预期检查点正常登录输入正确用户/密码登录成功返回Token前端收到Token并跳转密码错误输入错误密码登录失败提示“用户名或密码错误”解密成功但密码比对失败公钥获取失败模拟网络错误前端提示“无法获取安全密钥”登录按钮应禁用或提示加密失败注入非法公钥前端控制台报错表单阻止提交应有try-catch和用户提示传输密文被篡改使用代理工具修改请求体中的密文后端解密失败登录失败日志应记录解密异常重复登录请求快速连续点击登录后端正常处理防重放需额外机制业务逻辑不受影响常见问题排查清单解密失败Invalid point encoding 或 Not a valid point原因前后端公私钥不匹配。这是最常见的问题。排查确认后端用于解密的私钥是否与生成前端所用公钥的私钥是同一对。重新生成一对密钥并确保前后端同步更新。检查后端配置的私钥字符串格式是否正确是否包含了多余的换行符或空格。可以使用在线的SM2加解密工具用你的公钥加密一个字符串再用你的私钥解密先验证密钥对本身是否有效。解密失败Malformed input原因前端传给后端的密文格式不对。可能是加密输出编码不是16进制或者在传输过程中被意外处理如URL编码二次转义。排查在前端加密后用console.log打印出密文的前几十个字符看看是否是04开头的长16进制字符串。在后端接收到请求后立即打印loginBody.getPassword()对比是否一致。确保HTTP客户端如axios没有对请求数据做额外的转换。前端加密报错publicKey is not valid原因前端加载的公钥格式不符合sm-crypto的要求。排查检查/auth/publicKey接口返回的数据。是否是一个完整的、没有多余引号的字符串是否是以04开头长度是否为130位65字节的16进制表示可以通过浏览器开发者工具的Network面板查看原始响应。性能问题登录响应变慢原因SM2加解密是比对称加密如AES或哈希如BCrypt更耗时的操作但单次登录的耗时增加通常应在可接受范围几十毫秒。排查如果明显变慢如超过200ms检查后端私钥加载逻辑是否在每次解密时都重新初始化SM2对象。应确保Sm2Utils是单例SM2对象只初始化一次。另外检查服务器CPU负载。安全性遗漏HTTPS核心原则SM2加密解决了密码在传输过程中即使被截获也无法解密的问题但它不能替代HTTPS。HTTPS提供了通道加密、服务器身份认证和防篡改。没有HTTPS攻击者虽然解不开密码但可以实施中间人攻击窃取会话Token或篡改请求。生产环境必须启用HTTPS。6. 进阶考量与最佳实践基本的集成完成后我们可以从安全性和工程化角度思考一些更深入的问题。6.1 防重放攻击Replay Attack目前的方案有一个潜在风险攻击者虽然不能解密密码但他可以截获完整的登录请求包括密文密码然后原封不动地重复发送给服务器服务器会认为是合法用户在登录这就是重放攻击。解决方案加入随机数Nonce或时间戳。前端在加密前将密码和一个随机数或当前时间戳拼接。例如encrypt(password | timestamp)。后端解密后拆分出密码和时间戳。检查时间戳是否在合理的窗口期内如5分钟内。同时可以将使用过的随机数或时间戳缓存一段时间如Redis防止同一登录请求被重复使用。这样即使请求被截获也因时间戳过期或随机数已使用而失效。6.2 密钥轮转与多版本支持私钥长期不换也存在风险。需要设计密钥轮转机制。双密钥支持后端可以同时配置两对密钥当前密钥和旧密钥。解密时先用当前密钥尝试失败再用旧密钥尝试。前端适配公钥接口可以返回多个公钥标识。前端在加密时可以指定使用哪个版本的公钥例如在请求头或密文中附带版本号。这样在轮转期间新旧版本的客户端都可以正常登录。轮转流程生成新密钥对 - 后端部署新私钥和公钥 - 前端分批升级或热更新公钥 - 观察一段时间后下线旧密钥。6.3 与现有密码编码器的协同若依默认使用BCrypt密码编码器它将用户注册时的明文密码进行哈希后存入数据库。我们的SM2集成是在传输层进行的不影响存储层。注册/修改密码前端同样需要用SM2公钥加密新密码传到后端。后端解密后再用BCrypt进行哈希然后存入数据库。密码强度校验应在后端解密后、BCrypt哈希前对明文密码进行强度规则校验如长度、复杂度而不是在前端进行。6.4 监控与日志安全相关的操作必须有清晰的日志但要注意避免记录敏感信息。日志记录记录解密成功/失败的事件不记录密码明文或完整密文记录公钥获取次数。例如“用户[admin]登录SM2解密成功”或“SM2解密失败可能原因密文格式错误”。监控报警如果短时间内出现大量解密失败请求可能意味着有攻击者在尝试伪造或篡改密文应触发安全报警。7. 总结与个人心得这套方案从设计到落地花了不少时间调试尤其是密钥格式和前后端对齐那部分。走通之后感觉整个登录过程的安全水位确实提升了一个档次。最大的体会是密码学应用不仅仅是调用几个API更重要的是理解流程中每个环节的“为什么”。比如为什么选择非对称加密而不是在前端做哈希因为前端哈希即使加盐无法防御重放攻击而SM2加密每次结果都不同。为什么一定要用HTTPS配合因为SM2只保护了密码这一个数据HTTPS保护了整个通信链路和会话安全。在具体实现上有三点心得特别想分享 第一密钥管理是生命线。私钥的存储配置文件、环境变量、密钥管理服务、访问权限、以及如何安全地分发给运维和部署系统这些环节的重要性不亚于代码本身。我们最终是把私钥放在了配置中心并且只有特定的应用有读取权限。第二异常处理要友好但严谨。前端加密失败不能把具体的加密库错误抛给用户而是提示“安全模块初始化失败”后端解密失败日志里要详细记录便于排查但返回给前端的只是“登录信息异常”避免信息泄露。第三测试一定要充分。除了正常流我们模拟了网络延迟下公钥获取超时、前端公钥被意外修改、后端私钥文件损坏等各种边缘情况。这些测试帮我们提前发现了几个潜在的问题比如在公钥获取失败时登录按钮没有正确禁用可能导致用户反复提交。最后技术选型没有银弹。SM2国密集成增加了系统的复杂性也带来了一定的性能开销虽然很小。如果你的项目没有明确的国密合规要求使用成熟的RSA方案可能更省心。但如果有合规需求或者就是对自主可控有追求那么趟平SM2这条路是非常有价值的。它不仅仅是一个加密算法的更换更是一次对应用安全传输层的深度梳理和加固。