如何用Mybatis拦截器实现自动对敏感字段进行加解密

为了数据安全问题,有时候需要将部分敏感字段加密后再入库,查询时又需要将其解密后返回给前端使用。我们可以用Mybatis的拦截器来实现这一需求。

  1. 定义一个注解,用来标识需要加解密的字段。

为了尽量减少不必要的反射操作,可以将该注解同时标识在实体类上,对于没有被标识的实体类,无需利用反射来操作其属性。

import java.lang.annotation.*;

/**
 * 标识需要加解密的类及其属性
 */
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.TYPE})
public @interface FieldEncrypt {
}
  1. 定义加密拦截器

拦截所有insert和update操作,拿到实体对象,并通过反射获取到所有标记了@FieldExcrypt注解的属性,将其值进行加密并替换

import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.binding.MapperMethod;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.SqlCommandType;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Intercepts;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.plugin.Signature;

/**
 * 加密拦截器
 * <p>拦截所有insert和update操作,</p>
 * <p>如果实体类中有属性标记有@FieldEncrypt注解,则对其进行加密替换</p>
 */
@Slf4j
@Intercepts({@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})})
public class EncryptionInterceptor implements Interceptor {
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        Object[] args = invocation.getArgs();
        MappedStatement ms = (MappedStatement) args[0];
        SqlCommandType sqlCommandType = ms.getSqlCommandType();
        log.debug("EncryptionInterceptor 操作类型:{}", sqlCommandType);
        MapperMethod.ParamMap paramMap = (MapperMethod.ParamMap)args[1];
        Object entity = paramMap.get("et");
        try {
            if (SqlCommandType.INSERT == sqlCommandType) {
                FieldEncryptUtil.encryptField(entity);
            } else if (SqlCommandType.UPDATE == sqlCommandType) {
                FieldEncryptUtil.encryptField(entity);
                log.debug("[EncryptionInterceptor] update operation,encrypt field: {}", entity);
            }
        } catch (Exception e) {
            log.error("[EncryptionInterceptor] encryptField failed  entity:{}", entity.getClass().getName(), e);
        }
        return invocation.proceed();
    }

}
  1. 定义解密拦截器

拦截所有query操作,先执行sql,拿到返回的结果,并通过反射获取到所有标记了@FieldExcrypt注解的属性,将其值进行解密并替换。

请注意:结果可能是单个Bean(如selectOne方法),也有可能是ArrayList。如果是集合,则以第一个元素的Class对象为准。

import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.cache.CacheKey;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Intercepts;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.plugin.Signature;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;

import java.util.ArrayList;

/**
 * 解密拦截器
 * <p>拦截所有query操作</p>
 * <p>如果结果集合元素实体中,存在属性标记了@FieldEncrypt注解,则解密并替换</p>
 */
@Slf4j
@Intercepts({
        @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
        @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class})
})
public class DecryptionInterceptor implements Interceptor {
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        Object result = invocation.proceed();
        try {
            if(result instanceof ArrayList) {
                ArrayList list = (ArrayList) result;
                if(list.isEmpty()) {
                    return result;
                }
                Class objClazz = list.get(0).getClass();
                if (FieldEncryptUtil.hasEncryptFields(objClazz)) {
                    for (Object item : list) {
                        FieldEncryptUtil.decryptField(item);
                    }
                }
                return result;
            } else {
                FieldEncryptUtil.decryptField(result);
            }
        } catch (Exception e) {
            log.error("[DecryptionInterceptor] decryptField failed  entity:{}", result.getClass().getName(), e);
        }

        return result;
    }
}
  1. 加解密工具类
import com.baomidou.mybatisplus.core.toolkit.AES;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.AnnotationUtils;

import java.lang.reflect.Field;
import java.lang.reflect.Type;
import java.util.Objects;

@Slf4j
public class FieldEncryptUtil {

    private static final String SECRET = "{替换成AES密钥}";

    private FieldEncryptUtil() {
    }

    public static String encrypt(String src) {
        return AES.encrypt(src, SECRET);
    }

    public static String decrypt(String src) {
        return AES.decrypt(src, SECRET);
    }

    public static boolean hasEncryptFields(Class clz) {
        return AnnotationUtils.isCandidateClass(clz, FieldEncrypt.class);
    }

    private static boolean isFieldEncrypt(Field field) {
        FieldEncrypt encryptAnno = AnnotationUtils.findAnnotation(field, FieldEncrypt.class);
        return Objects.nonNull(encryptAnno);
    }

    /**
     * 对实体类字段加密替换
     */
    public static void encryptField(Object object) throws IllegalAccessException {
        if (Objects.isNull(object) || !hasEncryptFields(object.getClass())) {
            return;
        }
        Class clazz = object.getClass();
        Field[] fields = clazz.getDeclaredFields();
        for (Field field : fields) {
            Type fieldClazz = field.getGenericType();
            if (!(fieldClazz instanceof Class) || !String.class.isAssignableFrom((Class)fieldClazz)) {
                continue;
            }
            if (isFieldEncrypt(field)) {
                field.setAccessible(true);
                Object originVal = field.get(object);
                if (null == originVal || StringUtils.isBlank(originVal.toString())) {
                    continue;
                }
                String encryptedStr = encrypt(originVal.toString());
                field.set(object, encryptedStr);
                log.debug("[encryptField success] encrypt {}#{}", clazz.getName(), field.getName());
            }
        }
    }

    public static void decryptField(Object object) throws IllegalAccessException {
        if (Objects.isNull(object) || !hasEncryptFields(object.getClass())) {
            return;
        }
        Class clazz = object.getClass();
        Field[] fields = clazz.getDeclaredFields();
        for (Field field : fields) {
            Type fieldClazz = field.getGenericType();
            if (!(fieldClazz instanceof Class) || !String.class.isAssignableFrom((Class)fieldClazz)) {
                continue;
            }
            if (isFieldEncrypt(field)) {
                field.setAccessible(true);
                Object originVal = field.get(object);
                if (null == originVal || StringUtils.isBlank(originVal.toString())) {
                    continue;
                }
                String decryptedStr = decrypt(originVal.toString());
                field.set(object, decryptedStr);
                log.debug("[decryptField success] {}#{}", clazz.getName(), field.getName());
            }
        }
    }

}

实体类示例(对身份证号进行加密存储)

@FieldEncrypt
@TableName("user")
public class User {
    
    @TableId
    private Long id;
    
    private String username;
    
    private String phone;
    
    @FieldEncrypt
    private String idCard;
    
}

注意事项

  • 因为我是在starter组件里实现的,所以两个拦截器没有使用@Component注解标识,而是使用AutoConfiguration声明的。如果你是在项目中直接实现的,请将其交给Spring容器进行管理。

  • 因为加解密是针对字符串而言的,所以,在进行加解密时需要判断字段类型,如果注解错误地标识在了非String类型字段上,要么忽略,要么根据实际需求实现逻辑。

  • 工具类中写死了AES密钥,这个密钥必须全局唯一,否则会出错。

  • 为防止意外错误发生,导致代码报错,我这里在加解密过程中若发生异常,默认是不替换,直接返回原值。

©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容