编码技巧——数据加密(二)Mybatis拦截器

上一篇《数据加密(一)ShardingSphere》介绍了基于ShardingSphere的数据加密方案,本篇介绍基于Mybatis的加解密插件的原理及实现,以及遇到的困难;

为便于理解,还是先从背景开始介绍;

1. 需求背景

接到公司合规部门的指示,应工信部整改文件,限制各大互联网公司对用户隐私数据的收集;公司出于安全合规的考虑,需要对在数据库中的部分用户(明文存储)信息字段进行加密,防止未经授权的访问以及个人信息泄漏。

由于部分项目架构较老,未使用公司最新的ORM中间件框架(基于sharding-jdbc),因此与公司内部提供的加解密方案(shardingsphere)不兼容,改造的成本太大,因此我们选用了Mybatis插件来实现数据库加解密,保证往数据库写入数据时能对指定字段加密,读取数据时能对指定字段解密。

2. 思路与方案

2.1 系统设计架构

  1. 对每个需要加密的字段新增密文字段(对业务有侵入),修改数据库、mapper.xml以及DO对象,通过插件的方式把针对明文/密文字段的加解密进行收口。
  2. 自定义Executor对SELECT/UPDATE/INSERT/DELETE等操作的明文字段进行加密并设置到密文字段。
  3. 自定义插件ResultSetHandler负责针对查询结果进行解密,负责对SELECT等操作的密文字段进行解密并设置到明文字段。

2.2 系统执行流程

  1. 新增加解密流程控制开关,分别控制写入时是只写原字段/双写/只写加密后的字段,以及读取时是读原字段还是加密后的字段。
  2. 新增历史数据加密任务,对历史数据批量进行加密,写入到加密后字段。
  3. 出于安全上的考虑,流程里还会有一些校验/补偿的任务,这里不再赘述。

3. 方案制定

先简单介绍mybatis插件,然后分析mybatis的总体执行流程从而明确插件在何时织入、能做哪些事情,接着介绍并选择合适的插件类型,最终从而确定我们的方案——使用哪些拦截器及拦截器里需要做哪些事情;

3.1 Mybatis插件简介

Mybatis 预留了 org.apache.ibatis.plugin.Interceptor 接口,通过实现该接口,我们能对Mybatis的执行流程进行拦截,接口的定义如下:

public interface Interceptor {
 
  Object intercept(Invocation invocation) throws Throwable;
 
  Object plugin(Object target);
 
  void setProperties(Properties properties);
 
}

其中有三个方法:

  • intercept: 插件执行的具体流程,传入的Invocation是Mybatis对被代理的方法的封装;
  • plugin: 使用当前的Interceptor创建代理,通常的实现都是 Plugin.wrap(target, this),wrap方法内使用 jdk 创建动态代理对象;
  • setProperties: 参考下方代码,在Mybatis配置文件中配置插件时可以设置参数,在setProperties函数中调用 Properties.getProperty("param1") 方法可以得到配置的值
<plugins>
    <plugin interceptor="com.xx.xx.xxxInterceptor">
        <property name="param1" value="value1"/>
    </plugin>
</plugins>

在实现intercept函数对Mybatis的执行流程进行拦截前,我们需要使用@Intercepts注解指定拦截的方法。

@Intercepts({ 
        @Signature(type = Executor.class, method = "update", args = { MappedStatement.class, Object.class }),
        @Signature(type = Executor.class, method = "query", args = { MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class }) 
})

参考上方代码,我们可以指定需要拦截的类和方法。当然我们不能对任意的对象做拦截,Mybatis插件可拦截的类为以下四个:

  • Executor
  • StatementHandler
  • ParameterHandler
  • ResultSetHandler

回到数据库加密的需求,我们需要从上面4个类里选择能用来实现入参加密和出参解密的类。在介绍这四个类之前,需要对Mybatis的执行流程有一定的了解。

3.2 Mybatis整体执行流程 

1. spring通过sqlSessionFactoryBean创建sqlSessionFactory,在使用sqlSessionFactoryBean时,我们通常会指定configLocation和mapperLocations,来告诉sqlSessionFactoryBean去哪里读取配置文件以及去哪里读取mapper文件。

2. 得到配置文件和mapper文件的位置后,分别调用XmlConfigBuilder.parse()和XmlMapperBuilder.parse()创建Configuration和MappedStatement,Configuration类顾名思义,存放的是Mybatis所有的配置,而MappedStatement类存放的是每条sql语句的封装,MappedStatement以map的形式存放到Configuration对象中,key为对应方法的全路径。

3. spring通过ClassPathMapperScanner扫描所有的Mapper接口,为其创建BeanDefinition对象,但由于他们本质上都是没有被实现的接口,所以spring会将他们的BeanDefinition的beanClass属性修改为MapperFactorybean。

4. MapperFactoryBean也实现了FactoryBean接口,spring在创建Bean时会调用FactoryBean.getObject()方法获取Bean,最终是通过mapperProxyFactory的newInstance方法为mapper接口创建代理,创建代理的方式是JDK,最终生成的代理对象是MapperProxy。

5. 调用mapper的所有接口本质上调用的都是MapperProxy.invoke方法,内部调用sqlSession的insert/update/delete等各种方法。

// MapperMethod.java
public Object execute(SqlSession sqlSession, Object[] args) {
  Object result;
  if (SqlCommandType.INSERT == command.getType()) {
    Object param = method.convertArgsToSqlCommandParam(args);
    result = rowCountResult(sqlSession.insert(command.getName(), param));
  } else if (SqlCommandType.UPDATE == command.getType()) {
    Object param = method.convertArgsToSqlCommandParam(args);
    result = rowCountResult(sqlSession.update(command.getName(), param));
  } else if (SqlCommandType.DELETE == command.getType()) {
    Object param = method.convertArgsToSqlCommandParam(args);
    result = rowCountResult(sqlSession.delete(command.getName(), param));
  } else if (SqlCommandType.SELECT == command.getType()) {
    if (method.returnsVoid() && method.hasResultHandler()) {
      executeWithResultHandler(sqlSession, args);
      result = null;
    } else if (method.returnsMany()) {
      result = executeForMany(sqlSession, args);
    } else if (method.returnsMap()) {
      result = executeForMap(sqlSession, args);
    } else {
      Object param = method.convertArgsToSqlCommandParam(args);
      result = sqlSession.selectOne(command.getName(), param);
    }
  } else if (SqlCommandType.FLUSH == command.getType()) {
      result = sqlSession.flushStatements();
  } else {
    throw new BindingException("Unknown execution method for: " + command.getName());
  }
  if (result == null && method.getReturnType().isPrimitive() && !method.returnsVoid()) {
    throw new BindingException("Mapper method '" + command.getName()
        + " attempted to return null from a method with a primitive return type (" + method.getReturnType() + ").");
  }
  return result;
}


6. SqlSession可以理解为一次会话,SqlSession会从Configuration中获取对应的MappedStatement,交给Executor执行。

// DefaultSqlSession.java
@Override
public <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds) {
  try {
    // 从configuration对象中使用被调用方法的全路径,获取对应的MappedStatement
    MappedStatement ms = configuration.getMappedStatement(statement);
    return executor.query(ms, wrapCollection(parameter), rowBounds, Executor.NO_RESULT_HANDLER);
  } catch (Exception e) {
    throw ExceptionFactory.wrapException("Error querying database.  Cause: " + e, e);
  } finally {
    ErrorContext.instance().reset();
  }
}


7. Executor会先创建StatementHandler,StatementHandler可以理解为是一次语句的执行。

8. 然后Executor会获取连接,具体获取连接的方式取决于Datasource的实现,可以使用连接池等方式获取连接。

9. 之后调用StatementHandler.prepare方法,对应到jdbc执行流程中的Connection.prepareStatement这一步。

10. Executor再调用StatementHandler的parameterize方法,设置参数,对应到jdbc执行流程的StatementHandler.setXXX()设置参数,内部会创建ParameterHandler方法。

// SimpleExecutor.java
@Override
public <E> List<E> doQuery(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {
  Statement stmt = null;
  try {
    Configuration configuration = ms.getConfiguration();
    // 创建StatementHandler,对应第7步
    StatementHandler handler = configuration.newStatementHandler(wrapper, ms, parameter, rowBounds, resultHandler, boundSql);
    // 获取连接,再调用conncetion.prepareStatement创建prepareStatement,设置参数
    stmt = prepareStatement(handler, ms.getStatementLog());
    // 执行prepareStatement
    return handler.<E>query(stmt, resultHandler);
  } finally {
    closeStatement(stmt);
  }
}


11. 再由ResultSetHandler处理返回结果,处理jdbc的返回值,将其转换为java的对象。
 

3.3 Mybatis插件的创建时机

在Configuration类中,我们能看到newExecutor、newStatementHandler、newParameterHandler、newResultSetHandler这四个方法,插件的代理类就是在这四个方法中创建的,我以StatementHandeler的创建为例:

// Configuration.java
public StatementHandler newStatementHandler(Executor executor, MappedStatement mappedStatement, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) {
  StatementHandler statementHandler = new RoutingStatementHandler(executor, mappedStatement, parameterObject, rowBounds, resultHandler, boundSql);
  // 使用责任链的形式创建代理
  statementHandler = (StatementHandler) interceptorChain.pluginAll(statementHandler);
  return statementHandler;
}
 
InterceptorChain.java
public Object pluginAll(Object target) {
  for (Interceptor interceptor : interceptors) {
    target = interceptor.plugin(target);
  }
  return target;
}

interceptor.plugin对应到我们自己实现的interceptor里的方法,通常的实现时 Plugin.wrap(target, this); ,该方法内部创建代理的方式为jdk动态代理InvocationHandler。

3.4 Mybatis插件可拦截类选择

Mybatis本质上是对jdbc执行流程的封装。结合上图我们简要概括下Mybatis这几个可被代理类的职能:

  • Executor: 真正执行sql语句的对象,调用sqlSession的方法时,本质上都是调用executor的方法,还负责获取connection,创建StatementHandler。
  • StatementHandler: 创建并持有ParameterHandler和ResultSetHandler对象,操作jdbc的statement与进行数据库操作。
  • ParameterHandler: 处理入参,将java方法上的参数设置到被执行语句中。
  • ResultSetHandler: 处理sql语句的执行结果,将返回值转换为java对象。

对于入参的加密,我们需要在ParameterHandler调用prepareStatement.setXXX()方法设置参数前,将参数值修改为加密后的参数,这样一看好像拦截Executor/StatementHandler/ParameterHandler都可以。

但实际上呢?——由于我们的并不是在原始字段上做加密,而是新增了一个加密后字段,这会带来什么问题?请看下面这条mapper.xml文件中加了加密后字段的动态sql:

 <select id="selectUserList" resultMap="BaseResultMap" parameterType="com.xxx.internet.demo.entity.UserInfo">
        SELECT
        *
        FROM
        `t_user_info`
        <where>
            <if test="phoneNum != null">
                `phone_num` = #{phoneNum}
            </if>
			<!-- 明文字段 -->
            <if test="secret != null">
                AND `secret` = #{secret}
            </if>
			<!-- 加密后字段-->
            <if test="secretCiper != null">
                AND `secret_ciper` = #{secretCiper}
            </if>
            <if test="name">
                AND `name` = #{name}
            </if>
        </where>
        ORDER BY `update_time` DESC
    </select>

可以看到这条语句带了动态标签,那肯定不能直接交给jdbc创建prepareStatement,需要先将其解析成静态sql,而这一步是在Executor在调用StatementHandler.parameterize()前做的,由MappedStatementHandler.getBoundSql(Object parameterObject)函数解析动态标签,生成静态sql语句,这里的parameterObject我们可以暂时先将其看成一个Map,键值分别为参数名和参数值。

那么我们来看下用StatementHandler和ParameterHandler做参数加密会有什么问题——

在执行MappedStatementHandler.getBoundSql时,parameterObject中并没有写入加密后的参数,在判断<if test="secretCiper != null">标签时必定为否,最后生成的静态sql必然不包含加密后的字段,后续不管我们在StatementHandler和ParameterHandler中怎么处理parameterObject,都无法实现入参的加密。

因此,在入参的加密上我们只能选择拦截Executor的update和query方法。

那么返回值的解密呢?参考流程图,我们能对ResultSetHandler和Executor做拦截,事实也确实如此,在处理返回值这一点上,这两者是等价的,ResultSetHandler.handleResultSet()的返回值直接透传给Executor,再由Executor透传给SqlSession,所以两者任选其一就可以。

4. 方案执行

在知道需要拦截的对象后,就可以开始实现加解密插件了。首先定义一个方法维度的注解。

/**
 * 通过注解来表明,我们需要对那个字段进行加密
 */
@Target({ ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface TEncrypt {
    /**
     * 加密时从srcKey到destKey
     * @return
     */
    String[] srcKey() default {};
 
    /**
     * 解密时从destKey到srcKey
     * @return
     */
    String[] destKey() default {};
}

将该注解打在需要加解密的DAO层方法上。

// UserMapper.java
public interface UserMapper {
    @TEncrypt(srcKey = {"secret"}, destKey = {"secretCiper"})
    List<UserInfo> selectUserList(UserInfo userInfo);
}

然后,修改xxxMapper.xml文件

<mapper namespace="com.xxx.internet.demo.mapper.UserMapper">
    <resultMap id="BaseResultMap" type="com.xxx.internet.demo.entity.UserInfo">
        <id column="id" jdbcType="BIGINT" property="id" />
        <id column="phone" jdbcType="VARCHAR" property="phone"/>
        <id column="secret" jdbcType="VARCHAR" property="secret"/>
		<!--加密后映射-->
        <id column="secret_ciper" jdbcType="VARCHAR" property="secretCiper"/>
        <id column="name" jdbcType="VARCHAR" property="name" />
    </resultMap>
 
    <select id="selectUserList" resultMap="BaseResultMap" parameterType="com.xxx.internet.demo.entity.UserInfo">
        SELECT
        *
        FROM
        `t_user_info`
        <where>
            <if test="phone != null">
                `phone` = #{phone}
            </if>
			<!--明文字段-->
            <if test="secret != null">
                AND `secret` = #{secret}
            </if>
			<!--加密后字段-->
            <if test="secretCiper != null">
                AND `secret_ciper` = #{secretCiper}
            </if>
            <if test="name">
                AND `name` = #{name}
            </if>
        </where>
        ORDER BY `update_time` DESCv
    </select>
</mapper>

做完上面的修改,我们就可以编写加密插件了:

@Intercepts({@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}),
        @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})})
public class ExecutorEncryptInterceptor implements Interceptor {
    private static final ObjectFactory DEFAULT_OBJECT_FACTORY = new DefaultObjectFactory();

    private static final ObjectWrapperFactory DEFAULT_OBJECT_WRAPPER_FACTORY = new DefaultObjectWrapperFactory();
 
    private static final ReflectorFactory REFLECTOR_FACTORY = new DefaultReflectorFactory();

    private static final List<String> COLLECTION_NAME = Arrays.asList("list");

    private static final String COUNT_SUFFIX = "_COUNT";

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

        // 获取拦截器拦截的设置参数对象DefaultParameterHandler
        final Object[] args = invocation.getArgs();
        MappedStatement mappedStatement = (MappedStatement) args[0];
        Object parameterObject = args[1];

        // id字段对应执行的SQL的方法的全路径,包含类名和方法名
        String id = mappedStatement.getId();
        String className = id.substring(0, id.lastIndexOf("."));
        String methodName = id.substring(id.lastIndexOf(".") + 1);

        // 分页插件会生成一个count语句,这个语句的参数也要做处理
        if (methodName.endsWith(COUNT_SUFFIX)) {
            methodName = methodName.substring(0, methodName.lastIndexOf(COUNT_SUFFIX));
        }

        // 动态加载类并获取类中的方法
        final Method[] methods = Class.forName(className).getMethods();

        // 遍历类的所有方法并找到此次调用的方法
        for (Method method : methods) {
            if (method.getName().equalsIgnoreCase(methodName) && method.isAnnotationPresent(TEncrypt.class)) {

                // 获取方法上的注解以及注解对应的参数
                TEncrypt paramAnnotation = method.getAnnotation(TEncrypt.class);

                // 支持加密的操作,这里只修改参数
                if (parameterObject instanceof Map) {
                    List<String> paramAnnotations = findParams(method);
                    parameterMapHandler((Map) parameterObject, paramAnnotation, mappedStatement.getSqlCommandType(), paramAnnotations);
                } else {
                    encryptParam(parameterObject, paramAnnotation, mappedStatement.getSqlCommandType());
                }
            }
        }

        return invocation.proceed();
    }
}

加密的主体流程如下

  1. 判断本次调用的方法上是否注解了@TEncrypt。
  2. 获取注解以及在注解上配置的参数。
  3. 遍历parameterObject,找到需要加密的字段。
  4. 调用加密方法,得到加密后的值。
  5. 将加密后的字段和值写入parameterObject。

5. 难点与挑战

5.1 parameterObject的解析

到了Executor这一层,parameterObject已经不再是简单的Object[],而是由MapperMethod.convertArgsToSqlCommandParam(Object[] args)方法创建的一个对象,既然要对这个对象做处理,我们肯定得先知道它的创建过程:

参考上图parameterObject的创建过程,加密插件对parameterObject的处理本质上是一个逆向的过程。如果是list,我们就遍历list里的每一个值,如果是map,我们就遍历map里的每一个值。

得到需要处理的Object后,再遍历Object里的每个属性,判断是否在@TEncrypt注解的srcKeys参数中,如果是,则加密再设置到Object中。

解密插件的逻辑和加密插件基本一致,这里不再赘述。

5.2 Mybatis分页插件PageHelper自动生成的count语句如何对明文密文做处理?

业务代码里很多地方都用了 com.github.pagehelper 进行物理分页,参考下面的demo,在使用PageRowBounds时,pagehelper插件会帮我们获取符合条件的数据总数并设置到rowBounds对象的total属性中。

PageRowBounds rowBounds = new PageRowBounds(0, 10);
List<User> list = userMapper.selectIf(1, rowBounds);
long total = rowBounds.getTotal();

那么问题来了,表面上看,我们只执行了userMapper.selectIf(1, rowBounds)这一条语句,而pagehelper是通过改写sql增加limit、offset实现的物理分页,在整个语句的执行过程中没有从数据库里把所有符合条件的数据读出来,那么pagehelper是怎么得到数据的总数的呢?

答案是——pagehelper会再执行一条count语句。先不说额外一条执行count语句的原理,我们先看看加了一条count语句会导致什么问题。

参考之前的selectUserList接口,假设我们想选择secret为某个值的数据,那么经过加密插件的处理后最终执行的大致是这样一条语句 "select * from t_user_info where secret_ciper = ? order by update_time limit ?, ?"。

但由于pagehelper还会再执行一条语句,而由于该语句并没有 @TEncrypt 注解,所以是不会被加密插件拦截的,最终执行的count语句是类似这样的: "select count(*) from t_user_info where secret = ? order by update_time"。

可以明显的看到第一条语句是使用secret_ciper作为查询条件,而count语句是使用secret作为查询条件,会导致最终得到的数据总量和实际的数据总量不一致

因此——我们在加密插件的代码里对count语句做了特殊处理:

由于pagehelper新增的count语句对应的mappedStatement的id固定以"_COUNT"结尾,而这个id就是对应的mapper里的方法的全路径,举例来说原始语句的id是"com.xxx.internet.demo.entity.UserInfo.selectUserList",那么count语句的id就是"com.xxx.internet.demo.entity.UserInfo.selectUserList_COUNT",去掉"_COUNT"后我们再判断对应的方法上有没有注解就可以了。

附上完整的代码:

1. 改写SQL执行时的执行参数parameterObject来动态改写SQL,填充密文参数,从而在SQL语句中加上密文列

package com.xx.dal.common.interceptor;

import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.util.*;

import org.apache.ibatis.annotations.Param;
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.Plugin;
import org.apache.ibatis.plugin.Signature;
import org.apache.ibatis.reflection.DefaultReflectorFactory;
import org.apache.ibatis.reflection.MetaObject;
import org.apache.ibatis.reflection.ReflectorFactory;
import org.apache.ibatis.reflection.factory.DefaultObjectFactory;
import org.apache.ibatis.reflection.factory.ObjectFactory;
import org.apache.ibatis.reflection.wrapper.DefaultObjectWrapperFactory;
import org.apache.ibatis.reflection.wrapper.ObjectWrapperFactory;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;

import com.xx.client.ConfigManager;
import com.xx.dal.common.interceptor.annotation.TEncrypt;
import com.xx.dal.common.interceptor.utils.EncryptUtils;
import com.xx.dal.common.interceptor.utils.PluginUtils;

/**
 * SqlEncryptInterceptor
 */
@Intercepts({@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}),
        @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})})
public class ExecutorEncryptInterceptor implements Interceptor {

    private static final ObjectFactory DEFAULT_OBJECT_FACTORY = new DefaultObjectFactory();

    private static final ObjectWrapperFactory DEFAULT_OBJECT_WRAPPER_FACTORY = new DefaultObjectWrapperFactory();

    private static final ReflectorFactory REFLECTOR_FACTORY = new DefaultReflectorFactory();

    private static final List<String> COLLECTION_NAME = Arrays.asList("list");

    private static final String COUNT_SUFFIX = "_COUNT";

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

        // 获取拦截器拦截的设置参数对象DefaultParameterHandler
        final Object[] args = invocation.getArgs();
        MappedStatement mappedStatement = (MappedStatement) args[0];
        Object parameterObject = args[1];

        // id字段对应执行的SQL的方法的全路径,包含类名和方法名
        String id = mappedStatement.getId();
        String className = id.substring(0, id.lastIndexOf("."));
        String methodName = id.substring(id.lastIndexOf(".") + 1);

        // 分页插件会生成一个count语句,这个语句的参数也要做处理
        if (methodName.endsWith(COUNT_SUFFIX)) {
            methodName = methodName.substring(0, methodName.lastIndexOf(COUNT_SUFFIX));
        }

        // 动态加载类并获取类中的方法
        final Method[] methods = Class.forName(className).getMethods();

        // 遍历类的所有方法并找到此次调用的方法
        for (Method method : methods) {
            if (method.getName().equalsIgnoreCase(methodName) && method.isAnnotationPresent(TEncrypt.class)
                    && method.getAnnotation(TEncrypt.class).enableExecutorPlugin()) {

                // 获取方法上的注解以及注解对应的参数
                TEncrypt paramAnnotation = method.getAnnotation(TEncrypt.class);

                // 支持加密的操作,这里只修改参数
                if (parameterObject instanceof Map) {
                    List<String> paramAnnotations = findParams(method);
                    parameterMapHandler((Map) parameterObject, paramAnnotation, mappedStatement.getSqlCommandType(), paramAnnotations);
                } else {
                    encryptParam(parameterObject, paramAnnotation, mappedStatement.getSqlCommandType());
                }
            }
        }

        return invocation.proceed();
    }

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

    @Override
    public void setProperties(Properties properties) {

    }

    /**
     * 获取annotation的注解信息
     *
     * @param method
     * @return
     */
    private List<String> findParams(Method method) {

        List<String> paramAnnotationList = new ArrayList<>();

        Annotation[][] annotations = method.getParameterAnnotations();
        paramAnnotationList.addAll(COLLECTION_NAME);

        for (Annotation[] anArr : annotations) {
            for (Annotation annotation : anArr) {
                if (annotation instanceof Param) {
                    paramAnnotationList.add(((Param) annotation).value());
                }
            }
        }

        return paramAnnotationList;
    }

    /**
     * 处理参数
     *
     * @param parameterMap
     * @param paramAnnotation
     * @param sqlCommandType
     * @throws Exception
     */
    private void parameterMapHandler(Map<String, Object> parameterMap, TEncrypt paramAnnotation, SqlCommandType sqlCommandType, List<String> paramAnnotations) throws Exception {

        for (Map.Entry<String, Object> entry : parameterMap.entrySet()) {

            if (null != entry.getValue() && entry.getValue() instanceof Collection) {
                // 针对list的情况只针对包含@Param注解的字段和mybatis默认的list字段进行加密
                if (paramAnnotations.contains(entry.getKey())) {
                    // 针对列表进行特殊处理
                    Iterator iterator = ((Collection) entry.getValue()).iterator();
                    while (iterator.hasNext()) {
                        Object obj = iterator.next();

                        encryptParam(obj, paramAnnotation, sqlCommandType);
                    }
                }

            } else if (null != entry.getValue() && isNotPrimitive(entry.getValue().getClass())) {
                // 针对自定义Bean对象进行处理
                encryptParam(entry.getValue(), paramAnnotation, sqlCommandType);

            }
        }

        // 针对Map整体进行次加密处理
        encryptParam(parameterMap, paramAnnotation, sqlCommandType);
    }

    private void encryptParam(Object parameterObject, TEncrypt paramAnnotation, SqlCommandType sqlCommandType) throws Exception {

        for (int i = 0; i < paramAnnotation.srcKey().length; i++) {
            String srcKey = paramAnnotation.srcKey()[i];
            String destKey = paramAnnotation.destKey()[i];

            // 反射获取参数对象
            MetaObject param = MetaObject.forObject(parameterObject, DEFAULT_OBJECT_FACTORY, DEFAULT_OBJECT_WRAPPER_FACTORY, REFLECTOR_FACTORY);

            // 入参中可能没有加密的字段
            if (!param.hasGetter(srcKey) || null == param.getValue(srcKey)) {
                continue;
            }

            Object srcValue = param.getValue(srcKey);

            // 针对INSERT、UPDATE、DELETE等SQL插入语句
            if (PluginUtils.isWriteSql(sqlCommandType)) {
                // 明文字段保留开关,同步写入没问题后不再写入明文字段,需要确认DB表该字段是否允许为空
                boolean writeStrategySwitch = ConfigManager.getBoolean("encrypt.strategy.write.plain.field.remain.switch", true);
                if (!writeStrategySwitch) {
                    param.setValue(srcKey, null);
                }

                if (null != srcValue) {
                    String destValue = EncryptUtils.encrypt(String.valueOf(srcValue));
                    param.setValue(destKey, destValue);
                }
            } else if (PluginUtils.isReadSql(sqlCommandType)) {
                // 针对SELECT等SQL查询语句
                Boolean readStrategySwitch = ConfigManager.getBoolean("encrypt.strategy.read.plain.field.remain.switch", true);
                if (!readStrategySwitch) {
                    // 使用密文进行查询,原明文直接为null
                    param.setValue(srcKey, null);

                    if (null != srcValue) {
                        String destValue = EncryptUtils.encrypt(String.valueOf(srcValue));
                        param.setValue(destKey, destValue);
                    }
                } else {
                    // 设置密文为null字段
                    if (param.hasSetter(destKey)) {
                        param.setValue(destKey, null);
                    }
                }
            }
        }
    }
    
    /**
     * 是否非原数据对象(基本类型及其包装类)
     *
     * @param clazz
     * @return
     */
    private static boolean isNotPrimitive(Class<?> clazz) {
        if (Collection.class.isAssignableFrom(clazz) || Map.class.isAssignableFrom(clazz)) {
            return true;
        }

        return !clazz.isPrimitive() && clazz.getPackage() != null
                && !clazz.isEnum() && clazz != Character.class
                && clazz != Integer.class && clazz != Boolean.class
                && clazz != Long.class && clazz != Short.class
                && clazz != Byte.class && clazz != BigDecimal.class
                && clazz != BigInteger.class && clazz != Date.class && clazz != Float.class
                && !startsWith(clazz.getPackage().getName(), "javax.")
                && !startsWith(clazz.getPackage().getName(), "java.")
                && !startsWith(clazz.getName(), "javax.")
                && !startsWith(clazz.getName(), "java.");
    }
    
    /**
     * 判断是否以某字符串开头
     *
     * @param str
     * @param start
     * @return
     */
    private static boolean startsWith(String str, String start) {
        if (str == null || str.isEmpty()) {
            return false;
        }

        return str.startsWith(start);
    }
}

2. 对返回结果拦截,用密文解密后的值替换明文查询结果

package com.xx.dal.common.interceptor;

import java.lang.reflect.Method;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Properties;

import org.apache.commons.lang3.StringUtils;
import org.apache.ibatis.executor.resultset.ResultSetHandler;
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.Plugin;
import org.apache.ibatis.plugin.Signature;
import org.apache.ibatis.reflection.DefaultReflectorFactory;
import org.apache.ibatis.reflection.MetaObject;
import org.apache.ibatis.reflection.ReflectorFactory;
import org.apache.ibatis.reflection.factory.DefaultObjectFactory;
import org.apache.ibatis.reflection.factory.ObjectFactory;
import org.apache.ibatis.reflection.wrapper.DefaultObjectWrapperFactory;
import org.apache.ibatis.reflection.wrapper.ObjectWrapperFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.xx.client.ConfigManager;
import com.xx.dal.common.interceptor.annotation.TEncrypt;
import com.xx.dal.common.interceptor.utils.EncryptUtils;

/**
 * 通过拦截器对返回结果中的某个字段进行加密处理
 */
@Intercepts({@Signature(type = ResultSetHandler.class, method = "handleResultSets", args = {Statement.class})})
public class ResultDecryptInterceptor implements Interceptor {

    private static final Logger log = LoggerFactory.getLogger(ResultDecryptInterceptor.class);

    private static final ObjectFactory DEFAULT_OBJECT_FACTORY = new DefaultObjectFactory();

    private static final ObjectWrapperFactory DEFAULT_OBJECT_WRAPPER_FACTORY = new DefaultObjectWrapperFactory();

    private static final ReflectorFactory REFLECTOR_FACTORY = new DefaultReflectorFactory();

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        // 获取到返回结果
        ResultSetHandler resultSetHandler = (ResultSetHandler) invocation.getTarget();
        MetaObject metaResultSetHandler = MetaObject.forObject(resultSetHandler, DEFAULT_OBJECT_FACTORY, DEFAULT_OBJECT_WRAPPER_FACTORY, REFLECTOR_FACTORY);
        MappedStatement mappedStatement = (MappedStatement) metaResultSetHandler.getValue("mappedStatement");
        TEncrypt annotation = getEncryptResultFieldAnnotation(mappedStatement);
        Object returnValue = invocation.proceed();
        if (annotation != null && returnValue != null) {
            // 对结果进行处理
            try {
                if (returnValue instanceof ArrayList<?>) {
                    List<?> list = (ArrayList<?>) returnValue;
                    for (int index = 0; index < list.size(); index++) {
                        Object returnItem = list.get(index);
                        if (returnItem instanceof String) {
                            List<String> stringList = (List<String>) list;
                            stringList.set(index, EncryptUtils.decrypt((String) returnItem));
                        } else {

                            // 是否使用密文解密后的值覆盖原明文字段开关 若无异常则一直打开 明文清理后必须一直打开
                            boolean strategySwitch = ConfigManager.getBoolean("decrypt.result.strategy.switch", false);
                            // 明文密文不一致校验开关 灰度期间打开
                            boolean validateSwitch = ConfigManager.getBoolean("decrypt.result.validate.switch", true);
                            // 明文密文不一致时的日志开关 灰度期间打开
                            boolean validateLogSwitch = ConfigManager.getBoolean("decrypt.result.validate.log.switch", true);

                            MetaObject res = MetaObject.forObject(returnItem, DEFAULT_OBJECT_FACTORY, DEFAULT_OBJECT_WRAPPER_FACTORY, REFLECTOR_FACTORY);

                            for (int i = 0; i < annotation.srcKey().length; i++) {
                                String srcKey = annotation.srcKey()[i];
                                String destKey = annotation.destKey()[i];

                                // 过滤值不存在
                                if (!res.hasGetter(destKey) || null == res.getValue(destKey)) {
                                    continue;
                                }

                                // 对密文字段进行解密
                                Object destValue = res.getValue(destKey);
                                String decryptDestValue = null;
                                // 保证数据不为null且不为空才进行解密操作
                                if (null != destValue && StringUtils.isNotEmpty(String.valueOf(destValue))) {
                                    decryptDestValue = EncryptUtils.decrypt((String) destValue);
                                }

                                // 获取明文字段
                                Object srcValue = null;
                                if (res.hasGetter(srcKey)) {
                                    srcValue = res.getValue(srcKey);
                                }

                                // 对同时存在密文和明文的灰度情况进行对比
                                if (null != decryptDestValue && null != srcValue && validateSwitch) {
                                    if (!Objects.equals(decryptDestValue, srcValue) && validateLogSwitch) {
                                        log.error("数据加解密返回值校验失败,密文解密前 = {} 密文解密后={}, 明文数据={}", destValue, decryptDestValue, srcValue);
                                    }
                                }

                                // 密文解密后覆盖明文字段
                                if (strategySwitch && null != decryptDestValue) {
                                    res.setValue(srcKey, decryptDestValue);
                                }
                            }
                        }
                    }
                }
            } catch (Exception e) {
                log.error("数据加解密返回值处理失败", e);
                // 这里加告警而不抛出异常中断mybatis的执行
                // throw e;
            }
        }

        return returnValue;
    }

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

    @Override
    public void setProperties(Properties properties) {

    }

    /**
     * 获取方法上的EncryptResultFieldAnnotation注解
     *
     * @param mappedStatement MappedStatement
     * @return EncryptResultFieldAnnotation注解
     */
    private TEncrypt getEncryptResultFieldAnnotation(MappedStatement mappedStatement) {
        TEncrypt encryptResultFieldAnnotation = null;
        try {
            String id = mappedStatement.getId();
            String className = id.substring(0, id.lastIndexOf("."));
            String methodName = id.substring(id.lastIndexOf(".") + 1);
            final Method[] method = Class.forName(className).getMethods();
            for (Method me : method) {
                if (me.getName().equals(methodName) && me.isAnnotationPresent(TEncrypt.class)) {
                    encryptResultFieldAnnotation = me.getAnnotation(TEncrypt.class);
                    break;
                }
            }
        } catch (Exception ex) {
            log.error("数据加解密获取注解异常 ", ex);
        }
        return encryptResultFieldAnnotation;
    }

}

3. 注解和工具类

package com.xx.dal.common.interceptor.annotation;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * 通过注解来表明,我们需要对那个字段进行加密
 */
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface TEncrypt {
    /**
     * 加密时从srcKey到destKey
     *
     * @return
     */
    String[] srcKey() default {};

    /**
     * 解密时从destKey到srcKey
     *
     * @return
     */
    String[] destKey() default {};

    /**
     * 是否生效executor级别的拦截器
     * 注意,如果和其他Executor拦截器公用,务必放在第一位。
     *
     * @return
     */
    boolean enableExecutorPlugin() default true;
}

工具类:

(1)判断SqlCommandType的类型

package com.xx.dal.common.interceptor.utils;

import org.apache.ibatis.mapping.SqlCommandType;

/**
 * 判断SqlCommandType的类型是否读/写
 */
public class PluginUtils {
    
    public static boolean isWriteSql(SqlCommandType sqlCommandType) {
        return sqlCommandType.equals(SqlCommandType.DELETE) || sqlCommandType.equals(SqlCommandType.INSERT) || sqlCommandType.equals(SqlCommandType.UPDATE);
    }

    public static boolean isReadSql(SqlCommandType sqlCommandType) {
        return sqlCommandType.equals(SqlCommandType.SELECT);
    }
}

(2)加密工具类 EncryptUtils.java

由于涉及加密算法和方案,这里不详细展开代码;实际是调用加密服务的二方包里面的client来执行加解密;

实际上并没有RPC调用,并且client也没有作为bean注入进来,而是使用本地加解密(性能问题),原理是:1. 将秘钥冗余在了密文中来来做解密,2. 而加密的秘钥在类初始化时通过client向远程拉取存在本地缓存中,如写在static静态块中;

最终,需要自己声明这个加密工具类,并在static块中调用SDK中的init方法,自己写encrypt(String plainText)加密方法和decrypt(String cipherText)解密方法,通过包装好的client调用,需要传入应用key和秘钥ID来做简单的鉴权;

4. 在Mybatis的配置文件mybatis-config.xml中注册Mybatis拦截器

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
        PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
    <!--配置全局属性-->
    <settings>
		 <!-省略-->
    </settings>

    <plugins>
        <plugin interceptor="com.xx.dal.common.interceptor.ResultDecryptInterceptor"/>
        <plugin interceptor="com.xx.dal.common.interceptor.ExecutorEncryptInterceptor"/>
    </plugins>
</configuration>

5. 使用示例:在Mapper接口的方法上打上注解

    /**
     * 查询:根据id查询用户设备信息
     */
    @TEncrypt(srcKey = {"imei", "emmcid"}, destKey = {"imeiCipher", "emmcidCipher"})
    List<UserDeviceInfoDO> queryByIds(List<Long> list);
	
	/**
     * 批量插入:备份记录
     */
    @TEncrypt(srcKey = {"imei", "emmcid"}, destKey = {"imeiCipher", "emmcidCipher"})
    Integer batchInsert4Backup(List<UserDeviceInfoBackupDO> list);
	
	/**
     * 更新:更新记录
     */
	@TEncrypt(srcKey = { "phoneNum" }, destKey = { "phoneNumCipher" })
    int updateByPrimaryKeySelective(UserDeviceInfoDO record);

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值