前言
本文旨在给出Springboot+Vue 框架下的加密通信具体实现,同时为照顾非行业内/初学读者,第一小节浅显的解释下加解密方式,老鸟直接跳过。
1 加解密方式
常见的加解密方式大概分成对称加密、非对称加密与信息摘要算法三类。下面仅从使用角度简单介绍下加解密方式:
1.1 对称加密
采用单钥密码的加密方法,同一个密钥可以同时用来加密和解密,这种加密方法称为对称加密,也称为单密钥加密。加解密过程如下:
1.2 非对称加密
非对称加密是一种使用公钥和私钥对的加密方式,可以安全地公开公钥。通俗点理解,区别于对称加密的单密钥,非对称加密使用公钥-私钥密钥对,公钥仅用于加密,私钥解密。加解密过程如下:
1.3 信息摘要算法
摘要算法又称哈希算法,它表示输入任意长度的数据,输出固定长度的数据,相同的输入数据始终得到相同的输出,不同的输入数据尽量得到不同的输出,主要用于校验数据的完整性。
2 Springboot+Vue RSA加密通信实现
博主老抓娃,Spring全家桶用的飞起,短平快项目直接上ruoyi,但是碰上需要加密通信的场景,若依提供的加密通信方式还真是短平快,稍微太简单了点,故给他强化一下。
2.1 前后端加密通信过程
流程上大致如图,页面请求公钥 >> 页面使用公钥进行加密 >> 提交请求 >> 服务端接收并使用私钥解密请求数据 >> 服务端执行业务逻辑 >> 服务端返回业务 >> 页面接收响应结果并处理。当然在这个流程里面还有很多可优化的点,比如:服务器端生成密钥对采用周期更新策略提高安全性;缓存密钥对提升性能;生成两组密钥对用于响应加密;针对客户端生成密钥对进一步提升安全性等等改进措施。这些改进点完全基于业务的需求度,并且需要充分考虑使用性能,越复杂的逻辑必然消耗越多的资源。话不多说,下面上代码,毕竟no code no bb。
2.2 实现代码
2.2.1 Vue前端代码
- 添加依赖包,别问我为啥用这个,只要RSA加密方法支持数据分段加密都行,毕竟提交的内容长度不确定。
- 获取公钥
// 增加api,必须能够匿名访问 import request from "@/utils/request"; // 获取公钥 export function getPublicKey() { return request({ url: "/common/publicKey", method: "get", }); } // 在合适的位置调用这个上面的getPublicKey方法,并保存publicKey, 毕竟不刷新页面就不用再次请求服务器获取公钥,比如我直接卸载App.vue的created()里面。 // 这个cache.local 你们就随意,只要存储到localstorage里面就行,后面要取 created() { // 查询公钥并更新 getPublicKey().then((resp) => { this.$cache.local.set("publicKey", resp); }); },
- 创建jsencrypt.js
1 import JSEncrypt from "jsencrypt/bin/jsencrypt.min"; 2 // 加密 3 export function encrypt(txt, publicKey) { 4 const encryptor = new JSEncrypt(); 5 encryptor.setPublicKey(publicKey); // 设置公钥 6 return encryptor.encrypt(txt); // 对数据进行加密 7 } 8 9 // 解密 10 export function decrypt(txt, privateKey) { 11 const encryptor = new JSEncrypt(); 12 encryptor.setPrivateKey(privateKey); // 设置私钥 13 return encryptor.decrypt(txt); // 对数据进行解密 14 }
- 增加requset拦截器
// 这里只展示关键代码,至于怎么写具体根据项目自身情况,只要在请求提交之前处理即可 import axios from "axios"; import cache from "@/plugins/cache"; import { encrypt} from "@/utils/jsencrypt"; const service = axios.create({ // axios中请求配置有baseURL选项,表示请求URL公共部分 baseURL: process.env.VUE_APP_BASE_API, // 超时 timeout: 10000, }); // request拦截器 service.interceptors.request.use( (config) => { // .... 省略 let requestBody = typeof config.data === "object" ? JSON.stringify(config.data) : config.data; // 选择加密,这里根据api的header标记来决定是否加密,当然也可以不要,看项目情况 if (config.headers["encryption"] == true) { config.data = encrypt(requestBody, cache.local.get("publicKey")); } return config; }, (error) => { // 省略... });
需要加密的api方法,增加header["encryption"]=true 即可实现灵活配置。
2.2.2 后端代码
- 增加Rsa加解密工具及辅助类
package xxxx; import org.apache.commons.codec.binary.Base64; import org.apache.commons.lang3.ArrayUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.crypto.Cipher; import java.io.ByteArrayOutputStream; import java.net.URLDecoder; import java.net.URLEncoder; import java.security.*; import java.security.interfaces.RSAPrivateKey; import java.security.interfaces.RSAPublicKey; import java.security.spec.PKCS8EncodedKeySpec; import java.security.spec.X509EncodedKeySpec; /** * RSA加密解密 * * @author carliels **/ public class RsaUtils { private static final Logger logger = LoggerFactory.getLogger(RsaUtils.class); /*** RSA最大加密明文大小 */ private static final int MAX_ENCRYPT_BLOCK = 117; /*** RSA最大解密密文大小 */ private static final int MAX_DECRYPT_BLOCK = 128; private RsaUtils() { } /** * 公钥加密 * * @param str * @param publicKey * @return * @throws Exception */ public static String encrypt(String str, String publicKey) throws Exception { byte[] decoded = Base64.decodeBase64(publicKey); RSAPublicKey pubKey = (RSAPublicKey) KeyFactory.getInstance("RSA") .generatePublic(new X509EncodedKeySpec(decoded)); Cipher cipher = Cipher.getInstance("RSA"); cipher.init(1, pubKey); // 分段加密 // URLEncoder编码解决中文乱码问题 byte[] data = URLEncoder.encode(str, Constants.UTF8).getBytes(Constants.UTF8); // 加密时超过117字节就报错。为此采用分段加密的办法来加密 byte[] enBytes = null; for (int i = 0; i < data.length; i += MAX_ENCRYPT_BLOCK) { // 注意要使用2的倍数,否则会出现加密后的内容再解密时为乱码 byte[] doFinal = cipher.doFinal(ArrayUtils.subarray(data, i, i + MAX_ENCRYPT_BLOCK)); enBytes = ArrayUtils.addAll(enBytes, doFinal); } String outStr = Base64.encodeBase64String(enBytes); return outStr; } /** * 私钥分段解密 * * @param str * @param privateKey * @return * @throws Exception */ public static String decrypt(String str, String privateKey) throws Exception { // 获取公钥 byte[] decoded = Base64.decodeBase64(privateKey); RSAPrivateKey priKey = (RSAPrivateKey) KeyFactory.getInstance("RSA") .generatePrivate(new PKCS8EncodedKeySpec(decoded)); Cipher cipher = Cipher.getInstance("RSA"); cipher.init(2, priKey); byte[] data = Base64.decodeBase64(str.getBytes(Constants.UTF8)); // 返回UTF-8编码的解密信息 int inputLen = data.length; ByteArrayOutputStream out = new ByteArrayOutputStream(); int offSet = 0; byte[] cache; int i = 0; // 对数据分段解密 while (inputLen - offSet > 0) { if (inputLen - offSet > MAX_DECRYPT_BLOCK) { cache = cipher.doFinal(data, offSet, MAX_DECRYPT_BLOCK); } else { cache = cipher.doFinal(data, offSet, inputLen - offSet); } out.write(cache, 0, cache.length); i++; offSet = i * 128; } byte[] decryptedData = out.toByteArray(); out.close(); return URLDecoder.decode(new String(decryptedData, Constants.UTF8), Constants.UTF8); } /** * 构建RSA密钥对 * * @return 生成后的公私钥信息 */ public static RsaKeyPair generateKeyPair() throws NoSuchAlgorithmException, NoSuchProviderException { KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance("RSA"); keyPairGen.initialize(1024, new SecureRandom()); KeyPair keyPair = keyPairGen.generateKeyPair(); RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); String publicKeyString = Base64.encodeBase64String(publicKey.getEncoded()); String privateKeyString = Base64.encodeBase64String(privateKey.getEncoded()); RsaKeyPair rsaKeyPair = new RsaKeyPair(); rsaKeyPair.setPrivateKey(privateKeyString); rsaKeyPair.setPublicKey(publicKeyString); return rsaKeyPair; } }RsaUtils.java
package xxx; import org.apache.commons.lang3.builder.ToStringBuilder; import java.io.Serializable; /** * RSA 密钥对 * * @author carliels */ public class RsaKeyPair implements Serializable { /** * 公钥 */ private String publicKey; /** * 私钥 */ private String privateKey; public RsaKeyPair() { } public RsaKeyPair(String publicKey, String privateKey) { this.publicKey = publicKey; this.privateKey = privateKey; } public String getPublicKey() { return publicKey; } public RsaKeyPair setPublicKey(String publicKey) { this.publicKey = publicKey; return this; } public String getPrivateKey() { return privateKey; } public RsaKeyPair setPrivateKey(String privateKey) { this.privateKey = privateKey; return this; } @Override public String toString() { return new ToStringBuilder(this) .append("publicKey", publicKey) .append("privateKey", privateKey) .toString(); } }RsaKeyPair.java
- 增加Rsa加密服务类,主要用于缓存密钥对
package xxx; import xxx.Constants; import xxx.RedisCache; import xxx.RsaUtils; import xxx.RsaKeyPair; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import java.security.NoSuchAlgorithmException; import java.security.NoSuchProviderException; import java.util.concurrent.TimeUnit; /** * 加密服务 * * @author carliels */ @Slf4j @Component public class CryptoService { private static RsaKeyPair rsaKeyPair = null; @Autowired private RedisCache redisCache; /** * RSA 私钥解密 * * @param ciphertext * @return * @throws Exception */ public String decryptRsa(String ciphertext) throws Exception { RsaKeyPair rsaKeyPair = getRsaKeyPair(); return RsaUtils.decrypt(rsaKeyPair.getPrivateKey(), ciphertext); } /** * 获取RSA密钥对 * * @return */ public RsaKeyPair getRsaKeyPair() throws NoSuchAlgorithmException, NoSuchProviderException { if (rsaKeyPair != null) { redisCache.setCacheObject(Constants.RSA_KEY_PAIR, rsaKeyPair, 1, TimeUnit.DAYS); return rsaKeyPair; } rsaKeyPair = redisCache.getCacheObject(Constants.RSA_KEY_PAIR); if (rsaKeyPair == null) { rsaKeyPair = RsaUtils.generateKeyPair(); } redisCache.setCacheObject(Constants.RSA_KEY_PAIR, rsaKeyPair, 1, TimeUnit.DAYS); return rsaKeyPair; } }CryptoService.java
- 增加加解密过滤器
package xxx.filter; import xxx.HttpHelper; import xxx.RsaKeyPair; import xxx.RsaUtils; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.util.AntPathMatcher; import javax.servlet.*; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; /** * 通信加密 * * @author carliels */ @Slf4j public class CryptRequestFilter implements Filter { public static final String EXCLUDE = "exclude"; public static final String RSA_PUBLIC_KEY = "rsaPublicKey"; public static final String RSA_PRIVATE_KEY = "rsaPrivateKey"; private static final String ENCRYPTION_MARK = "Encryption"; private final AntPathMatcher pathMatcher = new AntPathMatcher(); private String[] excludePaths = new String[0]; private RsaKeyPair rsaKeyPair; @Override public void init(FilterConfig filterConfig) throws ServletException { String excludes = filterConfig.getInitParameter(EXCLUDE); if (org.apache.commons.lang3.StringUtils.isNotBlank(excludes)) { this.excludePaths = excludes.split(","); } String rsaPublicKey = filterConfig.getInitParameter(RSA_PUBLIC_KEY); String rsaPrivateKey = filterConfig.getInitParameter(RSA_PRIVATE_KEY); this.rsaKeyPair = new RsaKeyPair(rsaPublicKey, rsaPrivateKey); } @Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) servletRequest; // 路径过滤 String requestURI = request.getRequestURI(); boolean skip = false; for (String excludePath : excludePaths) { if (pathMatcher.match(excludePath, requestURI)) { skip = true; break; } } if (skip) { filterChain.doFilter(servletRequest, servletResponse); } else { HttpServletResponse response = (HttpServletResponse) servletResponse; String requestBody = HttpHelper.getBodyString(servletRequest); // 获取请求头加密标记 String encryptionMark = request.getHeader(ENCRYPTION_MARK); //解密请求报文 String requestBodyMw = requestBody; // 这里的依赖于前端给定标记进行解密处理,强制情况下应当只根据uri来判定 boolean encrypted = org.apache.commons.lang3.StringUtils.isNotBlank(encryptionMark) && Boolean.TRUE.toString().equalsIgnoreCase(encryptionMark); if (encrypted && StringUtils.isNotBlank(requestBody)) { try { requestBodyMw = RsaUtils.decrypt(requestBody, this.rsaKeyPair.getPrivateKey()); } catch (Exception e) { log.error("decrypt request body exception.", e); } } WrappedRequest wrapRequest = new WrappedRequest(request, requestBodyMw); filterChain.doFilter(wrapRequest, response); // if (!encrypted) { // filterChain.doFilter(wrapRequest, response); // } else { // WrappedResponse wrapResponse = new WrappedResponse(response); // byte[] data = wrapResponse.getResponseData(); // String responseBodyMw = null; // try { // responseBodyMw = RsaUtils.encrypt(data, this.rsaKeyPair.getPublicKey()); // } catch (Exception e) { // e.printStackTrace(); // } // System.out.println("加密返回数据: " + responseBodyMw); // response.addHeader("encrypt", "TRUE"); // response.getOutputStream().write(responseBodyMw.getBytes()); // } } } @Override public void destroy() { Filter.super.destroy(); } }CryptRequestFilter.java
package xxx.filter; import javax.servlet.ReadListener; import javax.servlet.ServletInputStream; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequestWrapper; import java.io.*; /** * @author carliels */ public class WrappedRequest extends HttpServletRequestWrapper { private String requestBody = null; HttpServletRequest req = null; public WrappedRequest(HttpServletRequest request) { super(request); this.req = request; } public WrappedRequest(HttpServletRequest request, String requestBody) { super(request); this.requestBody = requestBody; this.req = request; } /** * (non-Javadoc) * * @see javax.servlet.ServletRequestWrapper#getReader() */ @Override public BufferedReader getReader() throws IOException { return new BufferedReader(new StringReader(requestBody)); } /** * (non-Javadoc) * * @see javax.servlet.ServletRequestWrapper#getInputStream() */ @Override public ServletInputStream getInputStream() throws IOException { return new ServletInputStream() { private InputStream in = new ByteArrayInputStream( requestBody.getBytes(req.getCharacterEncoding())); @Override public int read() throws IOException { return in.read(); } @Override public boolean isFinished() { // TODO Auto-generated method stub return false; } @Override public boolean isReady() { // TODO Auto-generated method stub return false; } @Override public void setReadListener(ReadListener readListener) { // TODO Auto-generated method stub } }; } }WrappedRequest.java
package xxx.filter; import javax.servlet.ServletOutputStream; import javax.servlet.WriteListener; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponseWrapper; import java.io.*; /** * @author carliels */ public class WrappedResponse extends HttpServletResponseWrapper { private ByteArrayOutputStream buffer = null; private ServletOutputStream out = null; private PrintWriter writer = null; public WrappedResponse(HttpServletResponse resp) throws IOException { super(resp); // 真正存储数据的流 buffer = new ByteArrayOutputStream(); out = new WapperedOutputStream(buffer); writer = new PrintWriter(new OutputStreamWriter(buffer, this.getCharacterEncoding())); } /** * 重载父类获取outputstream的方法 */ @Override public ServletOutputStream getOutputStream() throws IOException { return out; } /** * 重载父类获取writer的方法 */ @Override public PrintWriter getWriter() throws UnsupportedEncodingException { return writer; } /** * 重载父类获取flushBuffer的方法 */ @Override public void flushBuffer() throws IOException { if (out != null) { out.flush(); } if (writer != null) { writer.flush(); } } @Override public void reset() { buffer.reset(); } /** * 将out、writer中的数据强制输出到WapperedResponse的buffer里面,否则取不到数据 */ public byte[] getResponseData() throws IOException { flushBuffer(); return buffer.toByteArray(); } /** * 内部类,对ServletOutputStream进行包装 */ private class WapperedOutputStream extends ServletOutputStream { private ByteArrayOutputStream bos = null; public WapperedOutputStream(ByteArrayOutputStream stream) throws IOException { bos = stream; } @Override public void write(int b) throws IOException { bos.write(b); } @Override public void write(byte[] b) throws IOException { bos.write(b, 0, b.length); } @Override public boolean isReady() { // TODO Auto-generated method stub return false; } @Override public void setWriteListener(WriteListener writeListener) { // TODO Auto-generated method stub } } }WrappedResponse.java
package com.casic.config; import xxx.StringUtils; import xxx.RsaKeyPair; import xxx.utils.spring.SpringUtils; import xxx.filter.CryptRequestFilter; import xxx.service.CryptoService; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import javax.servlet.DispatcherType; import java.security.NoSuchAlgorithmException; import java.security.NoSuchProviderException; import java.util.HashMap; import java.util.Map; /** * Filter配置 * * @author carliels */ @Configuration public class FilterConfig { @Bean public FilterRegistrationBean cryptRequestFilter() throws NoSuchAlgorithmException, NoSuchProviderException { CryptoService cryptoService = SpringUtils.getBean(CryptoService.class); RsaKeyPair rsaKeyPair = cryptoService.getRsaKeyPair(); FilterRegistrationBean registration = new FilterRegistrationBean(); registration.setFilter(new CryptRequestFilter()); registration.addUrlPatterns("/*"); Map<String, String> initParameters = new HashMap<String, String>(); initParameters.put(CryptRequestFilter.EXCLUDE, "/**/publicKey,/captchaImage"); initParameters.put(CryptRequestFilter.RSA_PUBLIC_KEY, rsaKeyPair.getPublicKey()); initParameters.put(CryptRequestFilter.RSA_PRIVATE_KEY, rsaKeyPair.getPrivateKey()); registration.setInitParameters(initParameters); registration.setOrder(FilterRegistrationBean.LOWEST_PRECEDENCE); return registration; } }FilterConfig.java
- 开放公钥获取接口
@Anonymous @GetMapping("/publicKey") public String publicKey() throws NoSuchAlgorithmException, NoSuchProviderException { RsaKeyPair rsaKeyPair = cryptoService.getRsaKeyPair(); return rsaKeyPair.getPublicKey(); }
以上大体就是整个实现代码了, 糙是糙了点,将就用。
3 改进建议
- 现有代码密钥对更新周期为1天, 有一个Bug, 如果刚好卡点,会形成使用上一次的公钥加密数据,解密时为新生成的私钥,导致解密失败。解决思路:缓存一个以上密钥对,解密的时候按密钥对生成时间倒序匹配,当然密钥对数量根据更新周期来定,性能上损耗较大。 也可以在请求头中增加密钥对时间,通过时间直接获取指定私钥解密。要不要改进这一点,视情况而定。 比如我这里,我就不改,在页面报错提示上优化"您的页面已过期,请刷新", 这样刷新页面后客户端公钥也更新成了新的。
- 响应加密。如果需要对响应加密,则可使用两组密钥对来实现,客户端保存【公钥1】和【私钥2】,使用【公钥1】加密请求参数,使用【私钥2】解密响应。响应加密有实例代码,已注释。 使用响应加密有前提,需要充分考虑响应内容,比如响应文件,图片等内容就不太合适,酌情考虑使用。