【JAVA技术】mybatis 数据库敏感字段加解密方案

引言:自从有公司项目前2年做了三级等保,每年一度例行公事,昨天继续配合做等保测试。这2天比较忙,这里整理之前写的一篇等保技术文章。

正文:

现在公司项目基本用mybatis实现,但由于项目跨度年份比较久, 技术实现分为2种,早期项目是用mybatis-generator实现, 新项目是用mybatis-plus实现, 这里讲一下分别用不同的方式实现数据库敏感字段加解密。

敏感字段加解密,比如手机号、姓名、身份证、住址、邮箱,原理比较简单,在数据插入前对数据进行加密,数据查询出来的时候再解密。

1、先说加密算法, 已有的老项目,可以用AES,毕竟修复数据可以通过mysql的sql语法直接搞定。直接上sql。新项目,推荐用国密SM4

java 复制代码
select hex(AES_ENCRYPT('hello world! 张三-123', '1234567890123456'));

select AES_DECRYPT(UNHEX('42A8DAF413119F35A746CD231A955E7501C1E3E3D785CD892795EAE387B379BF'),'1234567890123456') ;

2、mybatis-generator项目,实现mybatis的typeHandler

java 复制代码
/**
 * 注意不能把 BaseTypeHandler 改为 BaseTypeHandler<String> ,如果改为 BaseTypeHandler<String> 的话,
 * 所有 String 类型的字段都会给加密了。改为 BaseTypeHandler 在需要加密的地方加上 typeHandler 就好了
 */
public class EncryptTypeHandler extends BaseTypeHandler {
    @SneakyThrows
    @Override
    public void setNonNullParameter(PreparedStatement preparedStatement, int i, Object o, JdbcType jdbcType) {
        preparedStatement.setString(i, BizUtil.encryptData_ECB((String) o));
    }


    /**
     * 用于在Mybatis获取数据结果集时如何把数据库类型转换为对应的Java类型
     *
     * @param rs         当前的结果集
     * @param columnName 当前的字段名称
     * @return 转换后的Java对象
     * @throws SQLException
     */
    @SneakyThrows
    @Override
    public String getNullableResult(ResultSet rs, String columnName) {
        String r = rs.getString(columnName);
        return r == null ? null : BizUtil.decryptData_ECB(r);
    }

    /**
     * 用于在Mybatis通过字段位置获取字段数据时把数据库类型转换为对应的Java类型
     *
     * @param rs          当前的结果集
     * @param columnIndex 当前字段的位置
     * @return 转换后的Java对象
     * @throws SQLException
     */
    @SneakyThrows
    @Override
    public String getNullableResult(ResultSet rs, int columnIndex) {
        String r = rs.getString(columnIndex);
        return r == null ? null : BizUtil.decryptData_ECB(r);
    }

    /**
     * 用于Mybatis在调用存储过程后把数据库类型的数据转换为对应的Java类型
     *
     * @param cs          当前的CallableStatement执行后的CallableStatement
     * @param columnIndex 当前输出参数的位置
     * @return
     * @throws SQLException
     */
    @SneakyThrows
    @Override
    public String getNullableResult(CallableStatement cs, int columnIndex) {
        String r = cs.getString(columnIndex);
        // 兼容待修复的数据
        return r == null ? null : BizUtil.decryptData_ECB(r);
    }

}

mapper文件对应加密字段属性实现

java 复制代码
<result column="user_mobile" jdbcType="VARCHAR"

property="userMobile"

typeHandler="com.company.group.project.util.mybatis.

EncryptTypeHandler"/>

插入、更新对应属性加上

java 复制代码
#{userMobile,jdbcType=VARCHAR, typeHandler=

com.company.group.project.util.mybatis.EncryptTypeHandler},

老项目埋坑点:

a、手写的mapper 人肉处理,通过建resultMap设置属性

b、敏感字段作为参数搜索,必须加密后传递查询。 正常来说,在baseService做 selectByExample封装, 不少同学之前在extendService甚至更高层级操作了mapper, 改的苦笑不得

3、mybatis-plus项目, 之前mybatis版本之前用的低版本3.1,为了用上mybatis-plus 3.4的JsqlParserSupport等,把springboot1.5.x 升级到了springboot2.x, 这个过程走了一些弯路,所幸成功了。

mybatis-plus主要通过对象属性注解反射实现的,参照了网上的一些代码,代码改动量相对比较少。 敏感字段作为参数搜索,必须加密传递查询。

加密插件:

java 复制代码
public class EncryptInterceptor extends JsqlParserSupport implements InnerInterceptor {
    /**
     * 变量占位符正则
     */
    private static final Pattern PARAM_PAIRS_RE = Pattern.compile("#\\{ew\\.paramNameValuePairs\\.(" + Constants.WRAPPER_PARAM + "\\d+)\\}");

    @Override
    public void beforePrepare(StatementHandler sh, Connection connection, Integer transactionTimeout) {
        InnerInterceptor.super.beforePrepare(sh, connection, transactionTimeout);
        //System.out.println("================================================================================beforePrepare");
    }


    /**
     * 如果查询条件是加密数据列,那么要将查询条件进行数据加密。
     * 例如,手机号加密存储后,按手机号查询时,先把要查询的手机号进行加密,再和数据库存储的加密数据进行匹配
     */
    @Override
    public void beforeQuery(Executor executor, MappedStatement mappedStatement, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {
        //System.out.println("================================================================================beforeQuery");
        if (Objects.isNull(parameterObject)) {
            return;
        }
        if (!(parameterObject instanceof Map)) {
            return;
        }
        Map paramMap = (Map) parameterObject;
        // 参数去重,否则多次加密会导致查询失败
        Set set = (Set) paramMap.values().stream().collect(Collectors.toSet());
        for (Object param : set) {
            /**
             *  仅支持类型是自定义Entity的参数,不支持mapper的参数是QueryWrapper、String等,例如:
             *
             *  支持:findList(@Param(value = "query") UserEntity query);
             *  支持:findPage(@Param(value = "query") UserEntity query, Page<UserEntity> page);
             *
             *  不支持:findList(@Param(value = "mobile") String mobile);
             *  不支持:findList(QueryWrapper wrapper);
             */
            if (param instanceof AbstractWrapper || param instanceof String) {
                // Wrapper、String类型查询参数,无法获取参数变量上的注解,无法确认是否需要加密,因此不做判断
                continue;
            }
            if (needToDecrypt(param.getClass())) {
                encryptEntity(param);
            }
        }
    }

    @Override
    public void beforeUpdate(Executor executor, MappedStatement mappedStatement, Object parameterObject) throws SQLException {
        if (Objects.isNull(parameterObject)) {
            return;
        }
        // 通过MybatisPlus自带API(save、insert等)新增数据库时
        if (!(parameterObject instanceof Map)) {
            if (needToDecrypt(parameterObject.getClass())) {
                encryptEntity(parameterObject);
            }
            return;
        }
        Map paramMap = (Map) parameterObject;
        Object param;
        // 通过MybatisPlus自带API(update、updateById等)修改数据库时
        if (paramMap.containsKey(Constants.ENTITY) && null != (param = paramMap.get(Constants.ENTITY))) {
            if (needToDecrypt(param.getClass())) {
                encryptEntity(param);
            }
            return;
        }
        // 通过在mapper.xml中自定义API修改数据库时
        if (paramMap.containsKey("entity") && null != (param = paramMap.get("entity"))) {
            if (needToDecrypt(param.getClass())) {
                encryptEntity(param);
            }
            return;
        }
        // 通过UpdateWrapper、LambdaUpdateWrapper修改数据库时
        if (paramMap.containsKey(Constants.WRAPPER) && null != (param = paramMap.get(Constants.WRAPPER))) {
            if (param instanceof Update && param instanceof AbstractWrapper) {
                Class<?> entityClass = mappedStatement.getParameterMap().getType();
                if (needToDecrypt(entityClass)) {
                    encryptWrapper(entityClass, param);
                }
            }
            return;
        }
    }

    /**
     * 校验该实例的类是否被@EncryptedTable所注解
     */
    private boolean needToDecrypt(Class<?> objectClass) {
        EncryptedTable sensitiveData = AnnotationUtils.findAnnotation(objectClass, EncryptedTable.class);
        return Objects.nonNull(sensitiveData);
    }

    /**
     * 通过API(save、updateById等)修改数据库时
     *
     * @param parameter
     */
    private void encryptEntity(Object parameter) {
        //取出parameterType的类
        Class<?> resultClass = parameter.getClass();
        Field[] declaredFields = resultClass.getDeclaredFields();
        for (Field field : declaredFields) {
            //取出所有被EncryptedColumn注解的字段
            EncryptedColumn sensitiveField = field.getAnnotation(EncryptedColumn.class);
            if (!Objects.isNull(sensitiveField)) {
                field.setAccessible(true);
                Object object = null;
                try {
                    object = field.get(parameter);
                } catch (IllegalAccessException e) {
                    continue;
                }
                //只支持String的解密
                if (object instanceof String) {
                    String value = (String) object;
                    //对注解的字段进行逐一加密
                    try {
                        field.set(parameter, AESUtil.encrypt(value));
                    } catch (IllegalAccessException e) {
                        continue;
                    }
                }
            }
        }
    }

    /**
     * 通过UpdateWrapper、LambdaUpdateWrapper修改数据库时
     *
     * @param entityClass
     * @param ewParam
     */
    private void encryptWrapper(Class<?> entityClass, Object ewParam) {
        AbstractWrapper updateWrapper = (AbstractWrapper) ewParam;
        String sqlSet = updateWrapper.getSqlSet();
        if (StringUtils.isBlank(sqlSet)) {
            return;
        }
        String[] elArr = sqlSet.split(",");
        Map<String, String> propMap = new HashMap<>(elArr.length);
        Arrays.stream(elArr).forEach(el -> {
            String[] elPart = el.split("=");
            propMap.put(elPart[0], elPart[1]);
        });

        //取出parameterType的类
        Field[] declaredFields = entityClass.getDeclaredFields();
        for (Field field : declaredFields) {
            //取出所有被EncryptedColumn注解的字段
            EncryptedColumn sensitiveField = field.getAnnotation(EncryptedColumn.class);
            if (Objects.isNull(sensitiveField)) {
                continue;
            }
            String el = propMap.get(field.getName());
            try {
                Matcher matcher = PARAM_PAIRS_RE.matcher(el);
                if (matcher.matches()) {
                    String valueKey = matcher.group(1);
                    Object value = updateWrapper.getParamNameValuePairs().get(valueKey);
                    updateWrapper.getParamNameValuePairs().put(valueKey, AESUtil.encrypt(value.toString()));
                }
            }catch (Exception e){
                logger.error("{}", e);
            }
        }

        Method[] declaredMethods = entityClass.getDeclaredMethods();
        for (Method method : declaredMethods) {
            //取出所有被EncryptedColumn注解的字段
            EncryptedColumn sensitiveField = method.getAnnotation(EncryptedColumn.class);
            if (Objects.isNull(sensitiveField)) {
                continue;
            }
            String el = propMap.get(method.getName());
            try {
                Matcher matcher = PARAM_PAIRS_RE.matcher(el);
                if (matcher.matches()) {
                    String valueKey = matcher.group(1);
                    Object value = updateWrapper.getParamNameValuePairs().get(valueKey);
                    updateWrapper.getParamNameValuePairs().put(valueKey, AESUtil.encrypt(value.toString()));
                }
            }catch (Exception e){
                logger.error("{}",e);
            }
        }
    }
}

解密插件:

java 复制代码
@Intercepts({
        @Signature(type = ResultSetHandler.class, method = "handleResultSets", args = {Statement.class})
})
@Component
public class DecryptInterceptor implements Interceptor {
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        Object resultObject = invocation.proceed();
        if (Objects.isNull(resultObject)) {
            return null;
        }
        if (resultObject instanceof ArrayList) {
            //基于selectList
            ArrayList resultList = (ArrayList) resultObject;
            if (!resultList.isEmpty() && needToDecrypt(resultList.get(0))) {
                for (Object result : resultList) {
                    //逐一解密
                    decrypt(result);
                }
            }
        } else if (needToDecrypt(resultObject)) {
            //基于selectOne
            decrypt(resultObject);
        }
        return resultObject;
    }

    /**
     * 校验该实例的类是否被@EncryptedTable所注解
     */
    private boolean needToDecrypt(Object object) {
        Class<?> objectClass = object.getClass();
        EncryptedTable sensitiveData = AnnotationUtils.findAnnotation(objectClass, EncryptedTable.class);
        return Objects.nonNull(sensitiveData);
    }

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

    private <T> T decrypt(T result) throws Exception {
        //取出resultType的类
        Class<?> resultClass = result.getClass();
        Field[] declaredFields = resultClass.getDeclaredFields();
        for (Field field : declaredFields) {
            //取出所有被EncryptedColumn注解的字段
            EncryptedColumn sensitiveField = field.getAnnotation(EncryptedColumn.class);
            if (!Objects.isNull(sensitiveField)) {
                field.setAccessible(true);
                Object object = field.get(result);
                //只支持String的解密
                if (object instanceof String) {
                    String value = (String) object;
                    //对注解的字段进行逐一解密
                    field.set(result, AESUtil.decrypt(value));
                }
            }
        }
        return result;
    }
}

mybatis配置

java 复制代码
@Configuration(proxyBeanMethods = false)
public class MybatisPlusConfig {

    /**
     * 单页分页条数限制(默认无限制,参见 插件#handlerLimit 方法)
     */
    private static final Long MAX_LIMIT = 1000L;

    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        // 加解密拦截器
        interceptor.addInnerInterceptor(new EncryptInterceptor());
        // 分页拦截
        PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
        paginationInterceptor.setMaxLimit(MAX_LIMIT);
        interceptor.addInnerInterceptor(paginationInterceptor);
        // 乐观锁拦截
        interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
        return interceptor;
    }



}

model对象demo

java 复制代码
@EncryptedTable
@TableEventListen({SqlCommandType.INSERT, SqlCommandType.UPDATE})
@Data
@EqualsAndHashCode(callSuper = true)
@Accessors(chain = true)
@TableName("db_demo")
public class Demo extends BaseEntity<Demo> {

    private static final long serialVersionUID = 1L;
    

    @EncryptedColumn
    private String mobile;



    @Override
    protected Serializable pkVal() {
        return null;
    }

}

遇到的坑:

1、mybatis-plus的 service 低版本和高版本默认值不一样getOne(queryWrapper, false); 高版本默认是抛异常

2、针对对象反射,比如更新后查询这种,最好是更新 和查询分别用一个对象。

原文链接:【JAVA技术】mybatis 数据库敏感字段加解密方案

相关推荐
binishuaio2 分钟前
Java 第11天 (git版本控制器基础用法)
java·开发语言·git
zz.YE4 分钟前
【Java SE】StringBuffer
java·开发语言
老友@4 分钟前
aspose如何获取PPT放映页“切换”的“持续时间”值
java·powerpoint·aspose
superman超哥12 分钟前
04 深入 Oracle 并发世界:MVCC、锁、闩锁、事务隔离与并发性能优化的探索
数据库·oracle·性能优化·dba
wrx繁星点点19 分钟前
状态模式(State Pattern)详解
java·开发语言·ui·设计模式·状态模式
Upaaui22 分钟前
Aop+自定义注解实现数据字典映射
java
zzzgd81622 分钟前
easyexcel实现自定义的策略类, 最后追加错误提示列, 自适应列宽,自动合并重复单元格, 美化表头
java·excel·表格·easyexcel·导入导出
友善的鸡蛋23 分钟前
解决:使用EasyExcel导入Excel模板时出现数据导入不进去的问题
java·easyexcel·excel导入
星沁城24 分钟前
240. 搜索二维矩阵 II
java·线性代数·算法·leetcode·矩阵
NoneCoder36 分钟前
Java企业级开发系列(1)
java·开发语言·spring·团队开发·开发