前后端RSA加密登录完整流程:从密钥生成到Token返回

发布时间:2026/10/21 23:08:29
前后端RSA加密登录完整流程:从密钥生成到Token返回 写这篇东西的起因很直接之前做“某跨平台系统”的登录模块时被安全评审一直追问“密码是不是明文传输”。虽然当时项目里已经用了HTTPS但内网环境的HTTP调用依然存在而且就算有HTTPS也不该把密码裸奔着交出去。后来我花了一个下午把前后端RSA加密登录流程完整做了一遍从密钥生成、接口设计到前端加密、后端解密、Token返回全部实测通过。这篇就围绕这个流程把完整代码和设计思路都摊开讲给同样被安全评审卡住的人一个可以直接抄作业的方案。1. 为什么登录密码一定要做RSA加密1.1 明文传输的隐患我们先说个最常见的场景前端登录页里用户输入用户名和密码点击登录请求体直接是{username:admin,password:123456}就这么裸着发出去了。如果走的是HTTP中间任何一层路由器、交换机、代理服务器都能把这个包抓下来密码等于直接送人。就算走了HTTPS在客户端本地的抓包工具、调试面板、第三方统计脚本面前密码依然有可能是可见的——因为HTTPS只保护传输链路不保护客户端内部的数据流动。有人会说我用HTTPS不就行了吗这个想法没错但不够。HTTPS解决的是“传输过程中被窃听”的问题而RSA加密解决的是“即使数据到了服务端之前的任何环节也看不到明文”的问题。两层叠加才能做到即使日志里不小心打了请求体、或者网关层做日志采集时抓到了完整请求密码也不会泄露。安全这东西不是造一堵墙就能完事的最好是每层都加点防御纵深防御才是正路。1.2 为什么选RSA而不是AES或MD5先把几个常见方案摆出来做对比方案加密方式密钥管理安全性适用场景MD5散列不可逆无密钥有彩虹表和暴力破解风险密码存储不能用于传输AES对称加密密钥只有一把前端也得知道密钥一旦泄露全部完蛋适合大数据量加密RSA非对称加密公钥加密、私钥解密密钥分离公钥公开无风险小数据量加密适合密码传输MD5那类散列算法本质上不是加密是不可逆的摘要。它只能用来做密码存储时的比对没法让服务端“还原出密码再校验”。AES虽然性能好但对称加密最大的问题在于前端加密用的密钥必须给到前端而这个密钥一旦通过任何方式被攻击者拿到整个加密体系就形同虚设。RSA是非对称加密前端只需要拿到公钥公钥是公开的即使被攻击者获取也没关系因为私钥只保存在服务端。用公钥加密的数据只有私钥能解开。这就解决了前面说的核心痛点密码从客户端发出后全程以密文形态存在服务端用私钥解密后立即校验并丢弃密文即使在传输过程中被截获没有私钥也解不开。当然RSA的性能较差不适合加密大量数据但登录密码这种几十个字节的数据2048位密钥的RSA效率完全能扛住一次解密基本在几毫秒级别。1.3 整体流程设计整个登录加密流程我画了一条链路大家对照这个理解前端打开登录页先向后端发起请求获取RSA公钥后端生成或获取密钥对返回公钥字符串Base64格式前端拿到公钥后用JSEncrypt库对密码进行加密前端将用户名和加密后的密码一起提交到登录接口后端接收到请求用私钥对加密密码进行解密得到明文密码后端用解密后的明文密码与数据库中的BCrypt哈希进行比对校验通过生成Token返回给前端前端保存Token用于后续请求设计上有一条关键原则私钥只在服务端永远不出服务端。前端只需要公钥字符串。这一点从密钥生成的第一步就要严格遵守后面的代码也都是围绕这条原则展开的。2. 后端基础准备与密钥管理2.1 依赖引入后端基于SpringBoot 2.7.x除了基础Web依赖还需要引入JWT库用于登录成功后生成令牌。我用的JWT依赖是jjwt 0.9.1虽然版本老一点但胜在稳定API也简单没有那些花里胡哨的配置。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt/artifactId version0.9.1/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependencyRSA加解密用的是JDK原生的java.security包不需要额外引入第三方库。这里有个小注意点JDK自带RSA实现中Cipher.getInstance(RSA)默认使用的是RSA/ECB/PKCS1Padding这个和前端JSEncrypt默认的加密方式是一致的。如果后端指定成RSA/ECB/OAEPWithSHA-256AndMGF1Padding前端的JSEncrypt也要对应调整不然后端解密会直接抛BadPaddingException。这个我在后面“常见问题”里详细展开。2.2 RSA工具类工具类负责四件事生成密钥对、公钥转字符串、私钥转字符串、字符串还原密钥。这里关键是编码方式统一用Base64前端的JSEncrypt库对Base64格式的公钥接收度最好不需要额外转什么PEM格式头。import java.security.KeyFactory; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.PrivateKey; import java.security.PublicKey; import java.security.spec.PKCS8EncodedKeySpec; import java.security.spec.X509EncodedKeySpec; import java.util.Base64; public class RsaUtils { private static final int KEY_SIZE 2048; public static KeyPair generateKeyPair() throws Exception { KeyPairGenerator generator KeyPairGenerator.getInstance(RSA); generator.initialize(KEY_SIZE); return generator.generateKeyPair(); } public static String getPublicKeyStr(PublicKey publicKey) { return Base64.getEncoder().encodeToString(publicKey.getEncoded()); } public static String getPrivateKeyStr(PrivateKey privateKey) { return Base64.getEncoder().encodeToString(privateKey.getEncoded()); } public static PublicKey restorePublicKey(String publicKeyStr) throws Exception { byte[] keyBytes Base64.getDecoder().decode(publicKeyStr); X509EncodedKeySpec spec new X509EncodedKeySpec(keyBytes); KeyFactory keyFactory KeyFactory.getInstance(RSA); return keyFactory.generatePublic(spec); } public static PrivateKey restorePrivateKey(String privateKeyStr) throws Exception { byte[] keyBytes Base64.getDecoder().decode(privateKeyStr); PKCS8EncodedKeySpec spec new PKCS8EncodedKeySpec(keyBytes); KeyFactory keyFactory KeyFactory.getInstance(RSA); return keyFactory.generatePrivate(spec); } public static String encrypt(String data, PublicKey publicKey) throws Exception { Cipher cipher Cipher.getInstance(RSA); cipher.init(Cipher.ENCRYPT_MODE, publicKey); byte[] encryptedBytes cipher.doFinal(data.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(encryptedBytes); } public static String decrypt(String encryptedData, PrivateKey privateKey) throws Exception { byte[] encryptedBytes Base64.getDecoder().decode(encryptedData); Cipher cipher Cipher.getInstance(RSA); cipher.init(Cipher.DECRYPT_MODE, privateKey); byte[] decryptedBytes cipher.doFinal(encryptedBytes); return new String(decryptedBytes, StandardCharsets.UTF_8); } }这里有个算法细节值得说明KeyPairGenerator.getInstance(RSA)生成密钥对时PublicKey.getEncoded()返回的是X.509格式的SubjectPublicKeyInfoPrivateKey.getEncoded()返回的是PKCS#8格式的PrivateKeyInfo。还原的时候必须用对应的密钥规格类X509EncodedKeySpec对应公钥PKCS8EncodedKeySpec对应私钥搞混了会直接抛InvalidKeySpecException。2.3 密钥对管理策略密钥对生成之后存哪里、多久换一次是很多初学者容易忽略的问题。我项目里的方案是应用启动时生成一次保存在内存中。这样实现最简单性能也最好因为不需要每次请求都重新生成密钥对。RSA生成密钥对的过程比较耗时2048位密钥生成约需要几十到几百毫秒如果每个用户每次登录都触发重新生成并发高的时候CPU会有明显压力。import org.springframework.stereotype.Service; import javax.annotation.PostConstruct; import java.security.KeyPair; import java.security.PrivateKey; import java.security.PublicKey; Service public class RsaKeyService { private PublicKey publicKey; private PrivateKey privateKey; PostConstruct public void init() throws Exception { KeyPair keyPair RsaUtils.generateKeyPair(); this.publicKey keyPair.getPublic(); this.privateKey keyPair.getPrivate(); } public String getPublicKeyStr() { return RsaUtils.getPublicKeyStr(publicKey); } public PrivateKey getPrivateKey() { return privateKey; } }但这种方案有个隐患私钥长时间不变如果被泄露历史上所有截获的密文都能被解开。所以建议在配置里加一个开关支持定期重新生成密钥对。简单一点的做法是增加一个定时任务比如每小时重新生成一次密钥对并更新内存引用。更精细的做法是基于分布式缓存保存密钥对每个密钥对关联一个keyId前端获取公钥时带上keyId登录时提交keyId后端根据keyId选择对应的私钥解密。这个思路我放在文章最后讲进阶时再展开。3. 后端核心接口实现3.1 获取公钥接口后端接口设计遵循RESTful风格一个GET接口返回公钥一个POST接口处理登录请求。公钥接口无鉴权因为公钥本身就是公开的不需要保护。import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; RestController RequestMapping(/api/auth) public class AuthController { private final RsaKeyService rsaKeyService; public AuthController(RsaKeyService rsaKeyService) { this.rsaKeyService rsaKeyService; } GetMapping(/publicKey) public ResultString getPublicKey() { return Result.success(rsaKeyService.getPublicKeyStr()); } }这里返回的Result是一个统一响应体包含code、message、data三个字段。为了方便读者直接用我把它的定义放在下面import lombok.Data; Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.code 200; result.message success; result.data data; return result; } public static T ResultT error(String message) { ResultT result new Result(); result.code 500; result.message message; return result; } }3.2 登录接口解密逻辑登录接口接收两个参数用户名和加密后的密码。注意这里接收的字段名是encryptedPassword因为密码已经是密文了名字要体现出来避免别人看代码时产生误解。import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; RestController RequestMapping(/api/auth) public class AuthController { private final RsaKeyService rsaKeyService; private final UserService userService; private final JwtUtil jwtUtil; public AuthController(RsaKeyService rsaKeyService, UserService userService, JwtUtil jwtUtil) { this.rsaKeyService rsaKeyService; this.userService userService; this.jwtUtil jwtUtil; } GetMapping(/publicKey) public ResultString getPublicKey() { return Result.success(rsaKeyService.getPublicKeyStr()); } PostMapping(/login) public ResultLoginResponse login(RequestBody LoginRequest loginRequest) { try { // 1. 从请求中获取加密后的密码 String encryptedPassword loginRequest.getEncryptedPassword(); // 2. 服务端用私钥解密得到明文密码 String password RsaUtils.decrypt(encryptedPassword, rsaKeyService.getPrivateKey()); // 3. 内部校验解密出来的密码不能为空 if (password null || password.isEmpty()) { return Result.error(密码不能为空); } // 4. 校验用户名密码这里用了BCrypt比对 User user userService.checkLogin(loginRequest.getUsername(), password); // 5. 校验失败 if (user null) { return Result.error(用户名或密码错误); } // 6. 校验通过生成JWT Token返回 String token jwtUtil.generateToken(user.getId(), user.getUsername()); LoginResponse response new LoginResponse(); response.setToken(token); response.setUsername(user.getUsername()); return Result.success(response); } catch (Exception e) { // 解密失败或者内部异常统一返回 return Result.error(登录失败请稍后重试); } } }3.3 登录请求体和响应体请求体就是一个简单的POJOimport lombok.Data; Data public class LoginRequest { private String username; private String encryptedPassword; }响应体包含Token和基本信息import lombok.Data; Data public class LoginResponse { private String token; private String username; }3.4 UserService与密码校验UserService里的checkLogin是核心校验逻辑。数据库中保存的密码不是明文也不是RSA加密结果而是BCrypt哈希。RSA解密得到的明文密码要通过BCryptPasswordEncoder.matches()方法跟数据库里的哈希比对。import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; Service public class UserService { private final UserMapper userMapper; private final BCryptPasswordEncoder passwordEncoder new BCryptPasswordEncoder(); public UserService(UserMapper userMapper) { this.userMapper userMapper; } public User checkLogin(String username, String rawPassword) { User user userMapper.findByUsername(username); if (user null) { return null; } // BCrypt比对注意不能用equals直接比 if (passwordEncoder.matches(rawPassword, user.getPasswordHash())) { return user; } return null; } }这里有个容易踩的坑绝对不能用解密后的明文直接跟数据库里的密码做equals比较。数据库里存的是BCrypt哈希每次哈希的盐都是随机生成的同样的密码两次BCrypt的结果完全不同。BCryptPasswordEncoder.matches()会从哈希中提取盐重新计算再比对。如果数据库里存的是明文那说明早期系统设计就有问题需要先做密码迁移方案。3.5 JwtUtil登录成功后返回的Token用JWT生成包含了用户ID和用户名设置过期时间为24小时。这里我用一个简单的JwtUtil工具类封装了生成和解析两个方法。import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import java.util.Date; Component public class JwtUtil { Value(${jwt.secret}) private String secret; Value(${jwt.expire-hours}) private Long expireHours; public String generateToken(Long userId, String username) { Date now new Date(); Date expireDate new Date(now.getTime() expireHours * 3600 * 1000); return Jwts.builder() .setSubject(username) .claim(userId, userId) .setIssuedAt(now) .setExpiration(expireDate) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } public Claims parseToken(String token) { return Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); } }对应的配置文件里加上两行配置jwt: secret: your-secret-key-please-change-in-production expire-hours: 24注意生产环境里这个secret一定要换成足够长的随机字符串不要用配置文件里的默认值。HS256算法的密钥长度至少要32个字节太短的话有被暴力破解的风险。4. 前端加密实现4.1 引入JSEncrypt前端我选的是JSEncrypt库这是RSA加密在前端应用最广泛的库底层基于RSA算法实现API非常简单。可以通过npm方式安装到Vue/React项目里也可以直接引CDN的脚本文件到静态页面中。npm install jsencrypt或者在HTML中直接引用script srchttps://cdn.jsdelivr.net/npm/jsencrypt3.3.2/bin/jsencrypt.min.js/script我用的是Vue 3 Axios的项目结构下面这段代码放在src/utils/rsa.js里作为公共加密方法方便各个登录页面复用。import JSEncrypt from jsencrypt /** * 使用RSA公钥加密字符串 * param {string} publicKey Base64编码的公钥 * param {string} content 明文内容 * returns {string} Base64编码的密文 */ export function rsaEncrypt(publicKey, content) { const encryptor new JSEncrypt() encryptor.setPublicKey(publicKey) return encryptor.encrypt(content) }4.2 登录页核心逻辑登录页面拿到用户输入的用户名密码后先调公钥接口拿公钥再用公钥加密密码最后调登录接口。import axios from axios import { rsaEncrypt } from ../utils/rsa async function handleLogin(username, password) { // 1. 获取RSA公钥 const publicKeyRes await axios.get(/api/auth/publicKey) const publicKey publicKeyRes.data.data // 2. 用公钥加密密码 const encryptedPassword rsaEncrypt(publicKey, password) // 3. 提交登录 const loginRes await axios.post(/api/auth/login, { username: username, encryptedPassword: encryptedPassword }) // 4. 登录成功保存Token if (loginRes.data.code 200) { localStorage.setItem(token, loginRes.data.data.token) // 跳转到首页 window.location.href /dashboard } else { alert(loginRes.data.message) } }这就是一个非常标准的加密登录流程。前端做的事情比起后端来说简单很多核心就是“拿公钥-加密-提交”三步。但这里有一个细节需要注意加密后的密文是Base64格式的字符串里面可能包含、/、等字符这些字符在传输过程中如果被URL编码处理会产生变化。Axios默认的请求体是JSON格式application/json不会出现URL编码所以这里没有大问题。但如果读者用表单提交方式application/x-www-form-urlencoded就必须对密文做encodeURIComponent处理否则Base64里的字符到后端会变成空格导致解密失败。4.3 纯HTML静态页面版本有的小项目没有用前端框架就是一个单独的HTML页面加jQuery这时候直接用CDN方式引JSEncrypt更省事。!DOCTYPE html html langzh-CN head meta charsetUTF-8 title登录/title script srchttps://cdn.jsdelivr.net/npm/jsencrypt3.3.2/bin/jsencrypt.min.js/script /head body input typetext idusername placeholder用户名 input typepassword idpassword placeholder密码 button onclickhandleLogin()登录/button script async function handleLogin() { const username document.getElementById(username).value const password document.getElementById(password).value // 1. 获取公钥 const resp await fetch(/api/auth/publicKey) const result await resp.json() const publicKey result.data // 2. 加密密码 const encryptor new JSEncrypt() encryptor.setPublicKey(publicKey) const encryptedPassword encryptor.encrypt(password) // 3. 提交登录 const loginResp await fetch(/api/auth/login, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ username: username, encryptedPassword: encryptedPassword }) }) const loginResult await loginResp.json() if (loginResult.code 200) { localStorage.setItem(token, loginResult.data.token) alert(登录成功) } else { alert(loginResult.message) } } /script /body /html5. 完整登录流程串联演示5.1 一次登录请求的完整时序我用实际测试的结果来描述一下整个流程中的关键数据形态。第一步前端请求GET /api/auth/publicKey后端返回{ code: 200, message: success, data: MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoJ... }这个data就是Base64编码的公钥字符串长度一般在300-400字符之间。第二步前端用这把公钥加密密码123456得到的密文长这样Base64格式这里截取开头部分WpGNg5mKx2LcZphK8xJkHdJ...9KQ密文长度是256个字节的Base64编码固定长度为344个字符左右和明文长度无关。第三步前端提交{ username: admin, encryptedPassword: WpGNg5mKx2LcZphK8xJkHdJK...9KQ }第四步后端用私钥解密得到明文密码123456利用BCrypt跟数据库比对校验通过后返回{ code: 200, message: success, data: { token: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsInVzZXJJZCI6MSwiZXhwIjoxNzI4MDAwMDAwfQ.xxxxxx, username: admin } }5.2 用抓包工具验证加密效果流程跑通后我习惯性地用抓包工具看一眼实际发出的请求内容。打开浏览器的开发者工具切到Network面板提交一次登录请求可以看到/api/auth/login请求的Payload区域显示的是encryptedPassword那一长串密文而不是明文密码。到这一步就可以截图丢给安全评审了。这里顺便说一个排查技巧如果在Network面板里看到的密码还是明文不用怀疑肯定是前端加密逻辑没生效。常见原因有三个一是公钥没设置成功JSEncrypt实例是空的二是加密方法调了但返回值是false说明公钥格式有问题三是请求体字段写错了后端接收的还是明文那个字段。排查的时候先在浏览器Console里打印一下encryptedPassword的值如果是false基本就是公钥没设置对。5.3 齐备的代码结构为了让读者更直观地了解项目结构我把整个后端代码的目录结构列出来src/main/java/com/example/demo/ ├── DemoApplication.java ├── common/ │ └── Result.java ├── config/ │ └── CorsConfig.java ├── controller/ │ └── AuthController.java ├── service/ │ ├── RsaKeyService.java │ └── UserService.java ├── util/ │ ├── RsaUtils.java │ └── JwtUtil.java └── entity/ ├── User.java ├── LoginRequest.java └── LoginResponse.java前端如果是Vue项目结构参考src/ ├── utils/ │ └── rsa.js ├── api/ │ └── auth.js └── views/ └── Login.vue6. 常见问题与排查技巧实录6.1 报错“javax.crypto.BadPaddingException: Decryption error”这个报错是RSA解密时最经典的问题出现频率极高。原因基本锁定在三个方面第一前端加密用的公钥和后端解密用的私钥不是同一对。这种情况多发生在前后端联调时前端访问的是测试环境A的公钥接口登录请求却打到了测试环境B。排查方法很简单把前端Console里打印的公钥字符串跟后端日志里打印的公钥字符串比对一下如果不一致就是环境串了。第二前端加密算法的填充方式和后端解密算法的填充方式不一致。JSEncrypt默认用的是RSA/ECB/PKCS1Padding如果后端代码里写的是Cipher.getInstance(RSA/ECB/OAEPWithSHA-256AndMGF1Padding)那两边就不匹配。要么后端也改回默认的RSA要么前端换对应的加密库配置保持一致最稳妥。第三密文在传输过程中被篡改或截断。比如前端加密结果是abcdef如果走的是表单提交且没做URL编码到后端会变成空格Base64解码直接失败。解决办法就是加encodeURIComponent或者把传输格式统一为JSON。6.2 前端JSEncrypt加密返回falseJSEncrypt的encrypt()方法在加密失败时会返回false而不是抛异常。如果你在Console里看到了false不需要怀疑别的地方就是公钥格式不对。最常见的坑是后端返回的Base64字符串没有-----BEGIN PUBLIC KEY-----这个头部。JSEncrypt的setPublicKey()方法对纯Base64字符串的兼容不太稳定。解决办法有两个一是在前端拼上PEM头尾二是后端直接返回带PEM格式的公钥。我个人更倾向后端返回纯Base64然后前端在加密前把它包装成标准PEM格式function formatPublicKey(publicKey) { // 如果已经是PEM格式直接返回 if (publicKey.indexOf(BEGIN PUBLIC KEY) ! -1) { return publicKey } // 按64字符换行 const formatted publicKey.match(/.{1,64}/g).join(\n) return -----BEGIN PUBLIC KEY-----\n${formatted}\n-----END PUBLIC KEY----- }然后在rsaEncrypt方法里先调用formatPublicKey再setPublicKey加密稳定性会好很多。6.3 RSA加密长度限制问题RSA 2048位密钥的加密能力是有限制的最多只能加密(2048/8) - 11 245字节的数据。其中11字节是PKCS1Padding占用的空间。如果加密内容超过了245字节doFinal()就会抛IllegalBlockSizeException。登录密码通常不会超过245字节所以这个限制在密码场景下基本不会触发。但如果你打算把整个登录请求体比如包含验证码、设备指纹等都加密那就要考虑分段加密方案了。分段加密的思路是把明文按117字节加密时/256字节解密时切块逐块加密/解密再拼接结果。因为篇幅原因这里不展开代码但核心逻辑就是循环处理。我给读者的建议是设计传输协议时明确只加密密码等敏感字段不要偷懒把整个请求体加密否则又引入分段加密的复杂度得不偿失。6.4 密钥对需要多久轮换一次这是一个值得思考的安全策略问题。密钥对长期不变意味着攻击者一旦窃取到私钥历史所有加密流量全部暴露。我在项目中采用的建议方案是短期项目、内部系统应用启动时生成一天一换即可长期项目、高安全要求每次用户访问登录页时动态生成密钥对用Redis缓存设置5分钟过期key为随机UUID集群部署公钥接口和登录接口可能分发到不同节点此时必须用Redis共享密钥对或者用一个专用的密钥服务统一管理动态密钥对的实现思路并不复杂公钥接口生成一个uuid把uuid - privateKey存到Redis设置过期时间返回公钥时带上uuid。登录时前端把uuid和加密密码一起提交后端根据uuid从Redis取出私钥解密用完删除。这套方案能让每次登录使用的密钥都不同安全性上一个台阶。6.5 前端加密后每次结果为什么都不一样这不是Bug是RSA的PKCS1Padding机制决定的。同样的公钥加密同样的明文每次得到的密文都不同因为填充时包含随机字节。这个特性其实是安全性的体现——即使攻击者截获了两次相同密码的加密结果也无法推断出任何信息。有些初学者以为加密结果不同就意味着解密会失败实际上完全不影响解密端只关心最终解出来的明文是否一致。7. 高级进阶防重放与综合安全策略7.1 防重放攻击RSA加密解决了密码明文暴露的问题但还有一个隐患如果攻击者截获了一次完整的加密请求他不需要解密直接把这个请求原样重发一遍服务端依然会认为是合法登录。这就是重放攻击。简单的防护手段是加时间戳。前端加密时除了密码再拼上一个当前时间戳格式如123456_1728000000000后端解密后校验时间戳是否在有效期内比如5分钟内。如果攻击者重放旧的请求时间戳早已过期直接被拒绝。更严谨的方案是加一次性Nonce。前端生成一个随机字符串和后端的时间戳一起加密后端解密后将Nonce存入Redis设置很短的有效期比如1分钟相同Nonce的请求直接拒绝。这套方案能有效防重放但实现复杂度也随之提升适合对安全性要求更高的系统。7.2 登录失败次数限制加密做得再完善也防不住暴力破解。攻击者用不同的密码反复尝试登录虽然每个密码都需要加密但加密本身不消耗攻击者太多精力。服务端必须要做登录失败次数限制。我的实现思路是以用户名和客户端IP作为维度在Redis中记录失败次数。连续失败5次后锁定该账号15分钟。锁定期间即使密码正确也拒绝登录。这能在很大程度上遏制脚本化运行密码字典攻击基本被挡在门外。7.3 与HTTPS的关系是二选一还是叠加经常有人问我都用RSA加密了还需要HTTPS吗答案是必须的。RSA加密只保护了请求体中的密码字段但请求中的用户名、Cookie、Token等敏感信息依然是明文传输的。HTTPS保护的是整个传输链路两者解决的问题不同不是替代关系。HTTPS防止的是“传输链路上的中间人窃听”RSA防止的是“服务端或网关环节的数据泄露”。用个不太严谨但好理解的比喻HTTPS是保险箱在运钞车里的安全措施RSA是钱放进保险箱之前的另一把锁。两道锁各防各的风险点叠加使用才能构建完整的传输安全体系。7.4 密码传输加密只是开始坦白说登录传输加密在整个账号安全体系中只是很小的一环。真正要做好登录安全至少还要考虑密码存储用BCrypt并且每次哈希随机加盐登录接口设置限流同一个IP的请求频率不能无限制敏感操作如改密、支付需要二次校验不能只依赖一个TokenToken的过期时间不宜太长并且服务端要有主动吊销Token的能力这些是一个完整的登录安全方案必须具备的要素。RSA加密解决了“传输过程中密码暴露”这一个点但不要因为解决了这一个点就觉得安全已经做到位了。安全建设从来不是靠某一个单一方案一劳永逸的而是每一层都做到位最终织成一张防御网。最后聊几句实操体会这套RSA加密登录流程我在多个项目里实践过踩过的坑也不少。个人最大的体会是凡是加密传输类需求前后端联调时一定要把“密文打印”这件事做好。前端Console里打印加密结果后端日志里打印收到的密文和解密后的明文注意明文日志在测试环境可以生产环境千万别干一旦不对链路里到底哪一环出了问题一眼就能定位。还有一个小建议改造现有系统的登录流程前先和后端同事确认好数据库里存的密码是不是BCrypt哈希如果不是得先做密码迁移方案不然登录验证环节会有兼容问题。改造过程中保持旧登录接口短暂兼容等新流程稳定后再下线旧接口风险会小很多。这套代码我按照实际项目标准整理过直接Copy到工程里改改包名和字段就能跑通。如果你是第一次接触RSA登录加密建议先完整跑一遍流程再用抓包工具看看请求内容最后再去研究密钥轮换和防重放的扩展方案。这样一步一个脚印比直接啃大而全的框架要学得扎实。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询