前后端分离登录密码加密实践:JSEncrypt+RSA+Java完整方案
2026/9/7 9:32:43 网站建设 项目流程

简介:一份面向Web开发者的前后端数据加密通信示例源码,用于解决登录、支付等敏感信息在HTTP传输过程中被窃取的问题。核心演示前端引入jsencrypt库,通过设置公钥并对输入数据进行RSA加密;后端则基于Java原生的java.security包,实现Base64密钥解析与私钥解密,前后端串联形成完整可运行的加解密闭环。资源包共4个文件,涵盖两个JavaScript文件、一个HTML页面和一个Java工具类,压缩包仅54KB,结构精简,便于快速导入项目理解RSA算法、密钥格式与填充模式等关键细节。已有3163人学习下载,适合希望用非对称加密提升接口安全性的初中级Java全栈开发者,既可对照调试学习,也能将核心代码迁移到实际业务中。 说实话,我这两年接手过不少后台管理系统,登录接口的密码用明文传输,一抓一个准。客户要求整改,核心诉求就一句话:密码不能裸奔。我用的方案是前端JSEncrypt用RSA公钥加密、后端Java用私钥解密。这套链路很成熟,但网上资料东一块西一块,尤其前后端联调时的细节,踩坑率极高。这篇文章就把完整实现、源码拆解和我在实际项目中遇到的坑一次说清楚,适合正在做前后端分离项目、需要给登录或敏感字段加应用层加密的开发者参考。

1. RSA加解密方案的前后端分工逻辑

1.1 公私钥的分工:保险柜和钥匙的关系

RSA是非对称加密,核心是一对密钥:公钥和私钥。你可以把公钥理解成一把锁,私钥是唯一能打开这把锁的钥匙。锁可以随便发给别人,钥匙必须自己保管好。

在前后端分离项目里,正确的分工是这样的:

  • 后端生成这对密钥,公钥通过接口下发,私钥只存在于后端服务中。
  • 前端拿着公钥加密敏感数据,例如密码、身份证号、手机号。
  • 后端用私钥解密密文,拿到原始数据。
  • 私钥一旦进入前端代码,整个方案就废了。JS是在浏览器里跑的,打包后的代码人家用开发者工具一翻就全出来了。所以私钥只允许留在后端。

这个方案解决的核心问题是:即使POST请求被第三方截获,或者被网关日志、监控系统记录,对方拿到的也只是密文,没有私钥无法还原明文。

1.2 完整数据流

我做的这套流程,跑通后是这个样子:

  1. 后端服务启动时加载或生成RSA密钥对,公钥、私钥都保存好。
  2. 前端页面初始化或用户点击登录时,请求后端的/api/publicKey接口,拿到公钥字符串和密钥长度。
  3. 用户输入密码后,前端用JSEncrypt执行公钥加密,得到Base64格式的密文。
  4. 前端把密文放进请求体提交给后端登录接口。
  5. 后端取出密文,用私钥解密得到明文,再走原有的登录校验逻辑。

公钥是公开数据,接口不需要加登录鉴权,但要考虑网关层对匿名接口的放行配置,否则会陷入"没有token拿不到公钥,没有公钥没法登录"的死循环。

1.3 jsencrypt在方案里的定位

JSEncrypt是GitHub上社区维护的RSA加解密库,底层封装了RSA算法,前端通过setPublicKey传入公钥,调用encrypt方法即可完成加密。它最大的特点就是简单,十几行代码就能跑通一条加密链路。

但也要清醒:JSEncrypt本身不负责密钥管理,也不负责分段加密,更不负责中文编码处理。这些都需要业务代码自己组装。很多项目上线后偶发解密失败,基本都是栽在这些边界问题上。所以下面后端、前端两部分代码,我会把这些问题一次性处理好。

2. 后端Java实现:生成密钥对、下发公钥、解密数据

2.1 密钥对生成:代码生成与持久化方案

后端我用Java标准库实现,不需要引入额外依赖。密钥生成工具类如下:

import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.PrivateKey; import java.security.PublicKey; import java.util.Base64; import java.util.HashMap; import java.util.Map; public class RsaUtils { private static final String ALGORITHM = "RSA"; private static final String TRANSFORMATION = "RSA/ECB/PKCS1Padding"; private static final int KEY_SIZE = 2048; public static Map<String, String> generateKeyPair() throws Exception { KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance(ALGORITHM); keyPairGenerator.initialize(KEY_SIZE); KeyPair keyPair = keyPairGenerator.generateKeyPair(); PublicKey publicKey = keyPair.getPublic(); PrivateKey privateKey = keyPair.getPrivate(); Map<String, String> keyMap = new HashMap<>(2); keyMap.put("publicKey", Base64.getEncoder().encodeToString(publicKey.getEncoded())); keyMap.put("privateKey", Base64.getEncoder().encodeToString(privateKey.getEncoded())); return keyMap; } }

这里有两个细节值得注意。

第一,getEncoded()拿到的公钥是X.509标准的SubjectPublicKeyInfo格式,私钥是PKCS#8标准的格式。用Base64编码成字符串后,方便存储和传输。私钥尽量不要硬编码在代码里,建议放到配置中心、环境变量或数据库表中,并通过权限控制访问。

第二,实际项目中,我强烈建议密钥对首次启动时生成并持久化,而不是每次请求公钥接口都重新生成。如果你每次页面刷新都生成一对新密钥,前端用刚拿到的公钥加密,提交回来后,后台可能已经换了一轮密钥,解密直接失败。这种偶发问题排查起来相当折磨。

2.2 公钥下发接口与密钥存储设计

公钥接口用Spring Boot实现,按JSON结构返回,把公钥和密钥长度一起给前端,方便前端计算分段上限:

import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.HashMap; import java.util.Map; @RestController @RequestMapping("/api") public class RsaController { private String publicKey; public RsaController() throws Exception { Map<String, String> keyPair = RsaUtils.generateKeyPair(); this.publicKey = keyPair.get("publicKey"); // privateKey 可以放入配置中心或数据库,示例省略 } @GetMapping("/publicKey") public Map<String, Object> publicKey() { Map<String, Object> result = new HashMap<>(); result.put("publicKey", publicKey); result.put("keySize", 2048); return result; } }

我在实际项目里会把私钥保存到配置中心,服务启动时读取,既保证重启后密钥不变化,也方便后续做密钥轮换。如果公司没有配置中心,保存在数据库加密表里也是一种可行的做法,但一定要控制好私钥的访问权限。

2.3 私钥解密核心代码

后端解密是这套方案的关键,代码不多,但每一步都不能错:

import javax.crypto.Cipher; import java.nio.charset.StandardCharsets; import java.security.KeyFactory; import java.security.PrivateKey; import java.security.spec.PKCS8EncodedKeySpec; import java.util.Base64; public class RsaUtils { public static String decrypt(String ciphertext, String privateKeyStr) throws Exception { byte[] keyBytes = Base64.getDecoder().decode(privateKeyStr); PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes); KeyFactory keyFactory = KeyFactory.getInstance(ALGORITHM); PrivateKey privateKey = keyFactory.generatePrivate(keySpec); Cipher cipher = Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.DECRYPT_MODE, privateKey); byte[] encryptedBytes = Base64.getDecoder().decode(ciphertext); byte[] decryptedBytes = cipher.doFinal(encryptedBytes); return new String(decryptedBytes, StandardCharsets.UTF_8); } }

几个关键点:

  • 前端传过来的密文是Base64字符串,必须先通过Base64.getDecoder().decode()还原成二进制字节,再交给Cipher解密。漏掉这一步,会报IllegalArgumentException: Input byte[] should at least have 2 bytes这类错误。
  • 填充模式使用RSA/ECB/PKCS1Padding,这是JSEncrypt默认的填充方式,两端必须保持一致。
  • Cipher不是线程安全的。如果用多线程并发解密,每个任务里单独创建Cipher实例,不要做成共享的静态实例,否则会出现偶发的BadPaddingException

2.4 分段解密:配合前端长文本加密

前端做分段加密时,后端这边需要对应分段解密。我约定的规则是:用英文逗号拼接多个密文段。Base64字符集是A-Za-z0-9+/=,不含逗号,所以用逗号做分隔符是安全的,不会和密文内容冲突。

import java.net.URLDecoder; public class RsaUtils { public static String decryptSegments(String ciphertext, String privateKeyStr) throws Exception { String[] segments = ciphertext.split(","); StringBuilder stringBuilder = new StringBuilder(); for (String segment : segments) { stringBuilder.append(decrypt(segment, privateKeyStr)); } return URLDecoder.decode(stringBuilder.toString(), StandardCharsets.UTF_8.name()); } }

我习惯在解密结束后统一做一次URLDecoder.decode,对应前端加密前的encodeURIComponent,这样中文、空格、特殊符号都不会出问题。这个处理方案的具体原因,后面联调章节会详细讲。

3. 前端接入jsencrypt:加密逻辑与分段策略

3.1 引入JSEncrypt的方式

两种方式都可以。

npm安装方式:

npm install jsencrypt

业务代码里引入:

import JSEncrypt from 'jsencrypt';

如果你用的还是传统的script标签页面,直接用CDN:

</script>

注意CDN的路径要指向bin/jsencrypt.min.js,有些教程给的是根目录文件,加载后控制台报JSEncrypt is not defined,就是这个路径问题。

3.2 获取公钥与缓存策略

前端先请求公钥接口,拿到公钥和密钥长度:

async function fetchPublicKey() { const response = await fetch('/api/publicKey'); const data = await response.json(); return { publicKey: data.publicKey, keySize: data.keySize }; }

公钥是公开的,变化频率极低,可以在页面加载后缓存到全局变量或sessionStorage里,避免每次登录都多一次网络请求。如果后端做了密钥轮换,前端拿到新公钥后刷新缓存即可。旧公钥加密的数据在轮换过渡期内可能解密失败,这时候提示用户刷新页面重试是最实际的方案。

3.3 基础加密函数

加密函数本身非常简单:

function rsaEncrypt(plaintext, publicKey) { const encryptor = new JSEncrypt(); encryptor.setPublicKey(publicKey); return encryptor.encrypt(plaintext); }

encrypt方法返回的是Base64编码的密文字符串。这里有一个很关键的点:如果加密失败,这个方法返回的是false,而不是抛异常。所以调用方必须对返回值做判断,不然你会拿着false往后端传,后端Base64解码直接报错。

3.4 长文本分段加密:117字节还是245字节

这是这套方案里最大的坑。RSA加密对明文长度有限制,因为要填充随机数。

以2048位密钥为例,RSA能加密的最大字节数是密钥位数 / 8 - 11。这个11字节是PKCS#1 v1.5填充的开销。2048位密钥就是256 - 11 = 245字节。如果前端用的是1024位密钥,上限是128 - 11 = 117字节。

关键问题是:中文字符在UTF-8编码下占3个字节,JavaScript的length计算的是UTF-16编码单元数量,直接按length切片会把汉字劈成两半,整段解密乱码。我这里的处理方式是先整体encodeURIComponent转码,把非ASCII字符全部变成%xx形式的ASCII安全字符串,再按字符数分段,这样切出来的每一段都不会出现多字节截断问题。

function segmentEncrypt(plaintext, publicKey, keySize = 2048) { // 先转码,确保中英文、特殊字符都变成ASCII安全字符串 const encoded = encodeURIComponent(plaintext); // 分段上限 = 密钥字节数 - 11(PKCS#1 v1.5填充开销) const maxLength = Math.floor(keySize / 8) - 11; const encryptor = new JSEncrypt(); encryptor.setPublicKey(publicKey); const segments = []; for (let i = 0; i < encoded.length; i += maxLength) { const chunk = encoded.slice(i, i + maxLength); const encrypted = encryptor.encrypt(chunk); if (!encrypted) { throw new Error(`分段加密失败,位置: ${i}`); } segments.push(encrypted); } return segments.join(','); }

使用时,建议把原始对象先JSON.stringify再加密,比如:

const payload = JSON.stringify({ password: password, timestamp: Date.now() }); const encryptedData = segmentEncrypt(payload, publicKey, keySize); fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ encryptedData }) });

后端拿到encryptedData后,按逗号分段解密再拼接,最后还原出JSON字符串。

4. 联调时真正容易卡住的五个细节

4.1 公钥格式和“public key not found”类报错

我在网上看到很多人在各种激活工具里遇到rsa public key not find报错,其实项目中这个报错一样很常见。前端调用encrypt返回false,或者控制台直接给出Error decoding public key,绝大多数情况是公钥字符串格式不对。

排查时按这个链路走:

第一步,看公钥是否带完整头尾。如果走固定密钥,我建议后端直接把公钥拼成PEM格式再返回:

public static String formatPublicKey(String base64Key) { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.append("-----BEGIN PUBLIC KEY-----\n"); int index = 0; while (index < base64Key.length()) { int end = Math.min(index + 64, base64Key.length()); stringBuilder.append(base64Key, index, end).append("\n"); index = end; } stringBuilder.append("-----END PUBLIC KEY-----"); return stringBuilder.toString(); }

第二步,检查接口返回过程中有没有被框架或网关改动。很多网关、日志组件会在响应里加引号、转义符或换行,肉眼明明看着正常,实际字符串已经不干净。建议在浏览器Network面板里查看原始响应原文。

第三步,确认密钥格式。Java标准库生成的是X.509公钥和PKCS#8私钥,对应BEGIN PUBLIC KEYBEGIN PRIVATE KEY。如果你用OpenSSL生成的BEGIN RSA PRIVATE KEY,那是PKCS#1格式,Java标准库不直接支持,需要引入Bouncy Castle才能解析。前后端约定好使用标准格式,能省掉一大堆问题。

4.2 后端Base64工具别用错了类

老项目里经常看到sun.misc.BASE64Decoder,JDK 1.8开始就不再推荐使用这种内部API了。它属于JDK内部类,在高版本JDK上运行时会直接报NoClassDefFoundError之类的类加载错误。

统一用java.util.Base64,它提供了getEncoder()getDecoder(),纯标准库,没有任何兼容性负担。我排查过好几个解密失败案例,最后发现既不是公钥问题也不是算法问题,就是老代码还在用sun.misc,换掉之后一次过。

4.3 中文乱码问题

解密成功后,最常见的现象是中文变成一串乱码。原因很简单:前端加密时没有做encodeURIComponent,后端拿到UTF-8字节后按平台默认字符集还原,Windows服务器最常见的默认字符集是GBK,两边一错位,中文必乱。

我统一的做法是:前端加密前先encodeURIComponent(plaintext),后端解密后执行URLDecoder.decode(decrypted, "UTF-8")。这样无论传输过程经过什么环节,原始内容都不会被破坏。这也解释了为什么前面分段加密代码里要先转码再切割。

4.4 密文长度变大,注意接口体积限制

RSA加密后密文长度会明显膨胀。2048位密钥加密一个分段,得到的密文Base64字符串长度是344个字符。如果一个加密内容分了4段,整个encryptedData字段就是将近1400个字符。

大部分场景下POST请求体足够容纳,但要注意两个隐蔽位置:

一是Nginx层级的client_max_body_size,默认只有1MB,正常情况下足够,但有些网关或BCD平台会配得比较小,导致请求在到达应用前就被拦截。二是Spring Boot的server.tomcat.max-http-form-post-size,默认是2MB,表单提交超过这个值会报错。用JSON格式提交时,一般不受这个参数影响。

另外,日志打印时千万别把完整密文打出来。分段的密文很长,会把日志刷得乱七八糟,而且密码类密文即使打出来是密文,也有泄露风险。打印时截断到前20个字符足矣。

4.5 升级密钥长度后分段长度没跟着改

这是最隐蔽的坑。我之前维护过一个老模块,密钥长度从1024位升级到2048位,前端分段加密的maxLength还是旧的117,结果数据一长,偶发出现解密失败。因为每段117字节这个限制是针对1024位密钥的,换成2048位密钥之后,单段最大长度已经变成245字节,但旧代码依然把内容切成117字节一段,倒是没超过上限,看起来一切正常,可一旦内容长度落在一个特殊区间,就会触发问题。

这类问题最好的解法,就是公钥接口把密钥长度一起返回,前端动态计算分段上限。后端升级密钥,前端代码一行都不用改。这是我觉得整篇方案里最值得采纳的设计。

5. 从能用走向好用:进阶方向与安全加固

5.1 大体积数据用RSA+AES混合加密

RSA有个天然短板:性能差、有长度限制。如果加密对象是表单提交的整包数据,或者是一段较长的备注文本,直接用RSA分段加密不是不行,而是性能浪费严重。

工程上的标准方案是混合加密:

  1. 前端随机生成一个AES密钥和IV。
  2. 用AES-GCM加密业务数据,得到业务密文。
  3. 用RSA公钥加密AES密钥和IV。
  4. 把业务密文和加密后的AES密钥一起提交给后端。
  5. 后端先用RSA私钥解出AES密钥,再用AES密钥解出业务数据。

AES是对称加密,处理大数据效率高出RSA几个数量级,而且没有长度限制。RSA在这里只保护AES密钥,相当于给AES密钥套了一层保险柜。这个思路和很多系统里“AES加密盐放后端”的做法一脉相承:密钥类敏感参数永远只存在于后端,前端只处理业务数据,不接触核心密钥。

5.2 签名防篡改和时间戳防重放

公钥加密解决的是数据明文泄露问题,但解决不了“别人也知道公钥,能往里塞密文”的问题。只要公钥是公开的,任何人都可以向后端发送一段合法的RSA密文。

要防这类攻击,需要引入两个机制:

一个是数字签名。调用方用私钥对请求参数生成签名,后端用公钥验签,确保请求来源可信。注意,数字签名和传输加密的方向正好相反:加密是公钥加密、私钥解密;签名是私钥签名、公钥验签。两个概念别混。

另一个是时间戳防重放。前端在加密原文里拼入当前时间戳,后端解密后校验时间偏差,超过5分钟的直接拒绝。这个方案能拦截掉大部分重放攻击,实现成本也很低,在原文里加一个字段就行。

5.3 密钥长度怎么选

业界对RSA密钥长度的态度越来越明确:1024位已经被很多安全规范明令禁止使用,2048位是当前主流,3072位提供更高安全边际,但加解密耗时会明显上升。我在移动端测试过,低端安卓手机上JSEncrypt加密3072位密钥加一个分段,能感觉到明显的卡顿。

我的建议是:默认2048位,等保、金融类场景需要更高要求时再上3072。同时密钥要有轮换机制。轮换时给新旧密钥留一个过渡期,比如前端公钥接口返回两个公钥,新公钥用于新请求,旧公钥保留一段时间让已打开页面能正常提交,避免线上大面积解密失败。

5.4 HTTPS和RSA各管一段,别互相替代

有个问题经常被问:都上HTTPS了,为什么还要在应用层做RSA加密?

实际项目里,HTTPS保护的是数据传输通道,但数据到达后端之后,可能会被Nginx access log、网关日志、链路追踪系统记录下来。POST body里如果有明文密码,这些日志系统就成了泄密口。应用层加密的价值就在这里:即使日志记录了完整请求体,拿到的也只是密文。

反过来,RSA加密替代不了HTTPS。它保护不了传输层,防不了流量分析,也防不了中间人抓包后的重放。正确姿势是:传输层依赖HTTPS,应用层对敏感字段做RSA或混合加密,再配合签名和时间戳做业务安全。这样才算把一层层的洞都堵上。


踩过几次坑之后,我现在做这类加解密联调的习惯是:后端先把密钥对生成、解密、分段解码写成一个工具类并配上单元测试,前端再把公钥拉取、转码、分段加密封装成一个通用函数。前后端各自单测通过后,再连起来联调,而不是一上来就把两端代码同时跑起来瞎试。这个顺序能帮你把排查范围缩到最小,遇到问题也能快速定位是前端的锅还是后端的锅。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询