掘金 后端 ( ) • 2021-11-30 17:24
  • 背景
  • 思路
  • 实现
  • 思考

背景

最近接到需求需要对数据库中的电话、身份证号等敏感信息进行脱敏加密处理,再加上之前面试时也被问到相关问题,所有在此记录。脱敏对象是数据库的字段,所以在数据库存取的出入口进行加解密操作是最合适的,项目中使用mybatis作为ORM框架,所以使用基于mybatis的数据库脱敏。

思路

对数据库中的数据进行脱敏处理,核心思想就是在入库时对敏感字段进行加密,在出库时对敏感字段解密。看清了这个问题,我们的关注点就有两个。

  1. 何时?入库和出库
  2. 何地?入参和查询结果

mybatis框架中的plugin,能够对上面两个关注点进行很好的控制,再结合自定义注解,对需要脱敏的字段进行标注,就能够满足我们的需求。

实现

理论知识储备

  1. 定义自定义注解,用于标识敏感字段

    /**
     * 标识字段入库信息需要加密
     * @see com.vcg.veer.sign.utils.DesUtils
     * @author zhouyao
     * @date 2021/10/27 9:22 上午
     **/
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.FIELD)
    public @interface Encrypt {
    }
    
  2. mybatis插件逻辑(对项目中使用的pagehelper和mybatis-processor插件兼容)

    
    /**
     * 敏感字段入库、出库处理
     *
     * @author zhouyao
     * @date 2021/10/27 9:25 上午
     **/
    @Intercepts(
            {
                    @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
                    @Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})
            }
    )
    public class EncryptInterceptor implements Interceptor {
    
        private final String EXAMPLE_SUFFIX = "Example";
    
    
        @Override
        public Object intercept(Invocation invocation) throws Throwable {
            Object[] args = invocation.getArgs();
            MappedStatement ms = (MappedStatement) args[0];
            Object parameter = args[1];
            Class> argClass = parameter.getClass();
            String argClassName = argClass.getName();
            //兼容mybatis-processor
            if (needHandleExample(argClassName)){
                handleExample(args);
            }else{
                //自定义的mapper文件增删查改参数处理
                handleCustomizeMapperParams(args);
            }
    
            //update 方法
            if (args.length == 2 ){
                return invocation.proceed();
            }
            //兼容pagehelper
            if(args.length == 4){
                RowBounds rowBounds = (RowBounds) args[2];
                ResultHandler resultHandler = (ResultHandler) args[3];
                Executor executor = (Executor) invocation.getTarget();
                CacheKey cacheKey;
                BoundSql boundSql;
                //4 个参数时
                boundSql = ms.getBoundSql(parameter);
                cacheKey = executor.createCacheKey(ms, parameter, rowBounds, boundSql);
                List queryResult = executor.query(ms, parameter, rowBounds, resultHandler, cacheKey, boundSql);
                //处理需要解密的字段
                decryptFieldIfNeeded(queryResult);
                return queryResult;
            }
    
            return invocation.proceed();
        }
    
        /**
         * 对数据进行解密
         * @param queryResult
         */
        private void decryptFieldIfNeeded(List queryResult) throws IllegalAccessException {
            if (CollectionUtils.isEmpty(queryResult)) {
                return;
            }
            Object o1 = queryResult.get(0);
            Class> resultClass = o1.getClass();
            Field[] resultClassDeclaredFields = resultClass.getDeclaredFields();
            List needDecryptFieldList = new ArrayList<>();
            for (Field resultClassDeclaredField : resultClassDeclaredFields) {
                Encrypt encrypt = resultClassDeclaredField.getDeclaredAnnotation(Encrypt.class);
                if (encrypt == null){
                    continue;
                }
                Class> type = resultClassDeclaredField.getType();
                if (!String.class.isAssignableFrom(type)){
                    throw new IllegalStateException("@Encrypt should annotated on String field");
                }
                needDecryptFieldList.add(resultClassDeclaredField);
            }
            if (CollectionUtils.isEmpty(needDecryptFieldList)){
                return;
            }
            for (Field field : needDecryptFieldList) {
                field.setAccessible(true);
                for (Object o : queryResult) {
                    String fieldValue = (String) field.get(o);
                    if (!StringUtils.hasText(fieldValue)){
                        continue;
                    }
                    field.set(o,DesUtils.decrypt(fieldValue));
                }
            }
        }
    
        /**
         * 处理自定义mapper参数
         * @param args
         */
        private void handleCustomizeMapperParams(Object[] args) throws Exception {
            Object param = args[1];
            encryptObjectField(param);
        }
    
        private void encryptObjectField(Object param) throws Exception {
            Class> paramClass = param.getClass();
            //mybatis @param注解会处理为多参数
            if (Map.class.isAssignableFrom(paramClass)){
                Map mapParam = (Map) param;
                Set params = new HashSet<>();
                params.addAll(mapParam.values());
                for (Object o : params) {
                    encryptObjectField(o);
                }
                return;
            }
            Field[] paramClassDeclaredFields = paramClass.getDeclaredFields();
            // 遍历参数的所有字段查找需要加密的字段
            for (Field paramClassDeclaredField : paramClassDeclaredFields) {
                Encrypt encrypt = paramClassDeclaredField.getDeclaredAnnotation(Encrypt.class);
                if (encrypt != null){
                    //加密
                    encryptField(param,paramClassDeclaredField);
                }
            }
        }
    
        /**
         * 给指定字段加密
         * @param targetObj
         * @param paramClassDeclaredField
         */
        private void encryptField(Object targetObj, Field paramClassDeclaredField) throws Exception {
            paramClassDeclaredField.setAccessible(true);
            Class> type = paramClassDeclaredField.getType();
            Object fieldValue = paramClassDeclaredField.get(targetObj);
            if (fieldValue == null){
                return;
            }
    
            if (Collection.class.isAssignableFrom(type)) {
                try {
                    Collection collection = (Collection) fieldValue;
                    List tempList = new ArrayList<>();
                    Iterator iterator = collection.iterator();
                    while (iterator.hasNext()) {
                        String next = iterator.next();
                        tempList.add(DesUtils.encrypt(next));
                        iterator.remove();
                    }
                    collection.addAll(tempList);
                }catch (Exception ex){
                    //加密字段参数只支持String类型
                    throw new IllegalArgumentException("Encrypted fields only support String type");
                }
            }
            else if(String.class.isAssignableFrom(type)){
                //基础数据类型直接设值
                paramClassDeclaredField.set(targetObj, DesUtils.encrypt(fieldValue.toString()));
            }
            else if (isBasicType(type)) {
                //加密字段参数只支持String类型
                throw new IllegalArgumentException("Encrypted fields only support String type");
            } else {
                //递归调用
                encryptObjectField(fieldValue);
            }
        }
    
        private boolean isBasicType(Class> clz) {
            try {
                return ((Class) clz.getField("TYPE").get(null)).isPrimitive();
            } catch (Exception e) {
                return false;
            }
        }
    
        //兼容processor
        private void handleExample(Object[] args) throws Exception {
            Object arg = args[1];
            Class> argClass = arg.getClass();
            String argClassName = argClass.getName();
            //兼容 mybatis-processor
            if (argClassName.endsWith(EXAMPLE_SUFFIX)) {
                //实体类的类名
                String modelClassName = argClassName.substring(0, argClassName.length() - 7);
                Class> modelClass;
                try {
                    modelClass = Class.forName(modelClassName);
                }catch(ClassNotFoundException ex){
                    return;
                }
    
                Method getCriteria = argClass.getDeclaredMethod("getCriteria");
                getCriteria.setAccessible(true);
                Object criteria = getCriteria.invoke(arg);
                Class> criteriaClass = criteria.getClass();
                Method getAllCriteria = criteriaClass.getDeclaredMethod("getAllCriteria");
                Set criterions = (Set) getAllCriteria.invoke(criteria);
                for (Object criterionObj : criterions) {
                    Class> criterionClass = criterionObj.getClass();
                    Method getCondition = criterionClass.getDeclaredMethod("getCondition");
                    String condition = (String) getCondition.invoke(criterionObj);
                    //列名
                    String[] conditionParts = condition.split(" ");
                    if (conditionParts.length != 2){
                        continue;
                    }
                    String columnName = conditionParts[0];
                    //操作 >=< like
                    String operateType = conditionParts[1];
                    Field[] modelClassDeclaredFields = modelClass.getDeclaredFields();
                    for (Field modelClassDeclaredField : modelClassDeclaredFields) {
                        Column annotation = modelClassDeclaredField.getAnnotation(Column.class);
                        if (annotation == null){
                            continue;
                        }
                        if (columnName.equalsIgnoreCase(annotation.name())){
                            Encrypt encrypt = modelClassDeclaredField.getDeclaredAnnotation(Encrypt.class);
                            if (encrypt != null) {
                                //加密字段只能用等于比较
                                if (!"=".equalsIgnoreCase(operateType)) {
                                    throw new IllegalArgumentException("encrypt field only can be operate by '='");
                                }
                                Field value = criterionClass.getDeclaredField("value");
                                value.setAccessible(true);
    
                                List list = new ArrayList<>();
                                list.add(1);
                                //重新设置参数
                                value.set(criterionObj,list);
    
                                break;
                            }
                            break;
                        }
    
                    }
                }
            }
        }
    
        /**
         * 判断是否需要处理Example类型的查询
         * @param argClassName
         * @return
         */
        private boolean needHandleExample(String argClassName) {
            return argClassName.endsWith(EXAMPLE_SUFFIX);
        }
    
        private Object decryptIfNeeded(Invocation invocation) throws InvocationTargetException, IllegalAccessException {
            return invocation.proceed();
        }
    
        @Override
        public Object plugin(Object target) {
            return Plugin.wrap(target, this);
        }
    
        @Override
        public void setProperties(Properties properties) {
            Interceptor.super.setProperties(properties);
        }
    }
    
  3. 插件的使用

    在项目启动时注册插件(注意,根据mybatis插件的执行原理,此插件需要在最后注册,才能保证最先解析参数)

    SqlSessionFactoryBean bean = new SqlSessionFactoryBean();
    bean.setDataSource(dataSource);
    //加解密插件
    EncryptInterceptor encryptInterceptor = new EncryptInterceptor();
    
    //分页插件
    PageInterceptor pageInterceptor = new PageInterceptor();
    Properties properties = new Properties();
    properties.setProperty("reasonable", "true");
    properties.setProperty("supportMethodsArguments", "true");
    properties.setProperty("returnPageInfo", "check");
    properties.setProperty("params", "count=countSql");
    pageInterceptor.setProperties(properties);
    
    //添加插件
    bean.setPlugins(pageInterceptor,encryptInterceptor);
    

    对需要加密处理的字段标注@Encrypt注解(入参和结果DTO对象字段都需要标注)

        @Encrypt
        private String mobile;
    

思考

通过mybatis的插件对数据库的增删改查实现脱敏处理还是比较简单的。重点就在于:

  1. 拦截Executor对象的query和update方法,获取查询/更新参数和查询结果集
  2. 通过反射对参数中标注自定义注解的字段进行加/解密处理

在开发过程中也遇到了由于使用了pagehelper插件,导致自定义拦截器不生效的问题,最后查阅pagehelper的文档解决了(需要根据pagehelper定义的拦截器编写规范来开发)。

完整的代码参考:

https://github.com/zhouyao423/mybatis-encrypt

参考文档:

pagehelper interceptor高级用法

mybatis interceptor