文章

基于 MyBatis 插件实现字段加解密

前言

对于大多数系统来说,敏感数据的加密存储都是必须考虑和实现的。最近在公司的项目中也接到了相关的安全需求,因为项目使用了 MyBatis 作为数据库持久层框架,在经过一番调研后决定使用其插件机制来实现字段加解密功能,并且封装成一个轻量级、支持配置、方便扩展的组件提供给其他项目使用。

MyBatis 的插件机制

关于 MyBatis 插件的详细说明可以查阅官方文档

简介

MyBatis 提供了插件功能,它允许你拦截 MyBatis 执行过程中的某个方法,对其增加自定义操作。默认情况下,MyBatis 允许拦截的方法包括:

方法说明
org.apache.ibatis.executor.Executorupdate, query, flushStatements, commit, rollback, getTransaction, close, isClosed拦截执行器的方法
org.apache.ibatis.executor.parameter.ParameterHandlergetParameterObject, setParameters说明:拦截参数处理的方法
org.apache.ibatis.executor.resultset.ResultSetHandlerhandleResultSets, handleOutputParameters拦截结果集处理的方法
org.apache.ibatis.executor.statement.StatementHandlerprepare, parameterize, batch, update, query拦截 Sql 语句构建的方法

插件实现

在 MyBatis 中,一个插件其实就是一个拦截器,插件的实现方式非常简单,只需要实现 org.apache.ibatis.plugin.Interceptor 接口,并且通过 @Intercepts 注解指定要拦截的方法签名即可。 以下是官方文档提供的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// ExamplePlugin.java
@Intercepts({@Signature(
  type= Executor.class,
  method = "update",
  args = {MappedStatement.class,Object.class})})
public class ExamplePlugin implements Interceptor {
  private Properties properties = new Properties();

  @Override
  public Object intercept(Invocation invocation) throws Throwable {
    // implement pre-processing if needed
    Object returnObject = invocation.proceed();
    // implement post-processing if needed
    return returnObject;
  }

  @Override
  public void setProperties(Properties properties) {
    this.properties = properties;
  }
}

上面的插件会拦截 org.apache.ibatis.executor.Executor#update 方法的所有调用,你可以在 invocation.proceed() 前后增加插件逻辑。

字段加解密实现

对于字段加解密来说,需要关注的点就是查询、插入和更新,在进行这些操作的时候需要对字段进行处理(插入、更新时加密,查询时解密),与上文提到的拦截点的对应关系如下:

  • 插入、更新:org.apache.ibatis.executor.Executor#update
  • 查询:org.apache.ibatis.executor.resultset.ResultSetHandler#handleResultSets

代码实现

先上完整代码:https://github.com/WhiteDG/mybatis-crypto

一般场景下只有包含敏感数据的字段才需要进行加解密,所以需要一个注解来标记哪些字段需要加解密,这里定义为 @EncryptedField,提供两个属性:key:加解密时用到的密钥;encryptor:指定加解密器,不指定则使用全局的加解密器。

1
2
3
4
5
6
7
8
9
10
@Documented
@Inherited
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface EncryptedField {

    String key() default "";

    Class<? extends IEncryptor> encryptor() default IEncryptor.class;
}

加密的整体思路就是通过 Invocation 拿到方法参数,有两种情况:一种是实体类,一种是 ParamMap,实体类通过注解确定需要加密的字段,ParamMap 通过配置的参数名前缀确定需要加密的字段,然后使用加密器对需要加密的字段进行加密覆盖掉原始值即可。如果是实体类则在方法执行完成后还需要对 key 字段进行回写处理。 在对参数进行处理前使用 Kryo 拷贝了一份源数据,目的是保留方法调用时的原始参数,避免经过插件的 SQL 执行完成后,原始参数变成已加密的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        Object[] args = invocation.getArgs();
        MappedStatement ms = (MappedStatement) args[0];
        Object parameter = args[1];
        if (Util.encryptionRequired(parameter, ms.getSqlCommandType())) {
            Kryo kryo = null;
            try {
                kryo = KryoPool.obtain();
                Object copiedParameter = kryo.copy(parameter);
                boolean isParamMap = parameter instanceof MapperMethod.ParamMap;
                if (isParamMap) {
                    //noinspection unchecked
                    MapperMethod.ParamMap<Object> paramMap = (MapperMethod.ParamMap<Object>) copiedParameter;
                    encryptParamMap(paramMap);
                } else {
                    encryptEntity(copiedParameter);
                }
                args[1] = copiedParameter;
                Object result = invocation.proceed();
                if (!isParamMap) {
                    handleKeyProperties(ms, parameter, copiedParameter);
                }
                return result;
            } finally {
                if (kryo != null) {
                    KryoPool.free(kryo);
                }
            }
        } else {
            return invocation.proceed();
        }
    }

解密插件与加密插件类似,通过 Invocation 拿到查询 SQL 执行后返回的结果集,有两种情况:一种是返回 ArrayList,一种是返回单个实体,同样通过注解确定需要解密的字段对其进行解密然后覆盖掉原始加密的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        Object result = invocation.proceed();
        if (result == null) {
            return null;
        }
        if (result instanceof ArrayList) {
            //noinspection rawtypes
            ArrayList resultList = (ArrayList) result;
            if (resultList.isEmpty()) {
                return result;
            }
            Object firstItem = resultList.get(0);
            boolean needToDecrypt = Util.decryptionRequired(firstItem);
            if (!needToDecrypt) {
                return result;
            }
            Set<Field> encryptedFields = EncryptedFieldsProvider.get(firstItem.getClass());
            if (encryptedFields == null || encryptedFields.isEmpty()) {
                return result;
            }
            for (Object item : resultList) {
                decryptEntity(encryptedFields, item);
            }
        } else {
            if (Util.decryptionRequired(result)) {
                decryptEntity(EncryptedFieldsProvider.get(result.getClass()), result);
            }
        }
        return result;
    }

支持配置、方便扩展的实现

作为一个通用的组件(这里命名为 mybatis-crypto),支持配置和方便扩展是基本的要求。

mybatis-crypto 提供了以下几个配置项满足基本使用:

配置项说明默认值
mybatis-crypto.enabled是否启用 mybatis-cryptotrue
mybatis-crypto.fail-fast快速失败,加解密过程中发生异常是否中断。true:抛出异常,false:使用原始值,打印 warn 级别日志true
mybatis-crypto.mapped-key-prefixes@Param 参数名的前缀,前缀匹配则会进行加密处理
mybatis-crypto.default-encryptor全局默认 Encryptor
mybatis-crypto.default-key全局默认 Encryptor 的密钥

mybatis-crypto 核心包默认不提供具体的加解密方法,开发者可以通过引入 mybatis-crypto-encryptors 使用其提供的常用加解密类,或者实现 io.github.whitedg.mybatis.crypto.IEncryptor 自行扩展加解密方法。

Starter 封装

目前大多数项目都是基于 spring-boot 进行开发的,所以将 mybatis-crypto 封装成一个 starter 会更方便开发者使用。starter 的主要工作就是读取配置,自动装配,因此它的实现非常简单,只有两个类,MybatisCryptoProperties 获取配置,MyBatisCryptoAutoConfiguration 加载插件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Configuration
@ConditionalOnProperty(value = "mybatis-crypto.enabled", havingValue = "true", matchIfMissing = true)
@EnableConfigurationProperties(MybatisCryptoProperties.class)
public class MyBatisCryptoAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean(MybatisEncryptionPlugin.class)
    public MybatisEncryptionPlugin encryptionInterceptor(MybatisCryptoProperties properties) {
        return new MybatisEncryptionPlugin(properties.toMybatisCryptoConfig());
    }

    @Bean
    @ConditionalOnMissingBean(MybatisDecryptionPlugin.class)
    public MybatisDecryptionPlugin decryptionInterceptor(MybatisCryptoProperties properties) {
        return new MybatisDecryptionPlugin(properties.toMybatisCryptoConfig());
    }

}

总结

本文简单介绍了基于 mybatis 插件机制实现字段加解密的思路及流程,并将其封装成一个通用的 spring-boot-starter 组件,开发者可以方便的引入使用,同时也提供了加解密方法集合 mybatis-crypto-encryptors

组件的具体使用方法和示例:https://github.com/WhiteDG/mybatis-crypto

本文由作者按照 CC BY 4.0 进行授权