【Java】AOP+注解实现前后端传参加解密

封装了一个前后端传参敏感数据加解密小工具,直接通过AOP+注解完成,在项目中亲测有效,特点包括:

  • 使用AES算法,密钥和偏移量基于token的md5加密,增加安全性;
  • 自定义方法注解,待加/解密字段全部放在注解中,不需要和其他任何注解组合使用,也不受其他任何注解的干扰;
  • 支持形参中的待解密对象为:自定义实体(实际加密实体中的指定字符串成员变量)、普通字符串、普通字符串列表;
  • 支持返回值的待加密对象为:单个自定义实体、自定义实体列表、自定义实体分页,实际加密的是实体中的字符串字段。

AES加解密工具类

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+注解实现前后端传参加解密的方式有很多种思路,根据不同的需求和考量,可以从以下几个方面着手:

  • 如果觉得单独去加解密模型对象中的一个字段比较麻烦,可以考虑暴力地将模型对象整体加解密,不用去剥洋葱找具体的敏感字段;
  • 也可以使用复合注解,不仅在方法上做注解,还需要在敏感字段上使用注解,这样就不需要将所有敏感字段像上面那样放在数组里面了

你可能感兴趣的:(实用小工具,java,开发语言)