封装了一个前后端传参敏感数据加解密小工具,直接通过AOP+注解完成,在项目中亲测有效,特点包括:
import org.apache.commons.codec.binary.Base64;
import java.nio.charset.StandardCharsets;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import cn.hutool.core.text.CharSequenceUtil;
import lombok.CustomLog;
import static com.uih.uplus.mti.tm.biz.utils.errorcode.ServiceErrorCode.TM_ILLEGAL_PARAMETER;
/**
* @author
* @description AES/CBC/PKCS5Padding加解密工具类
* @date 2023/5/25 14:02
*/
@CustomLog
public class AesUtils {
/**
* 算法/模式/补码方式
*/
private static final String MODE_METHOD = "AES/CBC/PKCS5Padding";
/**
* 算法
*/
private static final String ALGORITHM_NAME = "AES";
/**
* AES算法加密明文
*
* @date: 2023/5/25 14:01
* @author:
*/
public static String encryptAES(String data, String key, String iv) {
//若字段为 null/"" 直接返回
if (CharSequenceUtil.isEmpty(data)) {
return data;
}
//加密
try {
SecretKeySpec keySpec = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), ALGORITHM_NAME);
Cipher cipher = Cipher.getInstance(MODE_METHOD);
//使用CBC模式,需要一个向量iv,可增加加密算法的强度
IvParameterSpec ivParameterSpec = new IvParameterSpec(iv.getBytes(StandardCharsets.UTF_8));
cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivParameterSpec);
byte[] encrypted = cipher.doFinal(data.getBytes());
return encode(encrypted);
} catch (Exception e) {
throw new AppException(TM_ILLEGAL_PARAMETER);
}
}
/**
* AES算法解密密文
*
* @date: 2023/5/25 13:55
* @author:
*/
public static String decryptAES(String data, String key, String iv) {
//若字段为 null/"" 直接返回
if (CharSequenceUtil.isEmpty(data)) {
return data;
}
//解密
try {
Cipher cipher = Cipher.getInstance(MODE_METHOD);
SecretKeySpec keySpec = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), ALGORITHM_NAME);
IvParameterSpec ivParameterSpec = new IvParameterSpec(iv.getBytes(StandardCharsets.UTF_8));
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivParameterSpec);
byte[] original = cipher.doFinal(decode(data));
String originalString = new String(original);
return originalString.trim();
} catch (Exception e) {
throw new AppException(TM_ILLEGAL_PARAMETER);
}
}
/**
* BASE64编码
*
* @date: 2023/5/25 13:54
* @author:
*/
public static String encode(byte[] byteArray) {
return Base64.encodeBase64String(byteArray);
}
/**
* BASE64解码
*
* @date: 2023/5/25 13:54
* @author:
*/
public static byte[] decode(String base64EncodedString) {
return Base64.decodeBase64(base64EncodedString);
}
}
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* @description: 针对方法是否开启加解密注解
* 该注解是一个普通的自定义注解,不需要和其他任何注解组合使用,也不受其他任何注解的干扰
* 该注解的主要功能是针对入参所有字段中,对与字符串数组decryptParams中同名的字段,尝试对该其进行解密操作
* 针对出参的所有字段,对与字符串数组encryptParams中同名的字段,尝试对其进行加密操作
* 注意:
* 无论待加解密的对象被封装成自定义对象、分页类型、列表类型,AOP方法都会将其一层层剥离,最终仅针对具体的字符串字段做加解密操作
* 待解密的字段前后端参数名称必须保持一致(即不能使用@RequestParam注解去处理前后端参数名称不一致的情况)
* 若形参包括自定义类型对象,则该对象名称不能与类型中的成员变量重名
* 若待解密/加密字段为空值,会直接忽略该字段不做解密/加密处理,后端不会抛出任何异常
* 若在后端本因被解密的字段,前端传递过来却没有加密,后端不会抛异常,只会在控制台输出错误日志,并将其作为明文处理与使用(增加弹性)
*
* @date: 2023/4/1 14:52
* @author:
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(value = {ElementType.METHOD})
public @interface EncryptMethod {
/**
* 从前端传递的参数中,需要解密的字段列表
*/
String[] decryptParams() default {};
/**
* 向前端传递的参数中,需要加密的字段列表
*/
String[] encryptParams() default {};
}
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.uih.uplus.common.utils.result.Result;
import com.uih.uplus.mti.tm.biz.utils.AesUtils;
import com.uih.uplus.mti.tm.biz.utils.AppException;
import com.uih.uplus.mti.tm.biz.utils.TMPage;
import com.uih.uplus.mti.tm.biz.utils.annotation.encrypt.EncryptMethod;
import com.uih.uplus.mti.tm.biz.utils.uap.utils.TokenUtil;
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.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import javax.servlet.http.HttpServletRequest;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.convert.Convert;
import cn.hutool.crypto.digest.MD5;
import lombok.CustomLog;
import static com.uih.uplus.mti.tm.biz.utils.errorcode.ServiceErrorCode.DECRYPT_FAILED;
import static com.uih.uplus.mti.tm.biz.utils.errorcode.ServiceErrorCode.ENCRYPT_FAILED;
import static com.uih.uplus.mti.tm.biz.utils.errorcode.ServiceErrorCode.NO_SUPPORT_DECRYPT_TYPE;
/**
* @description: 入参解密/出参加密
* 支持形参中的待解密对象为:自定义实体(实际解密实体中的指定字符串成员变量)、普通字符串、普通字符串列表
* 支持返回值的待加密对象为:单个自定义实体、自定义实体列表、自定义实体分页,实际加密的是每个自定义实体中的指定字段
* @date: 2023/5/16 14:42
* @author:
*/
@Component
@Aspect
@CustomLog
public class EncryptAop {
//基于前端传递的token动态生成密钥和偏移量
@Autowired
private TokenUtil tokenUtil;
/**
* 切入点
*
* @date: 2023/5/17 20:13
* @author:
*/
@Pointcut("@annotation(com.uih.uplus.mti.tm.biz.utils.annotation.encrypt.EncryptMethod)")
public void pointCut() {
}
/**
* 环绕通知
*
* @date: 2023/5/16 16:37
* @author:
*/
@Around("pointCut()")
public Object aroundEncrypt(ProceedingJoinPoint pjp) throws Throwable {
//第一步:获取当前被@EncryptMethod注解的方法对象、注解对象
MethodSignature signature = (MethodSignature)pjp.getSignature();
Method method = pjp.getTarget().getClass().getMethod(signature.getName(), signature.getParameterTypes());
EncryptMethod annotation = method.getAnnotation(EncryptMethod.class);
//第二步:对入参敏感字段解密
decrypt(pjp, method, annotation);
//第三步:执行请求处理接口
Object response = pjp.proceed(pjp.getArgs());
//第四步:对出参敏感字段加密,并返回
encrypt(annotation, response);
return response;
}
/**
* 对入参做解密
* 支持对入参的自定义实体的字符串类型的成员变量、普通字符串形参的解密
*
* @date: 2023/5/17 20:13
* @author:
*/
private void decrypt(ProceedingJoinPoint pjp, Method method, EncryptMethod annotation) {
String key = MD5.create().digestHex(tokenUtil.getToken().jwtString);
String iv = MD5.create().digestHex16(tokenUtil.getToken().jwtString);
//获取注解的参数值
List<String> decryptParam = Lists.newArrayList(annotation.decryptParams());
ServletRequestAttributes sra = (ServletRequestAttributes)RequestContextHolder.getRequestAttributes();
if (sra != null) {
HttpServletRequest request = sra.getRequest();
//获取请求参数
try {
Object[] args = pjp.getArgs();
Parameter[] params = method.getParameters();
for (int i = 0; i < args.length; i++) {
//判断当前形参是否是自定义实体类型,判断依据:
//针对自定义实体类型,接口参数的名称一般与Params或Body中的变量名称不一致
//而非自定义实体类型,一般接口参数的名称与Params或Body中的变量名称是一致的
//若该形参是自定义实体类
if (Boolean.FALSE.equals(getKey(request, params[i].getName()))) {
Field[] declaredFields = args[i].getClass().getDeclaredFields();
for (Field field : declaredFields) {
if (decryptParam.contains(field.getName())) {
field.setAccessible(true);
field.set(args[i], AesUtils.decryptAES((String)field.get(args[i]), key, iv));
}
}
} else {
//若该形参是非自定义实体
if (decryptParam.contains(params[i].getName())) {
//若该参数是字符串列表
if (args[i] instanceof List && Objects.nonNull(args[i]) && (CollUtil.isNotEmpty((List<?>)args[i])) &&
(((List<?>)args[i]).get(0) instanceof String)) {
args[i] = Convert.toList(String.class, args[i]).stream().map(x -> AesUtils.decryptAES(x, key, iv))
.collect(Collectors.toList());
} else if (args[i] instanceof String) {
//若该参数是普通字符串
args[i] = AesUtils.decryptAES(pjp.getArgs()[i].toString(), key, iv);
} else {
//暂不支持其他类型形参的解密(如Map、Set、Long等)
throw new AppException(NO_SUPPORT_DECRYPT_TYPE);
}
}
}
}
} catch (Exception e) {
logger.error(e.getMessage());
throw new AppException(DECRYPT_FAILED);
}
}
}
/**
* 对出参对象的指定字段加密(仅支持分页、列表、自定义实体类型)
*
* @date: 2023/5/17 14:48
* @author:
*/
private void encrypt(EncryptMethod annotation, Object response) {
if (!Objects.isNull(response) && (response instanceof Result<?>)) {
Result<?> result = (Result<?>)response;
if (result.getData() instanceof TMPage<?>) {
//若返回值为分页类型(TMPage是当前项目的分页类型,其他项目可能有自己的分页类型)
TMPage<?> page = (TMPage<?>)result.getData();
page.getRecords().forEach(x -> doEncryptObject(x, Lists.newArrayList(annotation.encryptParams())));
} else if (result.getData() instanceof List<?>) {
//若返回值为列表类型
List<?> list = (List<?>)result.getData();
list.forEach(x -> doEncryptObject(x, Lists.newArrayList(annotation.encryptParams())));
} else {
//其他(默认为自定义实体对象)
doEncryptObject(result.getData(), Lists.newArrayList(annotation.encryptParams()));
}
}
}
/**
* 仅针对自定义实体中的敏感字段加密
*
* @date: 2023/5/17 14:19
* @author:
*/
private void doEncryptObject(Object object, List<String> encryptParam) {
//若加密字段数组为空,直接返回,杜绝了返回值位Boolean的情况
if (Objects.isNull(object) || CollUtil.isEmpty(encryptParam)) {
return;
}
String key = MD5.create().digestHex(tokenUtil.getToken().jwtString);
String iv = MD5.create().digestHex16(tokenUtil.getToken().jwtString);
Field[] declaredFields = object.getClass().getDeclaredFields();
HashMap<Object, Object> cipherMap = Maps.newHashMap();
try {
for (Field field : declaredFields) {
if (encryptParam.contains(field.getName())) {
field.setAccessible(true);
String cipherText = (String)field.get(object);
cipherText = AesUtils.encryptAES(cipherText, key, iv);
cipherMap.put(field.getName(), cipherText);
}
}
BeanUtil.copyProperties(cipherMap, object);
} catch (Exception e) {
logger.error(e.getMessage());
throw new AppException(ENCRYPT_FAILED);
}
}
/**
* 根据前后端参数名称是否一致判断参数类型
* 若名称一致则为非自定义类型
* 若名称不一致则为自定义类型
*
* @date: 2023/5/16 15:34
* @author:
*/
public Boolean getKey(HttpServletRequest request, String name) {
Map<String, String[]> map = request.getParameterMap();
for (Map.Entry<String, String[]> element : map.entrySet()) {
if (element.getKey().equals(name)) {
return true;
}
}
return false;
}
}
@RestController
@RequestMapping(value = "/app/patient")
public class AppPatientController {
@Autowired
private AppPatientService appPatientService;
/**
* 分页查询/患者列表信息
*
* @date: 2023/4/10 13:45
* @author:
*/
@GetMapping("/page")
@EncryptMethod(decryptParams = {"keyword"}, encryptParams = {"name", "patientIdNo"})
public Result<TMPage<InpatientVO>> patientList(@Valid InpatientListDTO inpatientListDTO) {
return RestResult.success(appPatientService.patientList(inpatientListDTO));
}
}
入参:
/**
* @Description 患者列表分页查询请求参数
* @Date 2022/10/19 10:53
* @Author
*/
@Data
public class InpatientListDTO {
//查询类型:0最近访问 1本院急诊科 2全域患者
private Integer queryType;
//搜索关键字(App端使用): 支持身份证号码右模糊、门诊号右模糊、患者姓名全模糊
private String keyword;
......
}
出参:
/**
* @Description 查询患者住院信息返回对象
* @Date 2022/10/19 10:53
* @Author
*/
@Data
public class InpatientVO {
//患者治疗周期信息表ID
private String patientPeriodId;
//患者姓名
private String name;
//身份证号
private String patientIdNo;
......
}
其实上面的整个加解密方法是有缺陷的,比如若存在类嵌套,敏感字段藏得比较深的话,上面的方法就不适用了。当然也有补救办法,就是如果嵌套的类对象中有敏感字段,就将该对象整体加解密,本文没有这样做的原因是项目中并不存在这种情况。
实质上AOP+注解实现前后端传参加解密的方式有很多种思路,根据不同的需求和考量,可以从以下几个方面着手: