前后端分离的项目我做过不少,登录认证这块也踩过很多坑。早年在某公司做内部管理系统时,因为登录接口被同事抓包发现密码明文传输,直接被安全团队点名整改——那会儿用户密码就是明文走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 + JWT,JDK1.8。先看工程依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mindrot</groupId> <artifactId>jbcrypt</artifactId> <version>0.4</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency> <dependency> <groupId>commons-codec</groupId> <artifactId>commons-codec</artifactId> <version>1.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 Result<String> getPublicKey() { return Result.success(rsaKeyService.getPublicKey()); } @PostMapping("/login") public Result<LoginVO> 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 class="login-container"> <el-form :model="loginForm" @submit.prevent="handleLogin"> <el-form-item label="用户名"> <el-input v-model="loginForm.username" placeholder="请输入用户名" /> </el-form-item> <el-form-item label="密码"> <el-input v-model="loginForm.password" type="password" placeholder="请输入密码" show-password /> </el-form-item> <el-button type="primary" :loading="loading" @click="handleLogin"> 登 录 </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,后端从请求体里取这一个字段就足够了。可以让后端接收一个通用请求对象EncryptedRequest:
public 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 用户身份认证加密机制就是一套真正经得起安全评审和生产考验的登录方案。