前言:
在很多的后台系统中,出于对用户隐私保护、数据安全、使开发和业务数据无感知、安全等级评审等需求中,都会要求对数据库部分内容进行加密,基于此种需求,在书写逻辑时直接调用工具类加密,查询出来结果后再解密,虽然简单粗暴但有如下缺点:
- 代码量升高而加重代码复杂度,功能代码与业务代码耦合,侵入性过强
- 疲于应付多个字段以及多种入参方式,要写更多的解析替换字段属性代码
- 研发人员的关注点变多,关注点过多那么也意味着后续可能维护困难
- 不够灵活且无扩展性
- 做法不够优雅
基于以上需求及手动加解密的缺点,我们期待最好有一种声明式做法,比如注解,再配合AOP的思想,在最小影响原来代码的情况下,优雅的实现这个功能。
实现的方式将在下文有所罗列,以及相关可能遇到的问题也有所列出。本插件可以解决上述需求,或者为你提供一个思路,有一些项目我已经在使用此插件,目前情况稳定,你可以尝试在项目中使用此插件
一、 实现思路
- 入库时,切入点为mapper方法的参数 ,对其中标注了加密注解的值进行加密
- 出库时,切入点为mapper方法的返回值,其中标注了加密注解的返回值,进行解密
思路很简单,切入点就是我们的Mapper方法,那么怎么拿到这个切入点呢?
可行的方式:
- 创建"公共mapper接口",所有的mapper都实现于此接口,此时只需要动态代理该接口对象,mapper中所有方法执行前检查随之加密参数,方法执行完成后解密返回值内参数(如果有的话)即可
- 利用springAOP,AspectJ切面的方式对方法进行拦截,增强等操作
- 利用DAO框架自带的拦截器、filter等。比如mybatis拦截器
方法1有一个很明显的问题是,我们常规的DAO框架中的mapper,一般也是被代理执行的,比如mybatis的mapper接口,mapper无需编写具体实现类,框架会结合mapper.xml
动态的生成mapper对应的impl,或通过spring-mybatis的MapperFactoryBean
方法2的问题也类似,DAO框架其本身已经对mapper接口做过代理了,我们就不要再多此一举了
综合下来方法3是较确切可行的方式,而且mybatis的拦截器已经非常成熟了,无须担心其会发生意料之外的问题
———基于mybatis拦截器,再配合我们的自定义注解即可实现我们的需求。
二、准备
1.前提:
- 要支持的参数形式:实体类型参数中字段、String基础类型参数、Array,集合类型参数(元素为前两种类型)
- 不能影响原先的代码编写方式,且对实体类型对象中的值加密后,下面读取该值不能改变
- 要确保在加入此插件后,系统中其他功能不受影响,正常使用
- 配置简单、使用方便
2.拦截器的范围以及标识解释:
MyBatis 允许你在已映射语句执行过程中的某一点进行拦截调用。默认情况下,MyBatis 允许使用插件来拦截的方法调用包括:
- Executor (update, query, flushStatements, commit, rollback,
getTransaction, close, isClosed) --执行sql - ParameterHandler (getParameterObject, setParameters)
–获取、设置参数 - ResultSetHandler (handleResultSets, handleOutputParameters)
–处理结果集 - StatementHandler (prepare, parameterize, batch, update, query)
–记录sql
这里需要注意的是,这4个类型是固定的,里面的方法也是固定的,不能再被改变的,具体的信息,可以进入相关的类(Executor.class、ParameterHandler .class、ResultSetHandler .class、StatementHandler .class)查看。
三、插件实现
1).插件实现时的部分细节:
- 加密/解密在注解上可以单向配置,使参数只加密或只解密(目前没体会到其意义,但支持)
- 为了支持前提的第2条,则对实体类bean进行了深度copy,insert并返回主键采用了反射对克隆对象进行id赋值
- 当使用mybatisplus时,一般使用者会参考官方demo注册一个分页插件:
paginationInterceptor
,此分页插件在执行时内部会判断IPage
接口的isSearchCount
参数,IPage的默认实现Page对象,其searchCount默认为true,为true时将会执行一条countSql优化,可以理解为执行一次主表的count(1)查询,此countSql将会导致后续的拦截器拦截到该sql,进而多执行相关的拦截操作。当前插件是对mapper的入参出参做处理,而在处理正常业务sql之前,countSql已经触发了对实体bean中字段的加密,后续紧随的处理正常业务的sql入参则是未加密的,导致出现total有数据,records却没有数据的问题。当前插件中对该默认值searchCount=false
,以确保发生不必要的一系列问题,于是乎上述前提的第三条只能算是努力做到,没法完全做到,毕竟所有拦截器都是通过sql的执行来进行拦截的。且很难对其进行识别判断(不知该countSql是用户实际查询还是分页插件查询)
这里补充说一下,一般分页查询,如果有查询总量的需求,那么提前查一次放在缓存即可,不必每次都countSql查询,实质上是否影响性能就见仁见智了。当然也不否认mybatisplus这种做法,很方便,但的确会让用户使用其他拦截器时造成一些困扰。
2).创建自己的拦截器:
Mybatis开放的接口Interceptor ,可以让开发者自己实现自定义的拦截器,只需要实现这个接口,并在mybatis.xml中配置,即可生效,如果是springboot,可用java类的形式注册。
方法解释:
-
intercept方法:
插件拦截到的对象主要的执行方法 -
plugin方法
为目标对象生成一个代理对象 -
setProperties方法
可以为插件的变量设置属性
插件通过对mapper参数的拦截,取自定义注解标记的值进行加密,返回值做解密(如果有标记)
因篇幅问题,此处不展开代码内容,加解密插件源码已在Github开源,按照说明的方式引入,支持java,kotlin框架支持为:mybatis,mybatis-plus。文末包含源码地址
提示:问题篇幅较长,可以大致浏览一下,或者跳过,直达引入方式及使用
四、已填的坑(醒目)
问题一:
在本地开发时可以加密 一切正常,在linux上却不行?
原因:
在本地一切正常,在linux服务器上却无法加密,第一时间想到环境问题,但能有什么环境问题呢,由于加密或解密失败会try-catch掉并回滚为原来的值,也没加入过多的日志(因为需要频繁打印,有积少成多的性能损耗),所以未打印真实异常,便把加密代码写成了helloworld,独立放到服务器上,报了个
java.security.InvalidKeyException: Illegal key size
这个原因主要是某些国家的进口管制限制,JDK默认的加解密有一定的限制,从Java 1.8.0_151和1.8.0_152开始,为JVM启用 无限制强度管辖策略 有了一种新的更简单的方法。如果不启用此功能,则不能使用AES-256。找到了这个问题所在,那么就有对应的解决方案
解决:
两种解决方案:
1、升级jdk
2、修改jdk的参数
显然第二种更方便代价也小
在 jre/lib/security 文件夹中查找文件 java.security。
例如,对于Java 1.8.0_152,文件结构如下所示:
/jdk1.8.0_152
|- /jre
|- /lib
|- /security
|- java.security
现在用文本编辑器打开java.security,并找到定义java安全性属性crypto.policy的行,它可以有两个值limited或unlimited - 默认值是limited。
默认情况下,应该能找到一条注释掉的行:
#crypto.policy=unlimited
可以通过取消注释该行来启用无限制,删除#:
crypto.policy=unlimited
现在重新启动指向JVM的Java应用程序即可。
说了大白话就是去jdk目录下的jre/lib/security,找到java.security,去掉#crypto.policy=unlimited
的#号
然后kill掉java程序进程重新启动
问题二:
mybatis的selectKey标签反回id的策略失效,导致insert无法拿到插入反回的主键id
原因:
在对实体类字段做加解密时,为防止重复加密,即让其他下面代码还是使用未加密的值,所以对于实体类都进行了克隆,克隆后原对象不在引用,而新的对象赋值为加密的值,传入mybatis进行操作了,导致selectKey无法赋值给原对象并返回。(克隆出来的对象并没有进行返回)
解决:
为了既能防止重复加密,又能使selectKey返回的id赋值给原对象,这里采用threadLocal,在插件运行结束前,从克隆对象中获取ID字段的值,赋值给原对象id字段,这样就解决了这个问题。
引用部分代码实现:
1).用来存储原对象的引用和克隆对象的引用:
/**
* 线程副本存储ID 用以克隆的对象的ID赋值
*/
private static final ThreadLocal<KeyGenerateReference> KEY_GENERATE_REFERENCE_THREAD_LOCAL = new ThreadLocal<>();
2).在操作bean的处理器中,克隆的同时,对原对象和新对象以键值对形式存放。
//对bean的所有操作,会影响本地数据,可能存在重复加密的情况, 需要clone成新bean,必须要有默认构造器
//属性的二次操作 通过线程副本来解决 比如id的返回 此处需要将result和bean的映射放入线程副本
Object result = clone(bean);
CryptHelper.setKeyGenerateReference(bean, result);
3).存放后,任由程序继续执行,直到要退出插件,对id进行赋值,
这里由于扩大了excutor的拦截范围,两个query一个update方法,这样会使mapper方法对应的xml中的多条sql或其他不相干操作(多条sql是类似selectKey,不相干操作比如count)都走一遍插件,所以需要判断当前这次插件执行,是否可以进行引用清理,判断的依据有两个:
- 当次运行的statement的id获取到的method为空时,说明在做非本次sql查询的其他操作,需要跳过。
- 当次只有在成功赋值id时,才进行清理。
代码实现:
/**
* 修复selectKey无法赋值给源对象(源对象被clone,因为需要避免重复加密)
*
* @param reference keyGen的引用
* @throws IllegalAccessException 权限异常
* @throws NoSuchFieldException 没有对应属性异常
*/
private void returnIdToSourceBean(KeyGenerateReference reference) throws IllegalAccessException, NoSuchFieldException {
if (reference != null) {
Object sourceObj = reference.getOriginPojo();
Object cloneObj = reference.getClonePojo();
Field sourceObjFieldId = sourceObj.getClass().getDeclaredField(primaryKeyName);
sourceObjFieldId.setAccessible(true);
Field cloneObjFieldId = cloneObj.getClass().getDeclaredField(primaryKeyName);
cloneObjFieldId.setAccessible(true);
Object cloneObjFieldIdVal = cloneObjFieldId.get(cloneObj);
if (Objects.nonNull(cloneObjFieldIdVal)) {
sourceObjFieldId.set(sourceObj, cloneObjFieldIdVal);
}
}
}
至此,表面问题基本解决完毕。
五、引入方式
1).依赖:
maven:
基于mybatis-plus-boot-stater:
<dependency>
<groupId>com.github.kamjin1996</groupId>
<artifactId>kamjin-db-crypt-mybatis-plus-boot-starter</artifactId>
<version>${last.version}</version>
</dependency>
基于mybatis-spring:
<dependency>
<groupId>com.github.kamjin1996</groupId>
<artifactId>kamjin-db-crypt-mybatis</artifactId>
<version>${last.version}</version>
</dependency>
其他方式,以及版本等可查看:依赖坐标信息及版本
例如gradle:
implementation 'com.github.kamjin1996:kamjin-db-crypt-mybatis-plus-boot-starter:${last.version}'
2).配置
- starter方式(推荐):
可以不做配置,默认取下面的参数
kamjin:
dbcrypt:
secretkey: 123456789012345678901234
enable: true
primary-key-name: id
aes: AES192
配置释义:
-
非starter方式:
1). application.yaml: 同上starter的配置,PS:此方式关于参数的key可以自定义,只要确保能被spring解析到即可2). 新建配置类:
@Configuration
@Data
public class KamjinDbCryptConfig {
@Value("${kamjin.dbcrypt.secretkey}")
private String secretkey;
@Value("${kamjin.dbcrypt.enable}")
private boolean enable;
@Value("${kamjin.dbcrypt.primary-key-name}")
private String primaryKeyName;
@Value("${kamjin.dbcrypt.aes}")
private AesEnum aes;
@Bean
public MybatisCryptInterceptor mybatisCryptInterceptor() {
DbcryptProperties properties = new DbcryptProperties(aes, secretkey, enable, primaryKeyName);
CryptExecutorFactory.registry(new DefaultCryptExecutor(new DefaultAESCodecFieldValueHandler(properties)));
return new MybatisCryptInterceptor(properties);
}
}
注意:如果需要用javaBeanConfig配置MybatisPlusCryptInterceptor对象等相关配置,还需要@bean创建MybatisInterceptorInspectByAppRefreshedListener到spring容器中,参考的插件源码中的MybatisPlusCryptAutoConfiguration.java
手动配置完毕。
此外,项目如果较久远,采用mybatis.xml注册插件:
<plugins>
<plugin interceptor="com.kamjin.toolkit.db.crypt.mybatisplus.interceptor.MybatisPlusCryptInterceptor"/>
</plugins>
其他bean定义在xml中由spring维护即可
虽然读取配置方式有很多种,但我们还是选择最简洁最好用的来用 ,配置的方式也更加灵活
3).使用
类字段注解@CryptField,也支持在mapper入参前注解@CryptField
常见用法,pojo的字段上注解
字段上使用:
@CryptField
private String password;
mapper入参使用:
User selectByMobile(@CryptField @Param("mobile") String mobile);
更多使用方式参考下述demo
六、快速开始
欢迎提出你的意见以及建议,或者其他功能,改进,我将考虑并支持,感谢~
最近考虑在某个空闲时间,能采用kotlin重构该插件的代码及调整项目结构,目前仍然觉得插件虽够用,但代码质量及扩展性仍然没有那么出色,对现在的分层及依赖等不太满意,后面再说吧,欢迎在评论区留言讨论~~ 再会