来源:blog.csdn.net/LiuCJ_20000/article/details/138902163
👉 欢迎加入小哈的星球,你将获得: 专属的项目实战 / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论
新项目:《从零手撸:仿小红书(微服务架构)》 正在持续爆肝中,基于 Spring Cloud Alibaba + Spring Boot 3.x + JDK 17..., 点击查看项目介绍; 《从零手撸:前后端分离博客项目(全栈开发)》 2期已完结,演示链接:http://116.62.199.48/; 截止目前,累计输出 69w+ 字,讲解图 2776+ 张,还在持续爆肝中.. 后续还会上新更多项目,目标是将 Java 领域典型的项目都整一波,如秒杀系统, 在线商城, IM 即时通讯,Spring Cloud Alibaba 等等,戳我加入学习,解锁全部项目,已有2400+小伙伴加入
在Web应用中,确保前后端之间的数据传输安全是非常重要的。这通常涉及到使用HTTPS协议、数据加密、令牌验证等安全措施。本文通过将前后端之间的传输数据进行加密,用于在Spring Boot应用中实现前后端传输加密设计。
一、数据加密方案
即使使用了HTTPS,也可能需要在应用层对数据进行额外的加密。这可以通过以下方式实现:
对称加密: 加密解密是同一个密钥,速度快,数据接收方需要公布其私钥给数据传输方进行数据加密,安全性完全依赖于该密钥。适合做大量数据或数据文件的加解密。
使用AES、DES等对称加密算法对敏感数据进行加密和解密。 前后端需要共享一个密钥(key)用于加密和解密。 密钥的管理和传输需要特别注意安全性。
非对称加密: 加密用公钥,解密用私钥。公钥和私钥是成对的(可借助工具生成,如openssl等),即用公钥加密的数据,一定能用其对应的私钥解密,能用私钥解密的数据,一定是其对应的公钥加密。对大量数据或数据文件加解密时,效率较低。数据接收方需公布其公钥给数据传输方,私钥自己保留,安全性更高。
使用RSA、ECC等非对称加密算法。 私钥用于加密数据,公钥用于解密数据。 公钥可以公开,而私钥需要安全存储。
混合加密
结合使用对称加密和非对称加密。 使用非对称加密算法交换对称加密的密钥(会话密钥),然后使用会话密钥进行实际的数据加密和解密。
这里就赘述介绍每种加密的实现方式和原理。
1.1 数据加密实现方式
如果数据传输较大,密钥不需要进行网络传输,数据不需要很高的安全级别,则采用对称加密,只要能保证密钥没有人为外泄即可; 如果数据传输小,而且对安全级别要求高,或者密钥需要通过internet交换,则采用非对称加密;
本文采用了两者结合的方式(混合加密模式),这样是大多数场景下采用的加密方式。加密时序图如下所示:
通过使用对称加密(AES) 和 非对称加密(RSA) 的方式来实现对数据的加密;即通过对称加密进行业务数据体的加密,通过非对称加密进行对称加密密钥的加密; 它结合了对称加密的高效性 和 非对称加密的安全性。
注意事项:
确保RSA公钥在传输过程中是安全的,因为任何拥有这个公钥的人都可以用它来加密AES密钥,但只有拥有私钥的人才能解密它。 确保在加密和解密过程中使用安全的加密库和最新的加密算法标准。 定期更换密钥对和对称密钥,以降低密钥泄露的风险。
这种混合加密模式提供了安全性和效率之间的平衡。对称加密(如AES)用于加密大量数据,因为它通常比非对称加密更快。而非对称加密(如RSA)用于加密密钥,因为它提供了更强的安全性,特别是当密钥需要在不安全的通道上传输时。
1.2 AES加密工具类创建
封装AESUtil工具类时 pom.xml 中运用到的依赖:
<!-- hutool-all工具类依赖 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.18</version>
</dependency>
AES加解密工具类 AESUtil 代码:
package com.example.api_security_demo.utils;
import cn.hutool.core.codec.Base64;
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.spec.SecretKeySpec;
import java.io.UnsupportedEncodingException;
import java.security.Key;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Random;
/**
* @ClassName : AESUtil
* @Description : AES加密工具类
* @Author : AD
*/
public class AESUtil {
public static final String CHAR_ENCODING = "UTF-8";
/**
* [常见算法]AES、DES、RSA、Blowfish、RC4 等等
* [常见的模式] ECB (电子密码本模式)、CBC (密码分组链接模式)、CTR (计数模式) 等等
* [常见的填充] NoPadding、PKCS5Padding、PKCS7Padding 等等
*
* [AES算法]可以有以下几种常见的值:
* AES:标准的AES算法。
* AES/CBC/PKCS5Padding:使用CBC模式和PKCS5填充的AES算法。
* AES/ECB/PKCS5Padding:使用ECB模式和PKCS5填充的AES算法。
* AES/GCM/NoPadding:使用GCM模式的AES算法,不需要填充。
* AES/CCM/NoPadding:使用CCM模式的AES算法,不需要填充。
* AES/CFB/NoPadding:使用CFB模式的AES算法,不需要填充。
* */
public static final String AES_ALGORITHM = "AES";
public static char[] HEXCHAR = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'};
/**
* Description: 随机生成 AESKey密钥
*
* @param length 随机生成密钥长度
* @return java.lang.String
*/
public static String getAESKey(int length) throws Exception
{
/*
* Random类用于生成伪随机数。
* */
Random random = new Random();
StringBuilder ret = new StringBuilder();
for(int i = 0; i < length; i++)
{
// 选择生成数字还是字符
boolean isChar = (random.nextInt(2) % 2 == 0);
/* 0随机生成一个字符*/
if (isChar)
{
// 选择生成大写字母 / 小写字母
int choice = (random.nextInt(2) % 2 == 0) ? 65 : 97;
ret.append((char) (choice+random.nextInt(26)));
/* 1随机生成一个数字 */
}else
{
ret.append( random.nextInt(10));
}
}
return ret.toString();
}
/**
* Description: 加密
*
* @param data 待加密数据内容
* @param aesKey 加密密钥
* @return byte[]
*/
public static byte[] encrypt(byte[] data,byte[] aesKey)
{
if (aesKey.length != 16)
{
throw new RuntimeException("Invalid AES key length (must be 16 bytes) !");
}
try{
/*
* 创建一个SecretKeySpec对象来包装AES密钥。
* 它使用了aesKey字节数组作为密钥,并指定算法为"AES"。
* 这个对象用来提供对称加密算法的密钥。
* */
SecretKeySpec secretKey = new SecretKeySpec(aesKey, "AES");
/*
* 获取SecretKeySpec对象中的编码形式,将其存储在encodedFormat字节数组中。
* 这个编码形式可以被用来重新构造密钥。
* */
byte[] encodedFormat = secretKey.getEncoded();
/*
* 使用encodedFormat字节数组创建了另一个SecretKeySpec对象secKey。
* 这个对象也用来提供对称加密算法的密钥。
* */
SecretKeySpec secKey = new SecretKeySpec(encodedFormat, "AES");
/*
* 使用Cipher类的getInstance()方法获取了一个Cipher对象(创建密码器)。
* 这个对象用来完成加密或解密的工作。
* */
Cipher cipher = Cipher.getInstance(AES_ALGORITHM);
/*
* 码调用init()方法来初始化Cipher对象(初始化)。
* 它要求传入操作模式和提供密钥的对象,这里使用Cipher.ENCRYPT_MODE代表加密模式,以及之前创建的secKey对象作为密钥。
* */
cipher.init(Cipher.ENCRYPT_MODE,secKey);
/*
* 用Cipher对象对data进行加密操作,得到加密后的结果存储在result字节数组中。
* */
byte[] result = cipher.doFinal(data);
return result;
}catch (Exception e){
throw new RuntimeException(" encrypt fail! ",e);
}
}
/**
* Description: 解密
*
* @param data 解密数据
* @param aesKey 解密密钥
* @return byte[]
*/
public static byte[] decrypt(byte[] data,byte[] aesKey)
{
if (aesKey.length != 16)
{
throw new RuntimeException(" Invalid AES Key length ( must be 16 bytes)");
}
try {
SecretKeySpec secretKeySpec = new SecretKeySpec(aesKey, "AES");
byte[] encodedFormat = secretKeySpec.getEncoded();
SecretKeySpec secKey = new SecretKeySpec(encodedFormat, "AES");
/* 创建密码器 */
Cipher cipher = Cipher.getInstance(AES_ALGORITHM);
/* 初始化密码器 */
cipher.init(Cipher.DECRYPT_MODE,secKey);
byte[] result = cipher.doFinal(data);
return result;
}catch (Exception e){
throw new RuntimeException(" Decrypt Fail !",e);
}
}
/**
* Description:加密数据,并转换为Base64编码格式!
*
* @param data 待加密数据
* @param aeskey 加密密钥
* @return java.lang.String
*/
public static String encryptToBase64(String data,String aeskey)
{
try {
byte[] valueByte = encrypt(data.getBytes(CHAR_ENCODING), aeskey.getBytes(CHAR_ENCODING));
/* 加密数据转 Byte[]--> 换为Base64 --> String */
return Base64.encode(valueByte);
}catch (UnsupportedEncodingException e){
throw new RuntimeException(" Encrypt Fail !",e);
}
}
/**
* Description: 解密数据,将Basse64格式的加密数据进行解密操作
*
* @param data
* @param aeskey
* @return java.lang.String
*/
public static String decryptFromBase64(String data,String aeskey)
{
try {
byte[] originalData = Base64.decode(data.getBytes());
byte[] valueByte = decrypt(originalData,aeskey.getBytes(CHAR_ENCODING));
return new String(valueByte,CHAR_ENCODING);
}catch (UnsupportedEncodingException e){
throw new RuntimeException("Decrypt Fail !",e);
}
}
/**
* Description:加密数据,aesKey为Base64格式时,并将加密后的数据转换为Base64编码格式
*
* @param data
* @param aesKey
* @return java.lang.String
*/
public static String encryptWithKeyBase64(String data,String aesKey)
{
try{
byte[] valueByte = encrypt(data.getBytes(CHAR_ENCODING), Base64.decode(aesKey.getBytes()));
return Base64.encode(valueByte);
}catch (UnsupportedEncodingException e){
throw new RuntimeException("Encrypt Fail!",e);
}
}
/**
* Description: 解密数据,数据源为Base64格式,且 aesKey为Base64编码格式
*
* @param data
* @param aesKey
* @return java.lang.String
*/
public static String decryptWithKeyBase64(String data,String aesKey)
{
try {
byte[] originalDate = Base64.decode(data.getBytes());
byte[] valueByte = decrypt(originalDate,Base64.decode(aesKey.getBytes()));
return new String(valueByte,CHAR_ENCODING);
}catch (UnsupportedEncodingException e){
throw new RuntimeException("Decrypt Fail !",e);
}
}
/**
* Description:通过密钥生成器生成一个随机的 AES 密钥,并将其以字节数组的形式返回。
* 主要功能是生成并返回一组随机的密钥字节数组,这些字节数组可用于加密和解密数据。
*
* @param
* @return byte[]
*/
public static byte[] generateRandomAesKey()
{
KeyGenerator keyGenerator = null;
try{
/*
* KeyGenerator是Java Cryptography Architecture(JCA)提供的主要密钥生成器类之一,用于生成对称加密算法的密钥。
* 获取一个用于生成AES算法密钥的KeyGenerator实例,以便在加密和解密操作中使用该密钥。
* */
keyGenerator = KeyGenerator.getInstance(AES_ALGORITHM);
}catch (NoSuchAlgorithmException e){
throw new RuntimeException("GenerateRandomKey Fail !",e);
}
/*
* SecureRandom 类提供了一种用于生成加密强随机数的实现。
* */
SecureRandom secureRandom = new SecureRandom();
/*
* 初始化密钥生成器 keyGenerator。
* 初始化密钥生成器时使用了 SecureRandom 实例,以确保生成的密钥具有足够的随机性。
* */
keyGenerator.init(secureRandom);
/*
* 调用 generateKey() 方法,使用初始化后的 keyGenerator 生成密钥对象 key。
* */
Key key = keyGenerator.generateKey();
//返回生成的密钥的字节数组表示。
return key.getEncoded();
}
/**
* Description: 通过密钥生成器生成一个随机的 AES 密钥,并转化为Base64格式
*
* @param
* @return java.lang.String
*/
public static String generateRandomAesKeyWithBase64()
{
return Base64.encode(generateRandomAesKey());
}
/* !!当GET请求进行加密时,地址上的加密参数就以16进制字符串的方式进行传输,否则特殊符号路径无法解析[ +、/、=]等Base64编码格式 */
/**
* Description: 从Byte[] 数组转 16进制字符串
*
* @param b
* @return java.lang.String
*/
public static String toHexString(byte[] b)
{
/*
* 每个字节都可以用两个十六进制字符来表示,因此初始化的容量是字节数组长度的两倍。
* */
StringBuilder sb = new StringBuilder(b.length * 2);
for (int i = 0; i<b.length ;i++)
{
/*
* 首先取字节的高四位,然后查找对应的十六进制字符,并将其追加到StringBuilder中
* */
sb.append(HEXCHAR[(b[i] & 0xf0) >>> 4]);
/*
* 取字节的低四位,找到对应的十六进制字符,并追加到StringBuilder中。
* */
sb.append(HEXCHAR[b[i] & 0x0f]);
}
return sb.toString();
}
/**
* Description: 从16进制字符串转 byte[] 数组
*
* @param s
* @return byte[]
*/
public static final byte[] toBytes(String s)
{
byte[] bytes;
bytes = new byte[s.length() / 2];
for (int i = 0; i < bytes.length ; i++)
{
bytes[i] = (byte) Integer.parseInt(s.substring(2*i,2*i+2),16);
}
return bytes;
}
}
AES加解密工具类方法代码解析(为了方便自己理解和使用,有必要简单分类记录一下工具类中的方法接口):
AESUtil工具类中的方法封装的比较杂乱,通过梳理之后更加能理清每个方法的具体用法和功能!
生成AES密钥的方法:
该工具类中总共封装了两种 生成 AES密钥的方法 String getAESKey(int length)
和 String generateRandomAesKeyWithBase64()
getAESKey 方法生成的密钥是 由数字(0-9)、小写字母、大写字母随机组成的普通字符串; generateRandomAesKeyWithBase64() 方法生成的密钥是 通过 javax.crypto.KeyGenerator
密钥生成器生成Byte[]
类型的数据 在将该byte[]
转换为 Base64编码格式。
AES加密数据方法:
1.AES工具类封装的加密数据方法有以下几种byte[] encrypt(byte[] data,byte[] aesKey)
、 String encryptToBase64(String data,String aeskey)
和 String encryptWithKeyBase64(String data,String aesKey)
共三种加密方式:
注:其实其余加密方法都是基于该方法进行封装的。也可以根据自己需求来调整,注意区别在于传入的数据格式有所区别!!
2.String encryptToBase64(String data,String aeskey)
该AES加密方法是通过传入加密数据的字符串,同时传入字符格式的AES密钥Key(通过getAESKey生成的密钥);方法内部会将传入进来的 待加密数据 data 和 aesKey密钥转换为 byte[]
格式,然后在调用第一种加密方法;最后生成的加密数据byte[]
也会在内部自动转换为Base64编码格式 然后返回。
3.String encryptWithKeyBase64(String data,String aesKey)
该AES加密方法,需要传入 字符串形式的加密数据,以及Base64编码格式的AES密钥 (该密钥主要是通过generateRandomAesKeyWithBase64()
方法生成的密钥数据 为Base64编码格式)。加密方法内部在接收到 待加密数据后会自动转换为byte[]
格式;在接收到Base64编码格式的AES密钥后,通过Base64.decode()
将其解码为 byte[]
。然后在调用原始的加密方法对待加密数据进行加密操作。最终加密后的数据byte[]
通过 new String(valueByte,“UTF-8”)
的方式转换为字符串返回。
AES解密数据方法:
AES工具类中封装的解密方法,对应于加密方法:byte[] decrypt(byte[] data,byte[] aesKey)
、 String decryptFromBase64(String data,String aeskey)
和 String decryptFromBase64(String data,String aeskey)
三种方式。
该三种方式分别与上面三种加密方式是对应的。需要注意传入的数据封装格式就行。
第一种解密方法就需要传入 加密后数据格式 byte[]
,密钥格式byte[]
第二种解密方法对应于上面的第二种加密方法。需要传入的加密数据为Base64编码格式( 通过加密方法生成 byte[]
后 在转换为 Base64格式 ),需要传入AES密钥格式就为普通字符串格式(通过String getAESKey(int length)
方法生成的密钥)。第三种解密方法,需要传入的待解密数据 为Base64编码格式,需要传入的AES密钥也为Base64编码格式
String toHexString(byte[] b) 方法
该方法是将byte[]
字节数据转换为16进制的字符串数据,后续会利用到。比如在Get请求种传输加密数据,如果前端加密后的数据需要放入地址中进行传输到后端;若采用Base64编码格式数加密数据进行传输时,加密内容会包含 +、\、=
三个符号,无法在地址中进行传输了。
所以这里封装了该方法,通过调用该方法,将加密后的byte[]
字节数据数据转换为 HexString 16进制字符串格式(只包含了 0~9、a、b、c、d、e、f
)。这样Get请求中的加密数据就可以通过地址进行传输了
byte[] toBytes(String s) 方法
该方法于 toHexString 方法相对应,将转换为HexString十六进制的字符串 还原为字节数据Byte[]。
1.3 RSA加密工具类创建
RSA加密工具类,同样引用了 hutool-all 依赖工具类。
package com.example.api_security_demo.utils;
import cn.hutool.core.codec.Base64Encoder;
import javax.crypto.Cipher;
import java.io.ByteArrayOutputStream;
import java.security.*;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.*;
/**
* @ClassName : RSAUtil
* @Description : RSA加密工具类
* @Author : AD
*/
public class RSAUtil {
/**
* "SHA256withRSA" 是一种使用 SHA-256 哈希算法和 RSA 加密算法结合的数字签名算法。
* 在这种算法中,数据首先会通过 SHA-256 进行哈希处理,得到一个固定长度的摘要,然后使用 RSA 私钥对这个摘要进行加密,从而生成数字签名。
* */
public static final String ALGORITHM_SHA256WITHRSA = "SHA256withRSA";
public static final String KEY_ALGORITHM ="RSA";
//RSA最大加密明文大小
public static final int MAX_ENCRYPT_BLOCK = 117;
//RSA最大解密密文大小
public static final int MAX_DECRYPT_BLOCK = 128;
private static char[] HEXCHAR = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f' };
/**
* Description: 公钥分段加密
*
* @param data 待加密源数据
* @param publicKey 公钥(BASE64编码)
* @param length 段长 1024长度的公钥最大取117
* @return byte[]
*/
public static byte[] encryptByPublicKey(byte[] data,String publicKey,int length) throws Exception
{
/*
* 将BASE64编码格式 publicKey进行解码
* */
byte[] publicKeyByte = decryptBASE64(publicKey);
/*
* 使用X509EncodedKeySpec类创建了一个X.509编码的KeySpec对象,并将publicKeyByte作为参数传入。
* 将公钥 [字符串] 解码成 [公钥对象] ,以便用于加密数据。
* */
X509EncodedKeySpec x509EncodedKeySpec = new X509EncodedKeySpec(publicKeyByte);
/*
* 通过KeyFactory获取了RSA的实例
* */
KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM);
/*
* 调用generatePublic方法使用之前创建的X509EncodedKeySpec对象来生成公钥。
* */
Key generatePublicKey = keyFactory.generatePublic(x509EncodedKeySpec);
/*
* 创建一个Cipher实例,它是用于加密或解密数据的对象。Cipher类提供了加密和解密功能,并支持许多不同的加密算法。
* 在这里,getInstance 方法中传入了keyFactory.getAlgorithm()[获取与指定密钥工厂相关联的算法名称。],它用于获取与指定算法关联的 Cipher 实例。
* */
Cipher cipher = Cipher.getInstance(keyFactory.getAlgorithm());
/*
* 初始化 Cipher 对象。
* 在初始化过程中,指定加密模式为 ENCRYPT_MODE,并传入了之前生成的公钥 generatePublicKey。
* */
cipher.init(Cipher.ENCRYPT_MODE,generatePublicKey);
int inputLen = data.length;
ByteArrayOutputStream out = new ByteArrayOutputStream();
//段落起始位置
int offSet = 0;
byte[] cache;
int i = 0;
//对数据进行分段加密
while (inputLen - offSet > 0)
{
if (inputLen - offSet > length) {
cache = cipher.doFinal(data,offSet,length);
} else {
cache = cipher.doFinal(data,offSet,inputLen-offSet);
}
out.write(cache,0,cache.length);
i++;
offSet = i * length;
}
byte[] encryptDate = out.toByteArray();
out.close();
return encryptDate;
}
/**
* Description:
*
* @param data 待解密数据
* @param privateKey 私密(BUSE64编码)
* @param length 分段解密长度 128
* @return byte[]
*/
public static byte[] decryptByPrivateKey(byte[] data,String privateKey,int length) throws Exception
{
byte[] privateKeyByte = decryptBASE64(privateKey);
PKCS8EncodedKeySpec pkcs8EncodedKeySpec = new PKCS8EncodedKeySpec(privateKeyByte);
KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM);
Key generatePrivateKey = keyFactory.generatePrivate(pkcs8EncodedKeySpec);
Cipher cipher = Cipher.getInstance(keyFactory.getAlgorithm());
cipher.init(Cipher.DECRYPT_MODE,generatePrivateKey);
int inputLen = data.length;
ByteArrayOutputStream out = new ByteArrayOutputStream();
int offSet = 0;
byte[] cache;
int i = 0;
//对数据进行分段解密
while (inputLen - offSet > 0)
{
if (inputLen - offSet > length)
{
cache = cipher.doFinal(data,offSet,length);
} else {
cache = cipher.doFinal(data,offSet,inputLen - offSet);
}
out.write(cache,0,cache.length);
i++;
offSet = i * length;
}
byte[] decryptData = out.toByteArray();
out.close();
return decryptData;
}
/**
* Description: BASE64解码
*
* @param src
* @return byte[]
*/
public static byte[] decryptBASE64(String src)
{
sun.misc.BASE64Decoder decoder = new sun.misc.BASE64Decoder();
try{
return decoder.decodeBuffer(src);
}catch (Exception ex){
return null;
}
}
/**
* Description: BASE64编码
*
* @param src
* @return java.lang.String
*/
public static String encryptBASE64(byte[] src)
{
sun.misc.BASE64Encoder encoder = new sun.misc.BASE64Encoder();
return encoder.encode(src);
}
/**
* Description: 从Byte[] 数组转 16进制字符串
*
* @param b
* @return java.lang.String
*/
public static String toHexString(byte[] b)
{
/*
* 每个字节都可以用两个十六进制字符来表示,因此初始化的容量是字节数组长度的两倍。
* */
StringBuilder sb = new StringBuilder(b.length * 2);
for (int i = 0; i<b.length ;i++)
{
/*
* 首先取字节的高四位,然后查找对应的十六进制字符,并将其追加到StringBuilder中
* */
sb.append(HEXCHAR[(b[i] & 0xf0) >>> 4]);
/*
* 取字节的低四位,找到对应的十六进制字符,并追加到StringBuilder中。
* */
sb.append(HEXCHAR[b[i] & 0x0f]);
}
return sb.toString();
}
/**
* Description: 从16进制字符串转 byte[] 数组
*
* @param s
* @return byte[]
*/
public static final byte[] toBytes(String s)
{
byte[] bytes;
bytes = new byte[s.length() / 2];
for (int i = 0; i < bytes.length ; i++)
{
bytes[i] = (byte) Integer.parseInt(s.substring(2*i,2*i+2),16);
}
return bytes;
}
/**
* Description: 判断对象是否为null
*/
public static boolean isEmpty(Object str) {
return (str == null || "".equals(str));
}
/**
* RSA 公钥私钥生成器
* */
public static Map generateRandomToBase64Key() throws Exception{
String KEY_ALGORITHM ="RSA";
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance(KEY_ALGORITHM);
//密钥位数
keyPairGenerator.initialize(1024);
//创建公钥/私钥
KeyPair keyPair = keyPairGenerator.generateKeyPair();
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
byte[] publicKeyEncoded = publicKey.getEncoded();
byte[] privateKeyEncoded = privateKey.getEncoded();
HashMap<String,String> map = new HashMap<>();
map.put("publicKey", Base64Encoder.encode(publicKeyEncoded));
map.put("privateKey",Base64Encoder.encode(privateKeyEncoded));
return map;
}
/**
* Description: 根据请求参数Map集合,排号顺序Sort,组装生成对应请求中的签名参数sign
*
* @param map 请求参数Map集合
* @param allowValueNull 是否允许map中的值为null; true允许:若允许为空则会出现a=&b=
* @return java.lang.String
*/
public static String generateSortSign(Map<String,String> map,boolean allowValueNull)
{
List<String> keys = new ArrayList<>(map.size());
for ( String key : map.keySet() )
{
/*
* 排除下列参数数据
* 1.不允许出现空value 且 map中为null 的键值对
* 2.参数签名内容键值对
* */
if ( (!allowValueNull && isEmpty(map.get(key))) || "sign".equals(key) || "signValue".equals(key) )
{
continue;
}
keys.add(key);
}
/*
* sort静态方法用于按自然顺序或自定义顺序对List进行排序
* */
Collections.sort(keys);
StringBuffer stringBuffer = new StringBuffer();
boolean isFirst = true;
for (String key : keys){
if (isFirst){
stringBuffer.append(key).append("=").append(map.get(key));
isFirst = false;
continue;
}
stringBuffer.append("&").append(key).append("=").append(map.get(key));
}
return stringBuffer.toString();
}
/**
* Description: 用于生成数据的数字签名,并将签名数据转换为十六进制字符串格式返回
*
* @param rawDate 签名裸数据
* @param privateKey 私钥
* @param algorithm 签名验算算法
* @return java.lang.String
*/
public static String generateSign(byte[] rawDate,String privateKey,String algorithm) throws Exception
{
byte[] privateKeyBytes = decryptBASE64(privateKey);
PKCS8EncodedKeySpec pkcs8EncodedKeySpec = new PKCS8EncodedKeySpec(privateKeyBytes);
KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM);
PrivateKey generatePrivate = keyFactory.generatePrivate(pkcs8EncodedKeySpec);
/*
* 使用指定的签名算法algorithm,通过Signature实例获取签名对象signature。
* */
Signature signature = Signature.getInstance(algorithm);
/*
* 初始化签名对象,传入生成的私钥generatePrivate。
* */
signature.initSign(generatePrivate);
/*
* 将要签名的裸数据rawData传入签名对象。
* */
signature.update(rawDate);
/*
* 生成签名数据sign
* */
byte[] sign = signature.sign();
return toHexString(sign);
}
/**
* Description: 验证签名
*
* @param data 请求数据
* @param publicKey 公钥
* @param sign 签名数据
* @param algorithm 签名验算算法
* @return boolean
*/
public static boolean verify(byte[] data,String publicKey,String sign,String algorithm) throws Exception
{
byte[] publicKeyBytes = decryptBASE64(publicKey);
X509EncodedKeySpec x509EncodedKeySpec = new X509EncodedKeySpec(publicKeyBytes);
KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM);
PublicKey generatePublicKey = keyFactory.generatePublic(x509EncodedKeySpec);
Signature signature = Signature.getInstance(algorithm);
signature.initVerify(generatePublicKey);
signature.update(data);
return signature.verify( toBytes(sign) );
}
}
RSAUtil加解密工具类方法代码解析(为了方便自己理解和使用,有必要简单分类记录一下工具类中的方法接口):
生成密钥对方法:
Map generateRandomToBase64Key()
byte[]
与 Base64编码互相转换的方法:
byte[] decryptBASE64(String src)
String encryptBASE64(byte[] src)
HexString 十六进制字符串 与 byte[]
字节数组 互相转换的方法:
String toHexString(byte[] b)
byte[] toBytes(String s)
RSA加密/解密方法:
byte[] encryptByPublicKey(byte[] data,String publicKey,int length)
RSAUtil工具类中的加密接口就只有一个,最终加密后的数据会以字节数组 byte[]
格式返回。最终用户想将密文以什么形式传输都可以( String()
字符串形式、Base64编码格式、HexString十六进制字符串形式 )。
byte[] decryptByPrivateKey(byte[] data,String privateKey,int length)
在解码数据时,必须将密文数据根据对应的数据格式转换为 byte[]
后传入解码方法。
Base64格式编码解码方法:
byte[] decryptBASE64(String src)
String encryptBASE64(byte[] src)
二、解密传输数据实现方案
依托与SpringBoot进行开发,在后台中需要解密的请求接口,是采用了FIlter来实现解密操作。
采用 FIlter 来对加密数据进行解密的好处之一是:Filter 获取到参数后,可以将密文参数解密之后,重新重写请求参数。这样在Controller层处理业务逻的接口可以按照正常方式进行开发,@RequestBody
、@RequestParam
等注解都能正常使用。
2.1 Request 流只能读取一次的问题
在接口调用连接中,request的请求流只能调用一次,处理之后,如果之后还需要用到请求流获取数据,就会发现数据为空。比如使用了filter或者aop在接口处理之前,获取了request中的数据,对参数进行了校验,那么之后就不能在获取request请求流了。
解决办法:
继承HttpServletRequestWrapper
,将请求中的流copy一份,复写getInputStream
和getReader
等方法供外部使用。每次调用后的getInputStream
方法都是从复制出来的二进制数组中进行获取,这个二进制数组在对象存在期间一致存在。通过HttpServletRequestWrapper
可以获取到前端加密的请求参数,同时也可以将解密后的参数设置进去。
Post请求: 采用Filter来实现 加密传输数据的解密功能,在解密对应request请求流中的数据之后。将解密后的数据替换至自定义封装的 requestWrapper
对象中 body中。Get请求: 地址栏中添加了加密数据,在Filter进行解密之后,会将请求数据存入自定义封装的 requestWrapper
对象中的 Map集合数据中。在重写父类的getParament()
等方法。
这里需要特别注意;对于
MultipartRequest
请求如果不做处理HttpServletRequestWrapper
中是获取不到参数的;
自定义 RequestWrapper对象:
package com.example.api_security_demo.common.core.wrapper;
import com.alibaba.fastjson2.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.multipart.MultipartRequest;
import javax.servlet.ReadListener;
import javax.servlet.ServletInputStream;
import javax.servlet.ServletRequest;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.io.*;
import java.nio.charset.Charset;
import java.util.*;
/**
* @ClassName : RequestWrapper
* @Description : 自定义Request,解决request请求流中的数据二次或多次使用问题
* 继承HttpServletRequestWrapper,将请求体中的流copy一份,覆写getInputStream()和getReader()方法供外部使用。
* 每次调用覆写后的getInputStream()方法都是从复制出来的二进制数组中进行获取,这个二进制数组在对象存在期间一直存在,这样就实现了流的重复读取。
* @Author : AD
*/
@Slf4j
public class RequestWrapper extends HttpServletRequestWrapper {
/**
* 存储Body数据
* */
private byte[] body;
//============
/**
* 保存原始Request对象,当请求为 MultipartRequest 文件上传类的请求操作
*/
private HttpServletRequest request;
/**
* 额外参数可以加到这个里面 重写getParameter() 方法 ,从而使请求中不存在的参数,通过该Map集合中获取!!
*/
private Map<String, String[]> parameterMap = new LinkedHashMap<>();
/**
* Description: requestWrapper 请求包装类的构造方法
*
* @param request
* @return
*/
public RequestWrapper(HttpServletRequest request)throws IOException
{
super(request);
//[文件上传相关的操作]
this.request = request;
if(request instanceof MultipartRequest){
// 如果是[文件上传类]请求
this.parseBody(request);
}else{
//[普通请求类]将Body数据存储起来
String bodyString = getBodyString(request);
body = bodyString.getBytes(Charset.defaultCharset());
}
}
/* [MultipartRequest 文件上传]相关操作接口 */
/**
* 如果是 MultipartRequest,需要解析参数信息
*/
private void parseBody(HttpServletRequest request) {
Map<String,Object> parameterMap = new LinkedHashMap<>();
Enumeration<String> parameterNames = request.getParameterNames();
while(parameterNames.hasMoreElements()){
String name = parameterNames.nextElement();
String[] values = request.getParameterValues(name);
parameterMap.put(name, (values !=null && values.length == 1) ? values[0] : values);
}
// 将解析出来的参数,转换成JSON并设置到body中保存
this.body = JSONObject.toJSONString(parameterMap).getBytes(Charset.defaultCharset());
}
public void setBody(byte[] body)
{
this.body = body;
try {
if(this.request instanceof MultipartRequest){
//[文件上传请求相关的操作]
//todo 将Json格式body数据转换为mp
//this.setParameterMap(JsonUtil.json2map(body));
String bodyStr = new String(body, "UTF-8");
com.fasterxml.jackson.databind.ObjectMapper objectMapper = new com.fasterxml.jackson.databind.ObjectMapper();
Map<String, Object> bodyMap = objectMapper.readValue(bodyStr, Map.class);
this.setParameterMap(bodyMap);
}
} catch (Exception e) {
log.error("转换参数异常,参数:{},异常:{}",body, e);
}
}
/**
* Description:读取请全体Body中数据 [从 requestWrapper中读取]
*
* @param
* @return java.lang.String
*/
public String getBodyString(){
final InputStream inputStream = new ByteArrayInputStream(body);
return inputStreamToString(inputStream);
}
/**
* Description: 读取请求体Body中数据 [从HttpServletRequest中读取]
*
* @param request
* @return java.lang.String
*/
public String getBodyString(final ServletRequest request)
{
try {
return inputStreamToString(request.getInputStream());
}catch (IOException e){
log.error("Read Request Body IO_Stream Fail !",e);
throw new RuntimeException(e);
}
}
/**
* Description: 将inputStream流里面的数据读取出来并转换为字符串形式
*
* @param inputStream
* @return java.lang.String
*/
private String inputStreamToString(InputStream inputStream){
StringBuilder sb = new StringBuilder();
BufferedReader reader = null;
try {
reader = new BufferedReader(new InputStreamReader(inputStream, Charset.defaultCharset()));
String line;
while ((line = reader.readLine()) != null){
sb.append(line);
}
}catch (IOException e){
log.error("BufferedReader is Fail !",e);
throw new RuntimeException(e);
}finally {
if (reader != null){
try {
reader.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
return sb.toString();
}
@Override
public BufferedReader getReader() throws IOException {
return new BufferedReader(new InputStreamReader(getInputStream()));
}
@Override
public ServletInputStream getInputStream() throws IOException {
final ByteArrayInputStream inputStream = new ByteArrayInputStream(body);
return new ServletInputStream() {
@Override
public boolean isFinished() {
return false;
}
@Override
public boolean isReady() {
return false;
}
@Override
public void setReadListener(ReadListener readListener) {
}
@Override
public int read() throws IOException {
return inputStream.read();
}
};
}
// [Get请求相关操作,封装Request.getParameter()中的相关参数 ]
/**
* Description: The default behavior of this method is to return getParameter(Stringname) on the wrapped request object.
*
* @param name
* @return java.lang.String
* @date 2024-05-13
*/
@Override
public String getParameter(String name) {
String result = super.getParameter(name);
// 如果参数获取不到则尝试从参数(自定义封装的存贮零时请求数据的集合)Map中获取,并且只返回第一个
if(result==null && this.parameterMap.containsKey(name)){
result = this.parameterMap.get(name)[0];
}
return result;
}
/**
* The default behavior of this method is to return getParameterMap() on the
* wrapped request object.
*/
@Override
public Map<String, String[]> getParameterMap() {
// 需要将原有的参数加上新参数 返回
Map<String,String[]> map = new HashMap<>(super.getParameterMap());
for(String key: this.parameterMap.keySet()){
map.put(key, this.parameterMap.get(key));
}
return Collections.unmodifiableMap(map);
}
/**
* The default behavior of this method is to return
* getParameterValues(String name) on the wrapped request object.
*
* @param name
*/
@Override
public String[] getParameterValues(String name) {
String[] result = super.getParameterValues(name);
if(result == null && this.parameterMap.containsKey(name)){
result = this.parameterMap.get(name);
}
return result;
}
/**
* The default behavior of this method is to return getParameterNames() on
* the wrapped request object.
*/
@Override
public Enumeration<String> getParameterNames() {
Enumeration<String> parameterNames = super.getParameterNames();
Set<String> names = new LinkedHashSet<>();
if(parameterNames !=null){
while(parameterNames.hasMoreElements()){
names.add(parameterNames.nextElement());
}
}
// 添加后期设置的参数Map
if(!this.parameterMap.isEmpty()){
names.addAll(this.parameterMap.keySet());
}
return Collections.enumeration(names);
}
/**
* 设置参数map
* @param json2map
*/
public void setParameterMap(Map<String, Object> json2map) {
if(json2map != null && !json2map.isEmpty()) {
for (String key : json2map.keySet()){
//获取map中对应key的value
Object value = json2map.get(key);
if(this.parameterMap.containsKey(key)){
//如果额外参数HashLink中包含该参数,则在赋值加入到String[] 中
String[] originalArray = this.parameterMap.get(key);
int originalLength = originalArray.length;
originalArray = Arrays.copyOf(originalArray,originalLength + 1);
originalArray[originalLength] = String.valueOf(value);
//this.parameterMap.put(key, Collection.add(this.parameterMap.get(key), value));
this.parameterMap.put(key,originalArray);
}else{
this.parameterMap.put(key, new String[]{String.valueOf(value)});
}
}
}
}
}
RequestWrapper
类方法代码解析(为了方便自己理解和使用,有必要简单分类记录一下工具类中的方法接口):这里自定义封装的 RequestWrapper
对象,继承 HttpServletRequestWrapper
。
通过重写 getReader()
、getInputStream()
等方法,从而可以实现通过request对象读取body数据时,能够直接获取到该对象中我们自己封装的用于存放请求体数据的属性byte[] body
。void setBody(byte[] body)
方法可以用来给自定义封装的属性body进行赋值,在 解密请求数据的 FIlter中,解密了body数据后,通过调用该方法将解密后的body数据存入requestWrapper
对象。这样在后续的业务操作中直接通过request对象获取到的body数据就是已经解密的数据。实现了业务无感知!void setParameterMap(Map<String, Object> json2map)
在解密Filter中,将Get请求中地址上的加密数据进行解密之后,调用该方法就将解密后的Get请求数据封装到requestWrapper
对象中的Map集合中实现后续调用request.getParameter()
方法时能够获取到解密后的参数。重写父类 getParameter(String name)
、Map<String, String[]> getParameterMap()
、String[] getParameterValues(String name)
等方法,实习在getRequestParameter()
数据时,也能够在requestWrapper
对象中的Map集合中获取参数数据。
2.2 过滤器Filter解密请求参数
这里封装的解密参数过滤器Filter中,首先需要通过请求头中的 aksEncrypt数据判断该请求是否为加密请求,如果不是则直接放行不做解密操作。如果需要解密的请求,首先判断请求类型在进行对应的解密处理。
Filter解密数据过滤器代码:
package com.example.api_security_demo.filter;
import com.alibaba.fastjson2.JSONObject;
import com.example.api_security_demo.common.core.wrapper.RequestWrapper;
import com.example.api_security_demo.utils.AESUtil;
import com.example.api_security_demo.utils.RSAUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.util.ObjectUtils;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.BufferedReader;
import java.io.IOException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
/**
* @ClassName : DecryptReplaceStreamFilter
* @Description :Filter过滤器 解密请求参数。同时替换请求体,使后续操作无感知加密!!
* @Author : AD
*/
@Slf4j
public class DecryptReplaceStreamFilter implements Filter {
/*
* AKS(Authentication Key Management System),采用无明文密钥的方式对数据进行加密保护,加解密运算由统一的安全计算中心完成,AKS系统以接口的方式为对各个业务线提供加密解服务。
* 各个系统不再使用明文密钥,只使用密钥别名,调用简单的加解密函数,完成对数据的保护。
* */
//请求头标签开关:是否需要加密解密
private static final String AKS_ENABLE = "aksEncrypt";
//GET请求加密数据Key
public static final String AKS_PARAMETER = "encryptData";
private static final String METHOD_GET = "GET";
private static final String METHOD_POST ="POST";
//POST请求加密数据Key
private static final String AKS_BODY ="content";
//AES密钥Key
private static final String AES_KEY = "aesKey";
/**
* Feign是声明式Web Service客户端,它让微服务之间的调用变得更简单,类似controller调用service。
* Feign内部调用时,请求头中标注请求源
* */
public static final String SOURCE_KEY = "api-source";
public static final String SOURCE_VALUE = "inner-api";
@Value("${Rsa.PrivateKey}")
private String privateKey;
@Value("${API.Security.enable}")
private boolean securityEnable;
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
//转换为自己Wrapper,实现多次读写
RequestWrapper requestWrapper = new RequestWrapper((HttpServletRequest) servletRequest);
String contentType = requestWrapper.getContentType(); //请求头中获取Content-Type数据
String requestURI = requestWrapper.getRequestURI();
//配置文件配置是否开启加解密功能
if (!securityEnable)
{
log.info("未开启接口安全加密传输! 无需解密请求参数!");
filterChain.doFilter(requestWrapper,servletResponse);
return;
}
//通过请求头参数判断该请求是否需要解密处理
if (!needAks(requestWrapper)){
log.info("请求:{},非加密请求,无需解密操作!",requestURI);
filterChain.doFilter(requestWrapper,servletResponse);
return;
}
/*
* [该功能暂时不用管,因为在请求头中不添加 aksEncrypt:true 键值对,请求接口时同样不会去进行解密请求数据的操作]
* */
//Feign服务端调用内部请求,按照不加密的逻辑放行[Feign是声明式Web Service客户端,它让微服务之间的调用变得更简单,类似controller调用service。]
//前端 --> A --> b --> c
String sourceKey = requestWrapper.getHeader(SOURCE_KEY);
if (!ObjectUtils.isEmpty(sourceKey) && sourceKey.equals(SOURCE_VALUE) ){
log.info("内部请求,无效加密解密接口数据!");
filterChain.doFilter(requestWrapper,servletResponse);
return;
}
/*
* POST请求进行解密工作
* */
if (requestWrapper.getMethod().equalsIgnoreCase(METHOD_POST)){
//读取JSON请求体数据
StringBuffer bodyInfo = new StringBuffer();
String line = null;
BufferedReader reader = null;
reader = requestWrapper.getReader();
while ( ( line = reader.readLine() ) != null ){
bodyInfo.append(line);
}
//解密请求体数据
JSONObject jsonObject = JSONObject.parseObject(bodyInfo.toString());
log.info("Post请求:{},待解密请求参数:{}", requestURI, JSONObject.toJSONString(jsonObject));
//获取通过AES加密之后的密文
String content = jsonObject.getString(AKS_BODY);
//获取通过RSA加密之后的AES密钥KEy
String aesKey = jsonObject.getString(AES_KEY);
//RSAUtil解密出AES密钥Key
try {
aesKey = new String(RSAUtil.decryptByPrivateKey(RSAUtil.toBytes(aesKey),privateKey,RSAUtil.MAX_DECRYPT_BLOCK),"UTF-8");
} catch (Exception e) {
throw new RuntimeException(e);
}
/*
* 方式1.将解密之后的数据+aesKey [放入body中,弃:会影响body结构] ( 满足在AOP操作中对出参数据进行加密 ) 一并交给下游业务。
*/
//JSONObject requestBody = JSONObject.parseObject(data);
//requestBody.put(AES_KEY,aesKey);
/*
* 方式2.将其放入[请求对象属性中中],不影响请求体结构!
*/
//requestWrapper.setAttribute(AES_KEY,aesKey);
/*
* 方式3.将其放入[放入RequestWrapper封装的额外参数Map中],不影响请求体结构!
* */
HashMap<String, Object> map = new HashMap<>();
map.put(AES_KEY,aesKey);
requestWrapper.setParameterMap(map);
//AESUtil + aesKey 解密json数据
String decryptData = AESUtil.decryptFromBase64(content, aesKey);
log.info("Get请求:{},解密之后参数:{}", requestURI, decryptData);
//重置Json请求体,保证下游业务无感知获取数据
//requestWrapper.setBody(requestBody.toJSONString().getBytes());
requestWrapper.setBody(decryptData.getBytes());
}
/*
* GET请求解密处理:[AES密钥Key放置在请求头中]
* */
else if (requestWrapper.getMethod().equalsIgnoreCase(METHOD_GET))
{
//Get请求中 获取到指定加密的参数 然后进行解密操作
String encryptData = requestWrapper.getParameter(AKS_PARAMETER);
log.info("Get请求:{},待解密请求参数:{}", requestWrapper.getRequestURI(), encryptData);
// 先解密 存放在请求头中且经过RSA加密过的AES密钥
String aesKey = requestWrapper.getHeader(AES_KEY);
if (encryptData != null && !encryptData.isEmpty() && aesKey != null && !aesKey.isEmpty()){
try {
byte[] aesKeyByte = RSAUtil.decryptByPrivateKey(RSAUtil.toBytes(aesKey), privateKey, RSAUtil.MAX_DECRYPT_BLOCK);
aesKey = new String(aesKeyByte,"UTF-8");
System.out.println("aesKey.toString() = " + aesKey.toString());
} catch (Exception e) {
throw new RuntimeException(e);
}
//Get请求中的加密数据是以16进制字符串方式传输
byte[] encryptDataByte = AESUtil.toBytes(encryptData);
System.out.println("Arrays.toString(encryptDataByte) = " + Arrays.toString(encryptDataByte));
// 解密数据操作 AKS_PARAMETER
String decryptData = new String(AESUtil.decrypt(encryptDataByte,aesKey.getBytes("UTF-8")),"UTF-8");
log.info("Get请求:{},解密之后参数:{}", requestURI, decryptData);
//将GET请求中的Parameter参数赋值入RequestWrapper中封装的其它参数存放集合Map中
com.fasterxml.jackson.databind.ObjectMapper objectMapper = new com.fasterxml.jackson.databind.ObjectMapper();
Map<String, Object> map = objectMapper.readValue(decryptData, Map.class);
//将aseKey数据也存入map额外参数中
map.put("aesKey",aesKey);
requestWrapper.setParameterMap(map);
}
}
filterChain.doFilter(requestWrapper,servletResponse);
}
@Override
public void init(FilterConfig filterConfig) throws ServletException {
Filter.super.init(filterConfig);
}
@Override
public void destroy() {
Filter.super.destroy();
}
/**
* Description: 判断当前请求是否需要加密解密数据
*
* @param request
* @return boolean
*/
private boolean needAks(HttpServletRequest request){
String enableAKS = request.getHeader(AKS_ENABLE);
return (enableAKS != null && enableAKS.equalsIgnoreCase("true"))? true: false;
}
}
代码解析:
代码中通过三重方式放到是否需要解密请求接口。一个是配置文件中读取的开关配置、一个是通过请求头中是否开启加密传输的标识(也就是在发送加密接口时,都需要在请求头上封装该开关)、Feign服务端调用内部请求,按照不加密的逻辑放行。 解密方式按照文章开头采用的方案,先用RSA密钥解密出随机的AES密钥,在通过密钥解密密文。 对应Get请求和Post请求的解密方式两点区别。Post整个请求体都是加密参数即body数据通过Base64方式传输进来的;Get请求中的加密参数需要使用:encryptData,由于需要放入路径中,Base64编码格式中含有 +、\、=
等特殊符号无法进行传输和解析,所以Get请求中的加密数据是采用HexString格式进行传输的,在解密时也需要对应的方法进行转换为Byte[]
后在进行解密操作。关于AES密钥的存放位置。Post请求中的AES密钥是存入body中存入过来的,而Get请求中的AES密钥是存放在请求头中传递过来的,所以在解密对应请求方式的密文时,注意AES密钥的获取方式。 关键解密后的AES密钥,需要一并交给下游业务。满足在AOP操作中对出参数据进行加密,所以解密出来的AES密钥也需要存入 requestWrapper
对象的Map集合中,方便下游业务获取。
Filter过滤器注册配置类:
需要注意解密过滤器Filter 的优先级一点要设置为最高优先级registration.setOrder(1);
,首先需要该过滤器对加密数据进行解密后在重新封装请求,才能使后续的业务数据能直接获取到解密后的数据,达到无感知的效果。
package com.example.api_security_demo.filter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.servlet.Filter;
/**
* @ClassName : WebAllHandlerConfig
* @Description : 过滤器、监听器、拦截器 前置处理配置类
* @Author : AD
*/
@Configuration
public class WebAllHandlerConfig {
/**
* 注册过滤器
* */
@Bean
public FilterRegistrationBean AllFilterRegistration(){
FilterRegistrationBean<Filter> registration = new FilterRegistrationBean<>();
registration.setFilter(decryptReplaceStreamFilter());
registration.addUrlPatterns("/*");
registration.setName("APISecurityFilter");
registration.setOrder(1);
return registration;
}
@Bean(name = "decryptReplaceStreamFilter")
public Filter decryptReplaceStreamFilter(){
return new DecryptReplaceStreamFilter();
}
}
三、响应数据加密实现方案
响应数据加密的实现方式是采用AOP来实现,可以对返回的结果对象进行处理,而Filter只能拿到Request与Response对象,处理不方便;
这里的响应数据加密方案就简单采用的是AES加密,通过前端传来的随机AES进行加密响应数据后在响应给前端!
这里响应加密的开启方式是通过自定义注解来实现的,创建自定义一个自定义注解,作为响应数据加密的切点,就实现了响应数据是否加密的开启。
3.1 自定义注解(开启加密的注解):
package com.example.api_security_demo.common.core.annotation;
import java.lang.annotation.*;
/**
* @ClassName : ResponseEncrypt
* @Description : 响应数据加密开启注解
* @Author : AD
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
//@Documented
public @interface ResponseEncrypt {
}
3.2 AOP实现响应数据加密功能:
需要注意,这里的响应加密AOP的优先级也要设置为最高@Order(value = 0)
,从而使最先开始的AOP能最后结束,这样才能保证 加密响应的AOP最终处理的响应数据是所以业务逻辑都处理结束后最终的响应结果,然后进行加密处理后响应给前端。
package com.example.api_security_demo.aop;
import com.example.api_security_demo.common.R;
import com.example.api_security_demo.common.core.annotation.ResponseEncrypt;
import com.example.api_security_demo.utils.AESUtil;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* @ClassName : ResponseEncryptAOP
* @Description : 传输加密模块AOP,对接口的出参进行加密,注意顺序不能乱,
* 此AOP必须第一个执行,因为最先执行的最后结束,这样才能在各个AOP都执行完毕之后完成最后的加密
* @Author : AD
*/
@Order(value = 0)
@Aspect
@Component
public class ResponseEncryptAop {
@Pointcut("@annotation(com.example.api_security_demo.common.core.annotation.ResponseEncrypt)")
public void point(){}
/**
* 环绕增强,加密出参
* */
@Around(value = "point() && @annotation(responseEncrypt)")
public Object aroundEncrypt(ProceedingJoinPoint joinPoint, ResponseEncrypt responseEncrypt) throws Throwable {
//返回的结合
Object returnValue = null;
//从上下文中提取
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
HttpServletResponse response = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getResponse();
//业务执行返回结果
returnValue = joinPoint.proceed();
System.out.println("returnValue.toString() = " + returnValue.toString());
//获取到前端传递过来的AES密钥,然后对响应数据进行AES加密操作
String aesKey = request.getParameter("aesKey");
System.out.println("aesKey = " + aesKey);
String encryptToBase64Data = AESUtil.encryptToBase64(returnValue.toString(), aesKey);
returnValue = R.ok(encryptToBase64Data);
return returnValue;
}
}
四、测试相关的类
4.1 测试类实体封装
package com.example.api_security_demo.controller;
import lombok.Data;
import lombok.ToString;
import lombok.experimental.Accessors;
/**
* @ClassName : TbStudenEntity
* @Description : 测试实体类
* @Author : AD
*/
@Data
@Accessors(chain = true)
@ToString
public class TbStudentEntity {
int id;
String name;
String sex;
int age;
}
4.2 测试接口Controller层封装
package com.example.api_security_demo.controller;
import com.example.api_security_demo.common.R;
import com.example.api_security_demo.common.core.annotation.ResponseEncrypt;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
/**
* @ClassName : TbStudentController
* @Description : 前端控制器Controller测试类
* @Author : AD
*/
@RestController
@RequestMapping("/demo")
@Slf4j
public class TbStudentController {
@PostMapping("/encryptPost")
@ResponseEncrypt
public R postDemo(@RequestBody TbStudentEntity tbStudentEntity,HttpServletRequest request){
System.out.println("request.getParameter(\"aesKey\") = " + request.getParameter("aesKey"));
//System.out.println("request.getAttribute(\"aesKey\") = " + request.getAttribute("aesKey"));
System.out.println("tbStudentEntity = " + tbStudentEntity);
return R.ok(tbStudentEntity.toString());
}
@GetMapping("/encryptGet")
@ResponseEncrypt
public R postDemoGet(HttpServletRequest request,@RequestParam String id,@RequestParam String name,@RequestParam String aesKey ){
System.out.println("id = " + id);
System.out.println("name = " + name);
System.out.println("aesKey = " + aesKey);
System.out.println("request.getParameter(\"age\") = " + request.getParameter("age"));
//System.out.println("request.getAttribute(DecryptReplaceStreamFilter.AKS_PARAMETER) = " + request.getAttribute(DecryptReplaceStreamFilter.AKS_PARAMETER));
return R.ok();
}
}
4.3 前端模拟生成加密数据
package com.example.api_security_demo.utils;
import com.alibaba.fastjson2.JSONObject;
import com.fasterxml.jackson.core.JsonProcessingException;
import lombok.extern.slf4j.Slf4j;
import org.junit.Test;
import java.io.UnsupportedEncodingException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
/**
* @ClassName : UtilsTestClass
* @Description : 工具类测试类
* @Author : AD
*/
@Slf4j
public class UtilsTestClass {
/**
* 可以将其放入配置文件中进行读取
* */
String publicKey = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDI7HE0aW9ILCMkfoAJYmAG+5RBRhU2Itebf04GUtnYMuR6Rl1GJKec7JKuM/8xSindH4jn6Vz3oARTjbCn4CxjbtQPys5i8VeXxgzzqhE34LY0Rt62Qy8UVS113454DTwZZR9BjmPQSxMaftQHMgeDjXVwLUt0a6CmRiZKOjw8WQIDAQAB";
String privateKey = "MIICdQIBADANBgkqhkiG9w0BAQEFAASCAl8wggJbAgEAAoGBAMjscTRpb0gsIyR+gAliYAb7lEFGFTYi15t/TgZS2dgy5HpGXUYkp5zskq4z/zFKKd0fiOfpXPegBFONsKfgLGNu1A/KzmLxV5fGDPOqETfgtjRG3rZDLxRVLXXfjngNPBllH0GOY9BLExp+1AcyB4ONdXAtS3RroKZGJko6PDxZAgMBAAECgYBRUHdkKcNypwI188gnhBuu18QhQpa1CRbPBI90ObWWLMqQvcdj6tO2y3t1au+9Z/FXXzrN+IC6apU1p2M2HaB4iPdW+2Vm0DaRN7XJPzBdJqVASYVJ2oWWLWCkG05mS2pAgcMlxV3TLen7iBFKTjgZhdLIal8JYgyi6XWdNlAtAQJBAPjCtpmXuAS9luyXo2ExEauKEC2uC06FeZqgM6u1rTqsqUE5kGpmJ1DiERuOcQxn8i+mlnJ1Urmw8ltMxmnJAiECQQDOxVbjUXWolwRF8lsOeZ9XGGc/45pE7J8DubEV0Ii1RVckMyTkXmgNAupE7Xq9cgNMpNBRUQEDNzSHhA8rqGM5AkBuwWrBac6RtcPLpRwl+s3uPTNE01fPZxgkYy1+Rw5QsG1PUAzfgooAthZ92Wa16lXnJ1mWrmvdp03Qnpc8pDVhAkBoqWD6vV/2D0L9eNh4cj2iY1rX7whGfRNcWmD1rtGUF94tF6pD4jl+5Ivaie6H+C8NW5uKnZsKmqX/NmxLZ/eZAkBDXRX7YgMG4PLXcjPT4ot6DEJDjuSVb1LhwucL6QFFYhZloH3/9XQS3T9XB/2F61tdh5Jd3FaCD7WeXJCzW+64";
/**
* Description: 测试获取密钥的方法
*/
@Test
public void testGetASK() throws Exception {
// AES密钥
String aesKey = AESUtil.getAESKey(16);
System.out.println("aesKey = " + aesKey);
// AES Base64类型密钥
String base64AesKey = AESUtil.generateRandomAesKeyWithBase64();
System.out.println("base64AesKey = " + base64AesKey);
//RSA 私钥公钥
Map map = RSAUtil.generateRandomToBase64Key();
System.out.println("map.get(\"privateKey\") = " + map.get("privateKey"));
System.out.println("map.get(\"publicKey\") = " + map.get("publicKey"));
}
/**
* Description: post请求加密传输,请求体body封装测试
*
* @param
* @return void
*/
@Test
public void postRequestBodyEncryptTest() throws Exception {
//自定义方式获取 aesKey
String aesKey = AESUtil.getAESKey(16);
//内部方式获取 Base64AesKey
String base64AesKey = AESUtil.generateRandomAesKeyWithBase64();
//创建请求体body数据 content
//模拟业务数据Json,并用AES密钥Key进行加密
Map<String, Object> dataMap = new HashMap<>();
dataMap.put("id", 15);
dataMap.put("name", "张三");
dataMap.put("sex", "男");
dataMap.put("age", 20);
JSONObject dataJson = new JSONObject(dataMap);
String contentJson = JSONObject.toJSONString(dataJson);
//AES 加密业务数据
String content = AESUtil.encryptToBase64(contentJson, aesKey);
log.info("AES加密数据操作: \nAES加密之前的数据 = {} \nAES加密之后的数据 content= {}",contentJson,content);
//RSA 加密AES密钥
byte[] bytes = RSAUtil.encryptByPublicKey(aesKey.getBytes("UTF-8"), publicKey, RSAUtil.MAX_ENCRYPT_BLOCK);
String encryptAesKey = RSAUtil.toHexString(bytes);
log.info("RSA加密AES密钥操作: \nRSA加密之前的AES密钥= {} \nRSA加密之后的AES密钥 aesKey={}",aesKey,encryptAesKey);
/**
* 09:08:42.376 [main] INFO com.example.api_security_demo.utils.UtilsTestClass - AES加密数据操作:
* AES加密之前的数据 = {"sex":"男","name":"张三","id":15,"age":20}
* AES加密之后的数据 content= u/vRpwktLAo12ATyfa1rb14EHNftHfvhYEfy7r+DOJzO6jzXS4bwUJ0xNY8RJu8f
* 09:08:42.380 [main] INFO com.example.api_security_demo.utils.UtilsTestClass - RSA加密AES密钥操作:
* RSA加密之前的AES密钥= 9raG41FIE8uK7l3k
* RSA加密之后的AES密钥 aesKey=c7eccef1d112075ee64eec65163b8b1dcb1a54ea6c8b51875174f6d34fc4ac7d50d2977b7519d275ee610d717d594228e132b053a70cdad9f925701a728ed794684d12097cfb8bea598c561393cb69de384b2ec83aa8ddb9a98a5adb3ed51ee1b9aaab2cf7bc5b49712a95e40ac4ea17421f5250d34b8e629e58db0b26e54b39
* */
}
/**
* Description: Get请求加密传输,请求体RequestParameter数据封装测试
* 注意: 通过AES加密之后的数据 byte[] 需要转换为 HexString十六进制字符串后才能在 请求地址中传输,不能在用Base64编码格式的方式进行传输!
*/
@Test
public void getRequestBodyEncryptTest() throws Exception {
//自定义方式获取 aesKey
String aesKey = AESUtil.getAESKey(16);
//内部方式获取 Base64AesKey
String base64AesKey = AESUtil.generateRandomAesKeyWithBase64();
//创建请求体body数据 content
//模拟业务数据Json,并用AES密钥Key进行加密
Map<String, Object> dataMap = new HashMap<>();
dataMap.put("id", 15);
dataMap.put("name", "张三");
dataMap.put("sex", "男");
dataMap.put("age", 20);
JSONObject dataJson = new JSONObject(dataMap);
String contentJson = JSONObject.toJSONString(dataJson);
//AES 加密业务数据
byte[] contentByte = AESUtil.encrypt(contentJson.getBytes("UTF-8"), aesKey.getBytes("UTF-8"));
String content = AESUtil.toHexString(contentByte);
log.info("AES加密数据操作: \nAES加密之前的数据 = {} \nAES加密之后的Byte[]数据 = {} \nAES加密后的数据转换为HexString十六进制字符串数据encryptData={}",contentJson,Arrays.toString(contentByte),content);
//RSA 加密AES密钥
byte[] bytes = RSAUtil.encryptByPublicKey(aesKey.getBytes("UTF-8"), publicKey, RSAUtil.MAX_ENCRYPT_BLOCK);
String encryptAesKey = RSAUtil.toHexString(bytes);
log.info("RSA加密AES密钥操作: \nRSA加密之前的AES密钥= {} \nRSA加密之后的AES密钥aesKey={}",aesKey,encryptAesKey);
/**
* 09:09:13.172 [main] INFO com.example.api_security_demo.utils.UtilsTestClass - AES加密数据操作:
* AES加密之前的数据 = {"sex":"男","name":"张三","id":15,"age":20}
* AES加密之后的Byte[]数据 = [73, 19, 107, 60, 29, 109, 100, 119, -81, -117, -84, -85, 19, 28, 86, 18, 123, 48, 58, 37, -28, -65, -93, -124, -50, 89, -10, -101, 48, 48, -104, -18, -109, 127, -19, 80, 62, -122, -80, -122, 94, 72, -16, -89, -1, -128, 22, -92]
* AES加密后的数据转换为HexString十六进制字符串数据encryptData=49136b3c1d6d6477af8bacab131c56127b303a25e4bfa384ce59f69b303098ee937fed503e86b0865e48f0a7ff8016a4
* 09:09:13.177 [main] INFO com.example.api_security_demo.utils.UtilsTestClass - RSA加密AES密钥操作:
* RSA加密之前的AES密钥= 868E9FMA727S9W5q
* RSA加密之后的AES密钥aesKey=2ab5531c7814201b4eaef3802ca883e79ffffd4c4ec32e698403189c0954718fd5cebd0ac5e66e856ec4f95a408442fc76276586a8fdb94c14c8f311f30ad061d6928315078736e6633113cdba255870a78e9077b2f18bdc4b2730804e5d6181df4b0ecf51597f71c8e0ccb89a5e160f1216a7bde5386b42171577db400d5a54
* */
}
@Test
public void testByteBase64String(){
/*
* 创建字节数组的方式
* */
byte[] testBytes = new byte[]{0,1,2,3,4};
/*
* 读取字节数组的方式:
* */
System.out.println("Arrays.toString(bytes1) = " + Arrays.toString(testBytes));
//模拟业务数据Json,并用AES密钥Key进行加密
Map<String, String> dataMap = new HashMap<>(2);
dataMap.put("name", "张三");
dataMap.put("age", "20");
JSONObject dataJson = new JSONObject(dataMap);
String jsonStr = JSONObject.toJSONString(dataJson);
com.fasterxml.jackson.databind.ObjectMapper objectMapper = new com.fasterxml.jackson.databind.ObjectMapper();
try {
Map<String, Object> map = objectMapper.readValue(jsonStr, Map.class);
System.out.println("map.toString() = " + map.toString());
System.out.println("map.get(\"name\") = " + map.get("name"));
System.out.println("map.get(\"age\") = " + map.get("age"));
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
byte[] jsonByte = dataJson.toString().getBytes();
System.out.println("new String(jsonByte) = " + new String(jsonByte));
//[两种方式不一样!]
String hexStr = "0123456789abcdef";
System.out.println("Arrays.toString(hexStr.getBytes()) = " + Arrays.toString(hexStr.getBytes()));
System.out.println("Arrays.toString(RSAUtil.toBytes(hexStr)) = " + Arrays.toString(RSAUtil.toBytes(hexStr)));
}
/**
* Description: 解密操作测试
*/
@Test
public void testAesDecrypt() throws Exception {
String encryptAesKey = "868dbf00b6849a1189f186f18bc98eca65981829e12d3bad21f4a64c139a6fe6953729e488af642cb5bf7104459a4fbf084bb536f251e2d9fa39747037715da6a73caf23e1d68bd5338d51dd207ebe9c4a72749d87d73eb96fc193adac45e6b8b6b7fcc211ee47efd0d54ea97dcfdbc221ac0bd7664d32becbdd654c3d9b2446";
String encryptDate = "8ee240b806a571f0f7ef5568d9cf5e36d999686acabfa4d5425d73ef7e546c8c1e9147c084269a6884cfeebf6759bd60";
//解密之后的AesKey 56X8817GRC2p33w0
byte[] decryptAesKey = RSAUtil.decryptByPrivateKey(RSAUtil.toBytes(encryptAesKey), privateKey, RSAUtil.MAX_DECRYPT_BLOCK);
String decryptAesKeyStr = new String(decryptAesKey, "UTF-8");
System.out.println("decryptAesKeyStr = " + decryptAesKeyStr);
//GET请求数据解密 [加密数据为HexStr十六进制字符串形式]
byte[] encryptDataByte = AESUtil.toBytes(encryptDate);
System.out.println("Arrays.toString(encryptDataByte) 加密数据Byte[]形式 = " + Arrays.toString(encryptDataByte));
String decryptData = new String(AESUtil.decrypt(encryptDataByte,decryptAesKeyStr.getBytes("UTF-8")),"UTF-8");
System.out.println("decryptData解密后的数据 = " + decryptData);
/* //POST请求数据解密 [加密数据为Base64编码格式]
String decryptData2 = AESUtil.decryptFromBase64(encryptDate, decryptAesKeyStr);
System.out.println("decryptData2 解密后的数据 = " + decryptData2);*/
/**
* AES加密之前的数据 = {"sex":"男","name":"张三","id":15,"age":20}
* AES加密之后的Byte[]数据 = [-114, -30, 64, -72, 6, -91, 113, -16, -9, -17, 85, 104, -39, -49, 94, 54, -39, -103, 104, 106, -54, -65, -92, -43, 66, 93, 115, -17, 126, 84, 108, -116, 30, -111, 71, -64, -124, 38, -102, 104, -124, -49, -18, -65, 103, 89, -67, 96]
* AES加密后的数据转换为HexString十六进制字符串数据encryptData=8ee240b806a571f0f7ef5568d9cf5e36d999686acabfa4d5425d73ef7e546c8c1e9147c084269a6884cfeebf6759bd60
* 16:48:25.095 [main] INFO com.example.api_security_demo.utils.UtilsTestClass - RSA加密AES密钥操作:
* RSA加密之前的AES密钥= 8c0N9032214LJ139
* RSA加密之后的AES密钥aesKey=868dbf00b6849a1189f186f18bc98eca65981829e12d3bad21f4a64c139a6fe6953729e488af642cb5bf7104459a4fbf084bb536f251e2d9fa39747037715da6a73caf23e1d68bd5338d51dd207ebe9c4a72749d87d73eb96fc193adac45e6b8b6b7fcc211ee47efd0d54ea97dcfdbc221ac0bd7664d32becbdd654c3d9b2446
* */
}
/**
* Description: 响应数据加密后,模拟前端进行解密操作
*/
@Test
public void responseEncryptToDecrypt() throws UnsupportedEncodingException {
String aesKeyPost = "9raG41FIE8uK7l3k";
String aesKeyGet = "0303F71572405EF1";
String encryptData = "cf156ddc9cd9fde2a8287fa3b8eadca258ce063130d29d7d9c1b949a4628c42e497b4e1db76244bdd075cb37b8ef0212";
//解密后的数据
String decryptData = "";
//Base64编码格式的数据解密
//decryptData = AESUtil.decryptFromBase64(encryptData, aesKeyGet);
//HexString格式的数据转换后在解密
byte[] decrypt = AESUtil.decrypt(AESUtil.toBytes(encryptData), aesKeyGet.getBytes("UTF-8"));
decryptData = new String(decrypt, "UTF-8");
System.out.println("decryptData = " + decryptData);
}
}
4.4 模拟加密请求调用接口
4.4.1 Post请求的封装
请求头中封装数据:
aksEncrypt: true
开启解密功能
请求体Body(application/json
格式)的封装:
content:通过随机AES密钥加密后的请求数据 aesKey: 通过RSA加密后的随机AES密钥
Post请求后响应数据
可以看出 请求数据解密后,通过 RequestWrapper
重新封装后的请求数据,能直接通过@RequestBody
等注解直接获取到相请求体中的数据。同时也可以通过 request.getParameter()
中存放的参数。
4.4.2 Get请求的封装
Get请求的封装
Get请求响应结果:
可以看出 请求数据解密后,通过 RequestWrapper
重新封装后的请求数据,能直接通过@RequestParam
等注解直接获取到Get请求中的数据。同时也可以通过 request.getParameter()
中存放的参数。
结束语:还有些地方封装的不是很恰当,欢迎各位大佬留言提意见!
好书推荐
从零开始,全面精通医学统计学 从基础概念到高级应用,融合SPSS与PASS等 结合医学情境案例,逐步解析统计奥秘 助力医学探索者构建坚实统计基础,精准把握数据背后的价值
专业级Origin 2024英文版科研绘图指南 以“二维、三维科技绘图+拟合与分析+立体几何建模绘图+ AI 辅助科研与绘图”的思路 进行全方位讲解 掌握科技绘图与学术图表绘制的核心技能
👉 欢迎加入小哈的星球,你将获得: 专属的项目实战 / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论
新项目:《从零手撸:仿小红书(微服务架构)》 正在持续爆肝中,基于 Spring Cloud Alibaba + Spring Boot 3.x + JDK 17..., 点击查看项目介绍; 《从零手撸:前后端分离博客项目(全栈开发)》 2期已完结,演示链接:http://116.62.199.48/; 截止目前,累计输出 69w+ 字,讲解图 2776+ 张,还在持续爆肝中.. 后续还会上新更多项目,目标是将 Java 领域典型的项目都整一波,如秒杀系统, 在线商城, IM 即时通讯,Spring Cloud Alibaba 等等,戳我加入学习,解锁全部项目,已有2400+小伙伴加入
2. SpringBoot 不单独部署注册中心,如何实现注册中心功能?
最近面试BAT,整理一份面试资料《Java面试BATJ通关手册》,覆盖了Java核心技术、JVM、Java并发、SSM、微服务、数据库、数据结构等等。
获取方式:点“在看”,关注公众号并回复 Java 领取,更多内容陆续奉上。
PS:因公众号平台更改了推送规则,如果不想错过内容,记得读完点一下“在看”,加个“星标”,这样每次新文章推送才会第一时间出现在你的订阅列表里。
点“在看”支持小哈呀,谢谢啦