前后端分离登录密码加密实践:JSEncrypt+RSA+Java完整方案
简介一份面向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 完整数据流我做的这套流程跑通后是这个样子后端服务启动时加载或生成RSA密钥对公钥、私钥都保存好。前端页面初始化或用户点击登录时请求后端的/api/publicKey接口拿到公钥字符串和密钥长度。用户输入密码后前端用JSEncrypt执行公钥加密得到Base64格式的密文。前端把密文放进请求体提交给后端登录接口。后端取出密文用私钥解密得到明文再走原有的登录校验逻辑。公钥是公开数据接口不需要加登录鉴权但要考虑网关层对匿名接口的放行配置否则会陷入没有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 MapString, 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(); MapString, 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 { MapString, String keyPair RsaUtils.generateKeyPair(); this.publicKey keyPair.get(publicKey); // privateKey 可以放入配置中心或数据库示例省略 } GetMapping(/publicKey) public MapString, Object publicKey() { MapString, 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); // 分段上限 密钥字节数 - 11PKCS#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 KEY和BEGIN PRIVATE KEY。如果你用OpenSSL生成的BEGIN RSA PRIVATE KEY那是PKCS#1格式Java标准库不直接支持需要引入Bouncy Castle才能解析。前后端约定好使用标准格式能省掉一大堆问题。4.2 后端Base64工具别用错了类老项目里经常看到sun.misc.BASE64DecoderJDK 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 大体积数据用RSAAES混合加密RSA有个天然短板性能差、有长度限制。如果加密对象是表单提交的整包数据或者是一段较长的备注文本直接用RSA分段加密不是不行而是性能浪费严重。工程上的标准方案是混合加密前端随机生成一个AES密钥和IV。用AES-GCM加密业务数据得到业务密文。用RSA公钥加密AES密钥和IV。把业务密文和加密后的AES密钥一起提交给后端。后端先用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或混合加密再配合签名和时间戳做业务安全。这样才算把一层层的洞都堵上。踩过几次坑之后我现在做这类加解密联调的习惯是后端先把密钥对生成、解密、分段解码写成一个工具类并配上单元测试前端再把公钥拉取、转码、分段加密封装成一个通用函数。前后端各自单测通过后再连起来联调而不是一上来就把两端代码同时跑起来瞎试。这个顺序能帮你把排查范围缩到最小遇到问题也能快速定位是前端的锅还是后端的锅。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →