🔥 保护敏感数据:Mybatis字段加密你还在业务代码实现吗?

前言

最近在做的项目中在和甲方开会的过程中,提到了一个需求是需要对用户的隐私数据进行加密(比如用户的手机号、身份证号码)在保存到数据库的时候需要进行国密的SM2非对称加密、并且在查询数据的时候还需要对其解密出相应的数据。

实现的方案

对于这样的需求也挺简单,可以在每次新增或者是修改数据的时候,在相应的接口里面写上加密的代码然后保存到数据库,在查询的时候把查询出来的数据如果是一个数据的话遍历这个数组然后针对每一项进行解密,然后你会发现在一个原本片段很短的一个查询或者是新增修改的逻辑中由于需要加密或解密字段较多你的代码将会写的很长。并且在这样需要在每一个接口中编写。 然后你的业务代码就会如下图:

新增数据的Controller

查询列表的Controller

在你其它的业务中使用相关的字段的时候都会加上这样的if判断,所以这里我们能不能想一种使用简单的方式:如我在需要加密或解密的字段上加上一个注解,在新增、修改、查询的时候他就会自动的加解密,让开发人员免去这样需要重复编写的代码。

这样的方法还是存在的,我们可以使用Mybatis的拦截器+注解的方式+反射的方式就可以实现的这样的功能。

需要引入的Jar包

在写文章我不可能使用我们项目的代码,这里我就是使用户RuoYi开源项目来演示下, 对于数据的加解密可以使用Hutool工具包中的工具来实现,避免重复造轮子。

这Hutool中国密加密的包还需要引入一个依赖包:

xml 复制代码
<dependency>
  <groupId>org.bouncycastle</groupId>
  <artifactId>bcpkix-jdk18on</artifactId>
  <version>1.78.1</version>
</dependency>

实现对数据的加解密

既然看到了这里,我就认为你已经准备好了这篇文章需要的环境包插引入了需要代码和相关的工具类。

原理分析

  • 比如我现在要对用户表中的手机号码进行加密那么首先应该有一个可以让Java程序能知道你需要对哪一个字段进行加解密,这里可以使用自定义注解实现,注解我们使用的名称是Encryption.class并且这个类只能用在字段上。
  • 标识的注解现在已经有了,那么接下来的问题,在程序的运行期间如何让程序知道这个类对象的那个字段需要进行加解密。这个我们可以使用遍历这个类的所有的字段,看看哪一个字段上存在Encryption.class注解,但是这样的效率上是比较慢的,我们可以这样在程序启动的时候就将这些需要加密的字段放到某一个地方,在查询或新增的时候直接去这个地方去取,看看有没有需要加密的字段。
  • 好了,明白了如何标识,如何在程序运行期间找到这个需要加密的类,接下来就是反射登场,对这个需要加密的字段计算出密文得新赋值。

上面的这几条就是我对于进行字段数据加解密时的一个想法,那么接下来我们就一一实现

代码实现

下图是RuoYi项目的system模块的项目结构,我把代码在这个模块中实现。

创建一个标识注解

这个代码很简单就是在Java项目中创建出一个注解类。我就在这个模块中创建一个annotations的java包路径,然后在这个包中创建一个名称是Encryption的注解,并标识上他只能标注在字段上并且在运行时生效。

java 复制代码
@Documented
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Encryption {

}

注解创建好后就可以把他标注到需要加密的字段上了。

注:这里为了方便测试,我就在数据库中创建了一个新的表student学生表,里面只有学生名称和学生手机号字段

sql 复制代码
CREATE TABLE `student` (
  `base_id` bigint NOT NULL AUTO_INCREMENT,
  `s_name` varchar(100) DEFAULT NULL,
  `s_phone` varchar(100) DEFAULT NULL,
  PRIMARY KEY (`base_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

针对这个表创建出相关的 model、service、controller、mapper类,这里代码都比较简单,自行创建就好。 这里我就把学生的手机号码当成需要加解密的字段,将这个注解类标识到手机号字段上。

缓存需要加解密的字段

上面说到过,在SpringBoot项目启动的时候需要将加解密的字段缓存到某一个地方。在代码中做字段缓存的时候可以使用一些工具类,这里我就是使用了Map来进行这些需要加密字段的缓存,他们的key就是当前的类,需要加解密的字段是一个List<Field>的集合。

首先来创建一个类EncryptorManager这个类是用于在程序启动或者是说在这个自动配置类开始执行的时候将那些需要加解密的字段缓存起来。

java 复制代码
public class EncryptorManager {

    //这个Map当成一个缓存
    private Map<Class<?>,List<Field>> encryptorMap = new HashMap<>();

    /**
     *
     */
    public EncryptorManager(String packagePath) {
        scanEncryptorField(packagePath);
    }

    private void scanEncryptorField(String packages) {
        PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
        CachingMetadataReaderFactory factory = new CachingMetadataReaderFactory();
        try{
            String diskPackagePath = packages.replaceAll("\.","/");
            //取出这个目录中的所有的类,这里是取的class文件
            Resource[] resources = resolver.getResources(diskPackagePath  + "/*.class");
            for(Resource temp : resources){
                ClassMetadata classMetadata = factory.getMetadataReader(temp).getClassMetadata();
                //获取到该类的类名
                String className = classMetadata.getClassName();
                //通过Class.formName的方式将这个对象创建出来
                Class<?> aClass = Resources.classForName(className);
                List<Field> enFieldList = getEncodingFieldOnCurrentClass(aClass);
                if(!enFieldList.isEmpty()){
                    encryptorMap.put(aClass,enFieldList);
                }
            }
        }catch (Exception e){
            e.printStackTrace();
            System.out.println("这里出现了异常");
        }
    }

然后这里还有一个自定义的方法是将这个类中的所有加解密字段获取出来,返回了一个List<Field>,然后将返回的信息和当前类保存到encryptorMap中。

java 复制代码
/**
 * 将这个类中的需要加解密的字段获取出来
 * @param aClass 当前类
 * @return
 */
private List<Field> getEncodingFieldOnCurrentClass(Class<?> aClass) {
    List<Field> fieldList = new ArrayList<>();
    //这一步可要可不要,反正只是获取到的加解密的字段,下面的stream中自会给他过滤掉
    if(aClass.isInterface() || aClass.isMemberClass() || aClass.isAnonymousClass()){
        return fieldList;
    }

    Field[] declaredFields = aClass.getDeclaredFields();
    List<Field> collect = Arrays.stream(declaredFields).filter(item -> item.isAnnotationPresent(Encryption.class)).collect(Collectors.toList());
  
    //将这个字段的访问权限设置成true后,我们才可以在通过反射的方式获取或更新他的值
    for(Field field : collect){
        field.setAccessible(true);
    }

    fieldList.addAll(collect);
    return fieldList;
}

说到这里还需要创建一个自动配置的类,用于将加解密的需要的一些对象呀,什么的相关的类都给创建出来,这里你可以使用@Component或者是@Configuration之类的注解去创建,不过前面这两个我没有试过,因为我觉的既然这个类是对于Mybatis框架基础之上的算是一个应用吧,他在创建的时候更应该在Mybaits的自动配置类创建完成之后进行创建MybatisAutoConfiguration.class就是这个类之后进行创建。

java 复制代码
/**
 * 加解密自动配置类
 */
@AutoConfigureAfter(value = MybatisAutoConfiguration.class)
public class EncryptorAutoConfiguration {

    @Bean
    public EncryptorManager encryptorManager(){
        return new EncryptorManager("com.ruoyi.system.domain");
    }

}

EncryptorManager的构造中传入的这个包名,其实就是你所需要扫描加解密字段所在类的那个包名

因为我现在用的若依项目中使用的SpringBoot版本是2.X的,如果你是使用的SpringBoot3.X的版本可以去网上自行搜索如何配置自动配置类

在resource下创建META-INF文件夹,然后在这个文件夹中创建一个spring.factories的文件写下如下的代码

java 复制代码
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
  com.ruoyi.system.config.EncryptorAutoConfiguration,\
  com.ruoyi.system.config.EncryptorAutoConf

入库时的加密讲解

上面的代码准备好以后,这个时候就获取到了需要加解密的字段,接下来就是在数据库数据入库的时候如何将这个数据进行加密并写到数据库中。

这下点我们可以使用Mybatis的拦截器进行实现。如下是从网络上一篇文章里面找出来的感觉解释的很到位。

MyBatis允许使用者在映射语句执行过程中的某一些指定的节点进行拦截调用,通过织入拦截器,在不同节点修改一些执行过程中的关键属性,从而影响SQL的生成、执行和返回结果,如:来影响Mapper.xml到SQL语句的生成、执行SQL前对预编译的SQL执行参数的修改、SQL执行后返回结果到Mapper接口方法返参POJO对象的类型转换和封装等。

Mybatis拦截器的介绍

MyBatis一共有四大拦截器分别是Executor,ParameterHandler,ResultSetHandler,StatementHandler,来看一下这几个拦截器的用途,如果熟悉拦截器的话,可以跳过这里接着往下看。

  • Executor:Executor 拦截器用于拦截 Executor 接口的方法调用,允许你在 SQL 执行前后进行一些自定义的操作,比如你可以在这个拦截器里面做一些操作:性能监控、SQL重写、记录日志等。
  • ParameterHandler:这个拦截器的作用是负责设置 SQL 语句中的参数。它会将 Java 对象中的属性值映射到 SQL 语句中的参数占位符。比如你可以在这个拦截器中做一些操作:修改参数的值(本次咱们用到的)
  • ResultSetHandler:责将数据库返回的结果集转换成 Java 对象,本文章中解密就是用到的这个拦截器。
  • StatementHandler :负责操作 JDBC 的 Statement 对象,比如设置参数、执行 SQL 语句、获取结果集等。

文章中的代码所用到的拦截器就是ResultSetHandlerStatementHandler,分别修改入库之前的参数值和在数据库中查询出来后对数据进行解密填充到对象中。

入库时加密的实现

创建出加密字段的拦截器类,并加上Intercepts注解

java 复制代码
@Intercepts({@Signature(
        type = ParameterHandler.class,
        method = "setParameters",
        args = {PreparedStatement.class}
)})
public class EncryptField implements Interceptor {
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        return null;
    }

    @Override
    public Object plugin(Object target) {
        return Interceptor.super.plugin(target);
    }
}

有的同学可能没有看过Mybatis拦截器相关的代码,那关于拦截器这一块我在这里就说的细一些。

具体的代码中现在就是写一个大体的框架,具体的代码还没有实现,先在这里说一下@Intercepts的作用及里面写的参数的作用吧 Intercepts注解是一个用于MyBatis配置拦截器的注解,它告诉MyBatis哪些接口的哪些方法需要被拦截。然后里面的参数中的type 是表示拦截那一个method 就表示拦截这个类下面的哪一个方法,args 就表示这个方法中的参数是什么,虽然说这个setParameters方法中只有一个参数,但是这个配置拦截器的功能是通用的,这个方法中只有一个参数,但是需要拦截其它方法的时候他可能会有参数的重载,所以这里的args参数就更加的确定要拦截的是具体是哪一个方法。

上面的这个注解就可以确定为拦截ParameterHandler接口的setParameters方法,并且参数是PreparedStatement的那个方法。

拦截器创建完成了,就需要看下具体的加密代码应该如何编写了。

第一步,前面我们将某个类中需要加密的字段已经缓存起来了,在这个拦截器中我们是需要用到的,所以这里需要将这个EncryptorManager注入进来。

java 复制代码
.......
private EncryptorManager encryptorManager;

//这里直接通过构造器的方式注入就好
public EncryptField(EncryptorManager encryptorManager) {
    this.encryptorManager = encryptorManager;
}
.......

第二步,改造plugin方法,在这个方法中生成一个代理对象直接返回,因为只有生成了代理对象后它才可以调用intercept方法。

java 复制代码
@Override
public Object plugin(Object target) {
    return Plugin.wrap(target, this);
}

第三步,也就是到了真正需要加密的逻辑了他是写在了intercept方法中的。

java 复制代码
@Override
public Object intercept(Invocation invocation) throws Throwable {
    Object target = invocation.getTarget();
    if(target instanceof  ParameterHandler){
        ParameterHandler parameterHandler = (ParameterHandler) target;
        Object parameterObject = parameterHandler.getParameterObject();
        //如果不是空并且他不是String类的时候才会进行加密
        //如果是String类型的话无法判断出这个参数是否需要加密
        if(!Objects.isNull(parameterObject) && !(parameterObject instanceof String) && !(parameterObject instanceof Map)){
            encryptField(parameterObject);
        }
    }
    return invocation.proceed();
}

在这个方法中其实就是做了一件事,那就是获取到了需要往数据库写入的数据的对象Object parameterObject = parameterHandler.getParameterObject();也就是这行代码,也只有获取到了这个对象中的数据才可以进行修改。到最后返回它需要执行的方法,就完成了在数据入库时对指定数据进行加密的功能了。

下面是具体的加密的逻辑:

这里我分成两种情况,一个是传入的对象就是一个普通的对象,另外一个情况是传入的参数对象是一个List

如果是List的话那就对里面的每一个对象当成参数全部执行encryptField方法对里面的字段进行加密操作,如果不是List那就在encryptorManager的对象中获取到该类缓存的需要加密的字段,如果获取到了则遍历这些字段,一个一个的进行加密操作。最后将计算出来的密文重新的设置回该对象中。

最后在intercept方法中返回invocation.proceed()继续执行写库的逻辑。

java 复制代码
private void encryptField(Object parameterObject){
    //这里需要判断下这个parameterObject是不是List
    //如果是List需要对里面的元素进行递归处理
    if(parameterObject instanceof List<?>){
        //这里可以写一个优化的思路,这个数组中的所有的类型都是一个样的,我们只需要取出第一个或者是最后一个元素对象
        //判断这个对象中有没有需要加密的字段就可以了
        List<?> list = (List<?>) parameterObject;
        Object item = list.get(0);
        if(!Objects.isNull(item)){
            List<Field> fields = encryptorManager.getEncryptorMap().get(item.getClass());
            if(fields != null && !fields.isEmpty()){
                fields.forEach(this::encryptField);
            }
        }
    }
    List<Field> fields = encryptorManager.getEncryptorMap().get(parameterObject.getClass());
    if(fields == null || fields.isEmpty()){
        return;
    }
    try{
        for(Field item : fields){
            SM2 sm2 = SmUtil.sm2("3644f9ba1b587e08577ce0482a26fe153e253721917a7e29c8d85135bf76ba08", "02ecb798273c61ea1ac1befdb4bd9c729c2d51c36ea47195868ea4bc2c5b5bfe24");
            String encryptStr = sm2.encryptBcd((String) item.get(parameterObject), KeyType.PublicKey);
            item.set(parameterObject, encryptStr);
        }
    }catch (Exception e){
        System.out.println("加密报错");
    }
}

如果你在测试这一步的时候,你的代码没有正常的进入拦截器,请先看附录

这些代码写完后你再进行测试时,这时写到数据库的手机号码就是加密过后的了

这个时候也可以拿着加密后的字符串去解密网站上验证下

也成功的解密出我输入的明文,但是这里有一点大家看到这个加密后的密文还是很长的,所以这里我们可以将这个密文进行Base64编码,让它尽量的短一些。只需要将这个改成base64的就好

java 复制代码
try{
    for(Field item : fields){
        ......
        String encryptStr = sm2.encryptBase64((String) item.get(parameterObject), KeyType.PublicKey);
        ......
    }
}catch (Exception e){
    System.out.println("加密报错");
}

加密的部分到此就完成了

查库时的解密讲解

解密的操作和加密的类似,只是我们需要修改下要拦截的方法,注解上的type需要修成ResultSetHandler ,method改成handleResultSets ,args改成Statement.class

java 复制代码
@Intercepts({@Signature(
        type = ResultSetHandler.class,
        method = "handleResultSets",
        args = {Statement.class})
})
public class DeCodeField implements Interceptor {

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        return invocation;
    }

    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target, this);
    }
}

这里就有需要思考下了intercept方法里面他的proceed 需要在什么时候执行?,在加密的时候它是在返回的时候执行的,因为我们需要先计算出加密后的值,然后才执行入库的操作,所以在加密的时候intercept.proceed()是在最后执行。

那么到了解密的时候这个方法应该写在这个方法的开始的时候,只有在数据库中查询出了数据,我们才能拿到查询出的数据进行解密于是这个方法就变成了这样:

java 复制代码
@Override
public Object intercept(Invocation invocation) throws Throwable {
    Object proceed = invocation.proceed();
    deCode(proceed);
    return proceed;
}

我们具体执行解密的逻辑是在deCode自定义的方法中,我们来看下这个方法是怎么实现的

java 复制代码
private void deCode(Object sourceObject) {
    if (sourceObject instanceof List<?>) {
        List<?> list = (List<?>) sourceObject;
        if(CollUtil.isEmpty(list)) {
            return;
        }
        Object firstItem = list.get(0);
        if (ObjectUtil.isNull(firstItem) || CollUtil.isEmpty(encryptorManager.getEncryptorMap().get(firstItem.getClass()))) {
            return;
        }
        list.forEach(this::deCode);
        return;
    }
    List<Field> fields = encryptorManager.getEncryptorMap().get(sourceObject.getClass());
    try {
        for (Field field : fields) {
            String o = (String)field.get(sourceObject);
            field.set(sourceObject, this.doDecode(o));
        }
    } catch (Exception e) {
        System.out.println("解密失败");
    }
}

同样这个方法中只处理了List和一个普通对象的这两种方式。 if中的代码和加密时的一样,然后在if下面的代码也是需要在EncryptorManager 的对象中获取需要解密的字段(这里加密和解密的字段一样)。然后拿到这个解密的字段是谁调用doDecode对字段中的数据进行解密。

java 复制代码
private Object doDecode(String str) {
    SM2 sm2 = SmUtil.sm2("3644f9ba1b587e08577ce0482a26fe153e253721917a7e29c8d85135bf76ba08", "02ecb798273c61ea1ac1befdb4bd9c729c2d51c36ea47195868ea4bc2c5b5bfe24");
    byte[] decrypt = sm2.decrypt(str, KeyType.PrivateKey);
    String mw = StrUtil.utf8Str(decrypt);
    System.out.println(mw);
    return mw;
}

这个时候在加密时我们是使用的公钥进行加密,但是在解密的时候我们就需要私钥进行解密。将解密后的明文返回,由field.set方法将明文重新设置到对象的字段上,这个时候就完成的数据的解密并将解密后的明文设置到的对象中

注意,这个时候我们是新加了一个拦截器,也需要修改下EncryptorAutoConf类中的afterPropertiesSet方法,将解密的拦截器也需要添加进去

java 复制代码
@Override
public void afterPropertiesSet() throws Exception {
    System.out.println("执行了");
    EncryptField interceptor = new EncryptField(encryptorManager);
    DeCodeField deCodeField = new DeCodeField(encryptorManager);
    for (SqlSessionFactory sqlSessionFactory : sqlSessionFactoryList) {
        org.apache.ibatis.session.Configuration configuration = sqlSessionFactory.getConfiguration();
        if (!containsInterceptor(configuration, interceptor)) {
            configuration.addInterceptor(interceptor);
        }

        if (!containsInterceptor(configuration, deCodeField)) {
            configuration.addInterceptor(deCodeField);
        }
    }
}

自此完成数据的解密。测试的sql里面的baseId的值是我写死的,这个在测试的时候需要修改下。

xml 复制代码
<select id="select" resultType="com.ruoyi.system.model.Student">
    select base_id as "baseId",s_name as "sName",s_phone as "sPhone" from student where base_id = '41'
</select>

测试也没有问题,加密后的数据在查询的时候也成功的解密了出来。

总结

到此在Mybatis的扩展,对指定字段进行加密和解密的功能就完成。这个功能的重点就是在运用Mybatis的拦截器。你可以在这个基础上试的扩展下比如加密的公钥、私钥改成可配置的。

同样你还可以想出其他的功能也可以通过Mybatis的拦截器来实现,比如我前段时间遇到的一个功能阿里云的OSS在私有读写的时需要通过阿里OSS的SDK来计算出一个签名才可以访问OSS的资源,你也可以通过这种办法给资源的字段上加上一个注解,通过Mybatis拦截器的功能来对这个OSS的地址进行签名的计算并返回到前端。

附录

在若依的项目中,我试了好多次创建的拦截器在插入数据的时候是不会执行进去了,然后我看了下Myabtis自动配置的源码发现了问题。

在自动配置的代码中这个sqlSessionFactory方法并没有执行。所以他也不会将自定义的拦截器添加进去。它没有执行的原因可能是在执行Mybatis的自动配置之前可能已经创建出来的(这个只是我的猜测,如果有小伙伴知道是什么原因的话可以在下方留言),所以这个方法没有执行。

既然它没有将拦截器放到sqlSessionFactory中,那只能是我们通过代码来将自定义的拦截器放进去了,可以创建一个类来实现InitializingBean接口,在afterPropertiesSet方法中将自定义的拦截器添加进去。这个InitializingBean接口作用就是会在其所有属性都设置完毕后,调用 afterPropertiesSet() 方法。 你可以在这个方法中执行一些初始化操作。

java 复制代码
@AutoConfigureAfter(EncryptorAutoConfiguration.class)
public class EncryptorAutoConf implements InitializingBean {

    private final EncryptorManager encryptorManager;
    private final List<SqlSessionFactory> sqlSessionFactoryList;
    public EncryptorAutoConf(EncryptorManager encryptorManager,List<SqlSessionFactory> sqlSessionFactoryList) {
        this.encryptorManager = encryptorManager;
        this.sqlSessionFactoryList = sqlSessionFactoryList;
    }

    @Override
    public void afterPropertiesSet() throws Exception {
        EncryptField interceptor = new EncryptField(encryptorManager);
        for (SqlSessionFactory sqlSessionFactory : sqlSessionFactoryList) {
            org.apache.ibatis.session.Configuration configuration = sqlSessionFactory.getConfiguration();
            if (!containsInterceptor(configuration, interceptor)) {
                configuration.addInterceptor(interceptor);
            }
        }
    }

    /**
     * 这个方法是判断当前的这个拦截器有没有存在
     * <p>
     *  这段代码是在PageHelpe中复制过来的
     * </p>
     */
    private boolean containsInterceptor(org.apache.ibatis.session.Configuration configuration, Interceptor interceptor) {
        try {
            return configuration.getInterceptors().stream().anyMatch(config->interceptor.getClass().isAssignableFrom(config.getClass()));
        } catch (Exception e) {
            return false;
        }
    }
}

上面代码准备完成后需要修改下spring.factories文件

factories 复制代码
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
  com.ruoyi.system.config.EncryptorAutoConfiguration,\
  com.ruoyi.system.config.EncryptorAutoConf

通过这样的注册,自定义的拦截器就可以正常的使用了

相关推荐
向前看-2 小时前
验证码机制
前端·后端
xlsw_2 小时前
java全栈day20--Web后端实战(Mybatis基础2)
java·开发语言·mybatis
超爱吃士力架3 小时前
邀请逻辑
java·linux·后端
AskHarries5 小时前
Spring Cloud OpenFeign快速入门demo
spring boot·后端
isolusion6 小时前
Springboot的创建方式
java·spring boot·后端
zjw_rp7 小时前
Spring-AOP
java·后端·spring·spring-aop
TodoCoder7 小时前
【编程思想】CopyOnWrite是如何解决高并发场景中的读写瓶颈?
java·后端·面试
凌虚8 小时前
Kubernetes APF(API 优先级和公平调度)简介
后端·程序员·kubernetes
机器之心8 小时前
图学习新突破:一个统一框架连接空域和频域
人工智能·后端
.生产的驴9 小时前
SpringBoot 对接第三方登录 手机号登录 手机号验证 微信小程序登录 结合Redis SaToken
java·spring boot·redis·后端·缓存·微信小程序·maven