前后端分离的项目我做过不少登录认证这块也踩过很多坑。早年在某公司做内部管理系统时因为登录接口被同事抓包发现密码明文传输直接被安全团队点名整改——那会儿用户密码就是明文走HTTP只要在局域网里开个抓包工具账号密码就裸奔了。后来我重构了整套认证流程核心方案就是 Spring Boot Vue 基于 RSA 的用户身份认证加密机制。这篇文章把这套方案的完整设计与实现细节拆开讲清楚从选型、后端实现、前端对接到联调时容易踩的坑全部记录下来。这个方案解决的核心问题很直接在前后端分离架构下如何保证用户密码从浏览器发出时不是明文如何让后端安全地还原密文并完成认证同时兼顾工程上的可维护性和安全基线。适合正在做登录模块、被安全评审要求加密传输的开发者参考也适合想理解非对称加密在真实业务中怎么落地的朋友。1. 登录加密方案问题拆解与选型背后的思考1.1 明文密码传输的隐患到底有多大很多人觉得内网系统、后端接口没暴露公网密码明文传输没多大问题。这个想法我也有过直到被真实打脸。即使是内网环境只要有人用 Wireshark 抓包或者在公司交换机上做一个镜像端口所有HTTP请求里的密码都是可读的。更别说现在前后端分离的项目通常还会部署到公网或者通过小程序、App 端共用同一套 API。密码明文传输带来的风险不只是密码泄露本身而是连锁反应。大部分人的密码习惯是多个平台复用同一个密码一旦某个系统的密码被拿到攻击者会拿这个密码去尝试其他平台。所以即使是一个不起眼的后台管理系统密码泄露也可能成为突破口。安全团队当时给我看的报告里写得清清楚楚明文密码属于高危漏洞整改方式就是传输加密。那为什么不用 MD5 加盐之后再传给后端严格来说这种方式防的是数据库泄露后反向破解但对传输过程本身没有任何保护。MD5 后的值仍然是固定值攻击者抓包拿到这个值直接重放请求后端根本区分不了这是原始密码还是摘要值。也就是说MD5 加盐解决的是存储安全不是传输安全。传输加密需要的是前端用某把密钥加密后端用对应的密钥解密。这就引出了密钥管理的选型问题。1.2 对称加密与非对称加密的取舍做传输加密最先想到的方案往往是 AES 对称加密。AES 加解密速度快后端生成一个 AES 密钥前端拿到这个密钥对密码加密后端用同一个密钥解密。但这里有个致命的问题AES 密钥怎么安全地交给前端如果通过接口返回密钥那么抓包的人同样能拿到这把 AES 密钥他就能自己解密请求里的密文甚至用这把密钥伪造请求。你等于把锁和钥匙一起交给了对方。有人会说那把密钥写死在前端代码里那更糟前端打包后的JS文件里直接能搜到密钥字符串这层加密就是心理安慰。非对称加密 RSA 的思路正好绕开了这个困境。RSA 密钥对分成公钥和私钥公钥公开没关系它能加密不能解密私钥只在后端保存用来解密。前端用后端下发的公钥加密密码就算攻击者抓到了公钥和密文没有私钥他也还原不出明文密码。RSA 在这个场景下是最契合的选型。虽然它加解密速度比 AES 慢但登录时只加密一个密码字段数据量很小性能完全不是瓶颈。而且 RSA 还有 AES 不具备的附加价值后端可以用私钥对令牌做签名前端用公钥验签同一套密钥体系可以服务多种安全场景。所以在登录认证场景下我直接用 RSA 而不是 AES。注意不要试图用 RSA 加密整个请求体RSA 有长度限制且性能不占优加密密码、手机号这类短字段最合适。请求体整体加密请考虑 AES RSA 混合方案。1.3 整体认证流程设计这套方案的核心流程如下前端用户打开登录页时先向后端发起获取公钥的请求后端生成 RSA 密钥对或从缓存取将公钥的 Base64 字符串返回给前端。前端拿到公钥后用 JSEncrypt 库对用户输入的密码进行加密得到一段 RSA 密文然后连同用户名一起提交到登录接口。后端登录接口从请求中取出密文用保存在服务端的 RSA 私钥解密还原出明文密码再走原有的用户名密码校验逻辑。校验通过后后端生成一个认证令牌实际项目中可以用 JWT返回给前端。前端保存令牌后续请求在请求头中携带这个令牌实现会话保持。流程上看只是多了“获取公钥”和“密码加密”两个环节但安全效果完全不同。密码在整个传输链路上都是密文状态只有后端私钥能解密。2. 后端 Spring Boot 核心实现密钥生成、解密接口与登录认证2.1 工程结构与依赖准备我用的技术栈是 Spring Boot 2.7 MyBatis-Plus JWTJDK1.8。先看工程依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mindrot/groupId artifactIdjbcrypt/artifactId version0.4/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt/artifactId version0.9.1/version /dependency dependency groupIdcommons-codec/groupId artifactIdcommons-codec/artifactId version1.15/version /dependency项目结构上我单独抽了一个crypto包用来放 RSA 相关代码和业务代码隔离。因为 RSA 工具类将来可能会在多个模块复用独立成包方便维护。核心类就三个RsaKeyService负责密钥生命周期管理、RsaUtils加解密静态工具、LoginController登录接口。2.2 RSA 工具类的关键细节分段加密RSA 工具类是整套方案的基石。这里有一个非常关键的细节RSA 加密对明文长度有限制。使用 1024 位密钥时最多只能加密 117 字节使用 2048 位密钥时最多只能加密 245 字节。登录密码通常只有几十字节但为了后续扩展比如加密手机号、身份证号我统一实现分段加解密。public class RsaUtils { private static final String ALGORITHM RSA; private static final String TRANSFORMATION RSA/ECB/PKCS1Padding; private static final int KEY_SIZE 2048; // RSA/ECB/PKCS1Padding模式下2048位密钥最多加密245字节 private static final int MAX_ENCRYPT_BLOCK 245; // 解密对应最长支持256字节 private static final int MAX_DECRYPT_BLOCK 256; public static KeyPair generateKeyPair() throws NoSuchAlgorithmException { KeyPairGenerator keyPairGenerator KeyPairGenerator.getInstance(ALGORITHM); SecureRandom secureRandom new SecureRandom(); keyPairGenerator.initialize(KEY_SIZE, secureRandom); return keyPairGenerator.generateKeyPair(); } public static String encryptByPublicKey(String data, String publicKeyString) throws Exception { byte[] dataBytes data.getBytes(StandardCharsets.UTF_8); byte[] keyBytes Base64.decodeBase64(publicKeyString); PublicKey publicKey KeyFactory.getInstance(ALGORITHM) .generatePublic(new X509EncodedKeySpec(keyBytes)); // 对数据分段加密 ByteArrayOutputStream out new ByteArrayOutputStream(); try { Cipher cipher Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.ENCRYPT_MODE, publicKey); int inputLen dataBytes.length; int offSet 0; byte[] cache; int i 0; while (inputLen - offSet 0) { if (inputLen - offSet MAX_ENCRYPT_BLOCK) { cache cipher.doFinal(dataBytes, offSet, MAX_ENCRYPT_BLOCK); } else { cache cipher.doFinal(dataBytes, offSet, inputLen - offSet); } out.write(cache, 0, cache.length); i; offSet i * MAX_ENCRYPT_BLOCK; } return Base64.encodeBase64String(out.toByteArray()); } finally { out.close(); } } public static String decryptByPrivateKey(String data, String privateKeyString) throws Exception { byte[] dataBytes Base64.decodeBase64(data); byte[] keyBytes Base64.decodeBase64(privateKeyString); PrivateKey privateKey KeyFactory.getInstance(ALGORITHM) .generatePrivate(new PKCS8EncodedKeySpec(keyBytes)); // 对数据分段解密 ByteArrayOutputStream out new ByteArrayOutputStream(); try { Cipher cipher Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.DECRYPT_MODE, privateKey); int inputLen dataBytes.length; int offSet 0; byte[] cache; int i 0; while (inputLen - offSet 0) { if (inputLen - offSet MAX_DECRYPT_BLOCK) { cache cipher.doFinal(dataBytes, offSet, MAX_DECRYPT_BLOCK); } else { cache cipher.doFinal(dataBytes, offSet, inputLen - offSet); } out.write(cache, 0, cache.length); i; offSet i * MAX_DECRYPT_BLOCK; } return out.toString(StandardCharsets.UTF_8.name()); } finally { out.close(); } } }几个容易出错的地方提醒一下前端加密后的密文是 Base64 字符串后端拿到后必须先 Base64 解码再交给 RSA 解密。很多人忘记这一步直接拿字符串去解报javax.crypto.BadPaddingException。密钥字符串存储的格式是公钥用X509EncodedKeySpec解析私钥用PKCS8EncodedKeySpec解析。搞混了会报InvalidKeySpecException。SecureRandom初始化 KeyPairGenerator 是必要的可以避免在某些 JDK 版本下生成弱随机密钥。2.3 RsaKeyService 密钥管理策略密钥管理这里我做了两个层面的设计。密钥对的生成时机服务启动时生成一次还是每次获取公钥接口被调用时重新生成如果每次请求公钥都重新生成密钥对那么上一个公钥加密的密文后端再用新私钥解密就会失败。如果前端同时打开了多个登录页拿到不同的公钥就会出现“明明输入的密码没错后端却提示密码解密失败”的诡异问题。我采用的方案是应用启动时生成一次密钥对缓存在内存中。获取公钥接口直接返回缓存中的公钥登录解密时使用缓存的私钥。这样所有前端拿到的都是同一把公钥后端私钥自始至终也是同一把。密钥在内存中不会持久化到磁盘应用重启后密钥对自然更换安全性也有保障。Service public class RsaKeyService { PostConstruct public void init() { try { KeyPair keyPair RsaUtils.generateKeyPair(); this.publicKey Base64.encodeBase64String(keyPair.getPublic().getEncoded()); this.privateKey Base64.encodeBase64String(keyPair.getPrivate().getEncoded()); } catch (NoSuchAlgorithmException e) { throw new RuntimeException(RSA密钥初始化失败, e); } } private String publicKey; private String privateKey; public String getPublicKey() { return publicKey; } public String decrypt(String cipherText) { try { return RsaUtils.decryptByPrivateKey(cipherText, privateKey); } catch (Exception e) { throw new RuntimeException(解密失败, e); } } }如果追求更强的安全性可以给密钥对设置一个有效期比如每隔 24 小时重新生成一次并让前端重新拉取公钥。但要注意兼容会话中已经使用旧公钥加密的请求实际项目中我建议先做固定密钥等稳定后再考虑轮换。2.4 获取公钥接口与登录接口实现获取公钥的接口很简单就是一个 GET 请求返回当前公钥字符串。但登录接口需要注意几个防御细节。RestController RequestMapping(/api/auth) public class LoginController { Autowired private RsaKeyService rsaKeyService; GetMapping(/publicKey) public ResultString getPublicKey() { return Result.success(rsaKeyService.getPublicKey()); } PostMapping(/login) public ResultLoginVO login(RequestBody LoginRequest request) { // 每次登录尝试前先检查失败次数防止暴力破解 if (loginAttemptService.isBlocked(request.getUsername())) { return Result.fail(尝试次数过多请稍后再试); } String publicKey rsaKeyService.getPublicKey(); String decryptPassword; try { decryptPassword rsaKeyService.decrypt(request.getPassword()); } catch (Exception e) { logger.warn(密码解密失败用户{}, request.getUsername()); return Result.fail(密码解密失败请刷新页面后重试); } // 用户名同样建议加密传输从密文中解密 String decryptUsername rsaKeyService.decrypt(request.getUsername()); User user userService.findByUsername(decryptUsername); if (user null || !passwordEncoder.matches(decryptPassword, user.getPassword())) { loginAttemptService.loginFailed(request.getUsername()); return Result.fail(用户名或密码错误); } // 登录成功后签发 JWT String token jwtUtil.createToken(user.getId(), user.getUsername()); return Result.success(new LoginVO(token, user.getUsername())); } }为什么用户名也要一起加密因为光加密密码、用户名明文传输攻击者虽然拿不到密码但可以收集系统里的有效用户名用户名本身就是一种敏感信息。登录时把用户名和密码一起加密前端可以拼接成username : password再加密后端解密后拆分这样其实更省事。不过要注意分隔符选择避免用户名或密码中含有的字符冲突。登录失败之后返回给前端的提示语无论失败原因是用户不存在还是密码错误统一返回“用户名或密码错误”避免泄露用户名是否存在。这个属于安全基线里的细节但很多人会忽略。2.5 密码存储的二次加固RSA 解决的是传输过程的问题但密码到了后端存储环节也不能裸奔。很多老系统用户表里的密码就是 MD5 或者 SHA1这种值可以查彩虹表直接还原等于把用户的明文密码变相暴露了。我在这个项目里对数据库存储也做了加固使用了 BCrypt 算法。BCrypt 的显著特点是每次哈希同一个字符串会得到不同的结果因为它内部自动加入随机盐。它还要一个成本因子参数默认 10可以配置成 12 让它计算更慢。这个慢其实是故意设计的目的就是拖慢攻击者的批量破解速度让暴力破解单个密码的成本大大增加。Component public class PasswordEncoder { public String encode(String rawPassword) { return BCrypt.hashpw(rawPassword, BCrypt.gensalt(12)); } public boolean matches(String rawPassword, String encodedPassword) { return BCrypt.checkpw(rawPassword, encodedPassword); } }登录流程在工程上还有一个性能上的坑RA 解密 BCrypt 校验两步计算加起来会比纯 MD5 校验慢一些用户基本感知不到。但要注意如果系统面临着极高的并发登录压力BCrypt 的慢反而会放大风险攻击者可以故意发起海量登录请求来消耗 CPU。所以限流措施是必须的至少要对登录接口做一个简单的并发限流。3. 前端 Vue 对接加密封装、登录调用与请求拦截3.1 依赖选择与封装思路Vue 前端做 RSA 加密我用的是jsencrypt这个库。它在浏览器端的实现很成熟封装了公钥加密和私钥解密的逻辑API 非常简单几行代码就能跑通。Vue2 直接 npm 安装后引入Vue3 也无缝支持。npm install jsencrypt安装之后我习惯在src/utils/rsa.js里单独封装一个加密工具而不是在页面组件里直接写加密逻辑。原因是将来如果有多个页面都要加密字段比如修改密码页、绑定手机页、支付密码页统一封装后一处修改、处处生效。import JSEncrypt from jsencrypt // 每次用前都要先初始化否则会丢失密钥 const encryptor new JSEncrypt() /** * 使用公钥加密 * param {String} content 明文内容 * param {String} publicKey Base64编码的公钥 * returns {String|false} 加密失败返回 false */ export function encryptByPublicKey(content, publicKey) { encryptor.setPublicKey(publicKey) return encryptor.encrypt(content) }注意encryptor定义为模块级常量而不是在函数内部 new 出来的是因为每次 new 都会重新生成内部状态性能开销不必要。但要注意在并发场景如果两个请求同时使用同一个 encryptor 实例可能互相覆盖密钥状态。在登录场景下并发加密的情况很少这个写法是安全的。3.2 获取公钥并缓存公钥获取接口的调用时机可以在每次登录前实时拉取也可以在应用启动时拉取一次缓存进 Vuex 或内存变量。我推荐登录页面加载时异步获取一次存到 store 里这样即使用户停留了很久再登录拿到的公钥也是最新的。// store/modules/auth.js const state { publicKey: , token: } const mutations { SET_PUBLIC_KEY(state, key) { state.publicKey key } } const actions { async fetchPublicKey({ commit }) { const res await request({ url: /api/auth/publicKey, method: get }) commit(SET_PUBLIC_KEY, res.data) return res.data } }3.3 登录页面完整实现登录页的提交逻辑是先确保公钥已经存在然后把用户名和密码拼接后加密再把加密后的字符串按字段名传给后端。这里我选择“用户名和密码拼接后一起加密”而不是分别加密因为 HSEncrypt 的 RSA 加密每次产生的密文不同分别加密两个短字符串会多两次加密开销而且拼在一起只调用一次加密出错的概率更小。template div classlogin-container el-form :modelloginForm submit.preventhandleLogin el-form-item label用户名 el-input v-modelloginForm.username placeholder请输入用户名 / /el-form-item el-form-item label密码 el-input v-modelloginForm.password typepassword placeholder请输入密码 show-password / /el-form-item el-button typeprimary :loadingloading clickhandleLogin 登 录 /el-button /el-form /div /template script import { encryptByPublicKey } from /utils/rsa import { mapState, mapActions } from vuex export default { name: LoginPage, data() { return { loginForm: { username: , password: }, loading: false } }, computed: { ...mapState({ publicKey: state state.auth.publicKey }) }, methods: { ...mapActions([fetchPublicKey]), async handleLogin() { if (!this.loginForm.username || !this.loginForm.password) { this.$message.warning(请输入用户名和密码) return } this.loading true try { let publicKey this.publicKey if (!publicKey) { publicKey await this.fetchPublicKey() } const content this.loginForm.username : this.loginForm.password const encrypted encryptByPublicKey(content, publicKey) if (!encrypted) { throw new Error(RSA 加密失败) } await this.$store.dispatch(auth/login, { encryptedData: encrypted }) this.$message.success(登录成功) this.$router.push(/dashboard) } catch (error) { this.$message.error(error.message || 登录失败请稍后重试) } finally { this.loading false } } }, mounted() { if (!this.publicKey) { this.fetchPublicKey() } } } /script登录接口层面对应地也需要调整参数结构。前端传的是encryptedData后端从请求体里取这一个字段就足够了。可以让后端接收一个通用请求对象EncryptedRequestpublic class EncryptedRequest { private String encryptedData; // getter setter 省略 }后端拿到encryptedData后解密得到username:password格式的字符串再按第一个冒号位置拆分成用户名和密码。3.4 后续请求的令牌携带与登出清理用户登录成功后拿到 JWT 令牌前端 axios 拦截器统一在请求头里加Authorization: Bearer token。这里有一个细节JWT 本身不是加密的它的 payload 部分 Base64 解码之后任何人有能看到里面的内容所以不要把敏感信息放到 JWT 里。我只放 userId 和 username 这类非敏感标识信息角色权限信息也从用户表里现查不放 JWT。service.interceptors.request.use( config { const token getToken() if (token) { config.headers[Authorization] Bearer token } return config }, error Promise.reject(error) )响应拦截器对 401 状态码做统一处理跳转到登录页并清理本地 token。这样即使令牌过期用户只需要重新登录前端不用在每个页面里单独写判断。4. 联调踩坑与问题排查实录4.1 高频报错对照表这套方案在实际联调阶段问题比我预想的多。下面这些是我总结的高频报错和对应的排查思路。现象原因解决方案后端报BadPaddingException密文被截断或前端加密后传递给后端时被 URL 编码/换行符破坏确认前端传递的是 Base64 完整字符串检查 JSON 序列化是否正常换行后端报InvalidKeySpecException密钥字符串格式不对或使用了错误的 KeySpec 解析类公钥用X509EncodedKeySpec私钥用PKCS8EncodedKeySpec解密出来是乱码加密前字符串的编码问题前端 UTF-8 后端也必须是 UTF-8两端统一使用UTF-8前端 JSEncrypt 报unreachable code或返回 false公钥字符串拷贝时带了空格或换行去掉所有空白字符确保公钥是纯净 Base64 串用户反馈偶尔登录失败刷新后好了服务端重启导致密钥对变化前端缓存了旧公钥在获取公钥接口返回中加入密钥版本号前端发现版本变化就刷新缓存密文长度非常长接口返回 400数据超过容器请求体限制检查 Nginxclient_max_body_size配置适当调大4.2 一个印象深刻的排查过程有一件事印象特别深刻。内部测试时同一个登录接口在 Chrome 上正常在微信内置浏览器里偶尔会报“密码解密失败”。查了很久忽然想到是微信浏览器的 URL 编码问题。微信浏览器对某些特殊字符会进行重新编码RSA 密文里含有、/、这些字符在传输过程中可能被转成空格导致后端 Base64 解码后的数据不完整。解决方案有两个一个是在前端加密后对密文做encodeURIComponent处理后端拿到后先URLDecoder.decode再 Base64 解码另一个是把 Base64 字符串里的/替换成 URL 安全的字符集即 Base64 URL 变体。我采用的是简单的前端encodeURIComponent 后端解码的方案改动量小兼容性也够好。4.3 容易被忽视的安全加固项RSA 传输加密不是终点它是整个登录安全链路里的一环。有几个地方容易被忽略我列一下第一获取公钥的接口必须做防刷限流。因为公钥是公开的攻击者可以无限次请求这个接口虽然没有直接危害但可以发起大量请求消耗服务端资源。我用了一个简单的计数限流比如 5 秒内超过 100 次请求就拒绝服务。第二登录接口必须配合图形验证码。RSA 加密挡住了密码明文泄露但挡不住恶意攻击者用自动化脚本拿密码字典暴力撞库。验证码或者滑块验证是必要的。我建议验证码接口放在获取公钥接口之后、登录接口之前独立存在不要和登录逻辑耦合。第三RSA 密钥必须妥善管理。开发环境下密钥可以放在内存里但生产环境建议配合配置中心和密钥管理服务去管理私钥至少不要把私钥写成常量放在代码仓库里。私钥泄露等于安全性归零这一点再怎么强调都不为过。第四整个登录认证链路建议配合 HTTPS。RSA 加密解决的是密码字段的保护但页面参数、JWT 令牌如果走明文 HTTP 传输依然可以被窃取。HTTPS 提供的是传输层的端到端加密RSA 提供的是应用层字段加密两层叠加才能互相补充。关于这套方案后续的扩展方向我现在维护的项目里已经把同样的 RSA 加密机制用在了修改密码、绑定手机号、重置密码等所有涉及敏感信息提交的场景。实际做下来前后端只需要复用已有的工具类和接口约定扩展成本很低。最后再分享一个小技巧建议把公钥获取接口和登录接口的响应都加上Cache-Control: no-store头。浏览器默认情况下对 GET 请求可能做缓存如果公钥被缓存在 CDN 中间层后端更新密钥后前端拿到的还是旧公钥排查起来会非常痛苦。踩过几次坑之后我对这套方案的总结就是非对称加密本身并不复杂复杂的是把它放在真实业务环境里考虑缓存、编码、兼容性、限流这些工程问题。只要把这些问题都处理干净RSA 用户身份认证加密机制就是一套真正经得起安全评审和生产考验的登录方案。