前言
本文旨在给出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加解密工具及辅助类
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
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;}}
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
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;}@Overridepublic String toString() {return new ToStringBuilder(this).append("publicKey", publicKey).append("privateKey", privateKey).toString();} }
- 增加Rsa加密服务类,主要用于缓存密钥对
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
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;@Autowiredprivate 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;}}
- 增加加解密过滤器
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
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;@Overridepublic 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);}@Overridepublic 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()); // } }}@Overridepublic void destroy() {Filter.super.destroy();} }
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
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()*/@Overridepublic BufferedReader getReader() throws IOException {return new BufferedReader(new StringReader(requestBody));}/*** (non-Javadoc)** @see javax.servlet.ServletRequestWrapper#getInputStream()*/@Overridepublic ServletInputStream getInputStream() throws IOException {return new ServletInputStream() {private InputStream in = new ByteArrayInputStream(requestBody.getBytes(req.getCharacterEncoding()));@Overridepublic int read() throws IOException {return in.read();}@Overridepublic boolean isFinished() {// TODO Auto-generated method stubreturn false;}@Overridepublic boolean isReady() {// TODO Auto-generated method stubreturn false;}@Overridepublic void setReadListener(ReadListener readListener) {// TODO Auto-generated method stub }};} }
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
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的方法*/@Overridepublic ServletOutputStream getOutputStream() throws IOException {return out;}/*** 重载父类获取writer的方法*/@Overridepublic PrintWriter getWriter() throws UnsupportedEncodingException {return writer;}/*** 重载父类获取flushBuffer的方法*/@Overridepublic void flushBuffer() throws IOException {if (out != null) {out.flush();}if (writer != null) {writer.flush();}}@Overridepublic 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;}@Overridepublic void write(int b) throws IOException {bos.write(b);}@Overridepublic void write(byte[] b) throws IOException {bos.write(b, 0, b.length);}@Overridepublic boolean isReady() {// TODO Auto-generated method stubreturn false;}@Overridepublic void setWriteListener(WriteListener writeListener) {// TODO Auto-generated method stub }} }
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
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 {@Beanpublic 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;}}
- 开放公钥获取接口
@Anonymous @GetMapping("/publicKey") public String publicKey() throws NoSuchAlgorithmException, NoSuchProviderException {RsaKeyPair rsaKeyPair = cryptoService.getRsaKeyPair();return rsaKeyPair.getPublicKey(); }
以上大体就是整个实现代码了, 糙是糙了点,将就用。
3 改进建议
- 现有代码密钥对更新周期为1天, 有一个Bug, 如果刚好卡点,会形成使用上一次的公钥加密数据,解密时为新生成的私钥,导致解密失败。解决思路:缓存一个以上密钥对,解密的时候按密钥对生成时间倒序匹配,当然密钥对数量根据更新周期来定,性能上损耗较大。 也可以在请求头中增加密钥对时间,通过时间直接获取指定私钥解密。要不要改进这一点,视情况而定。 比如我这里,我就不改,在页面报错提示上优化"您的页面已过期,请刷新", 这样刷新页面后客户端公钥也更新成了新的。
- 响应加密。如果需要对响应加密,则可使用两组密钥对来实现,客户端保存【公钥1】和【私钥2】,使用【公钥1】加密请求参数,使用【私钥2】解密响应。响应加密有实例代码,已注释。 使用响应加密有前提,需要充分考虑响应内容,比如响应文件,图片等内容就不太合适,酌情考虑使用。