使用拦截器进行数据加解密

本文并非详细探讨AES加解密内容,而是在Spring+Mybatis的项目基础上,以sql拦截器的形式,实现了对数据存取加解密的方案。文章项目示例采用springboot框架,对需要加解密的字段添加注解,sql执行过程中,拦截器进行拦截。可通过配置加解密开关决定是否对字段进行加解密。加密方式AES。

文章并未列出所有源码,依赖包等详细配置,在源码中有具体的sql脚本等文件,点击访问项目源码。

  • 源码框架

    java8 springboot mybatis gradle


加解密工具

方法generateAESKey()生成128位秘钥,以16进制字符串保存,从配置文件读取,以单例模式初始化加解密工具,保证项目运行过程中对象不会被重新创建,避免多次初始化Cipher。加解密方法详见代码如下。

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
/**
* @decription ADESUtils
* <p>字段加解密,使用MySql AES算法</p>
* @author Yampery
* @date 2018/4/4 13:10
*/
@Component
public class ADESUtils {
private static final String ENCRYPT_TYPE = "AES";
private static final String ENCODING = "UTF-8";

// 密盐
private static String aesSalt;
private static ADESUtils adesUtils;
private static Cipher encryptCipher; // 加密cipher
private static Cipher decryptChipher; // 解密chipher

// 加解密开关,从配置获取
private static String CRYPTIC_SWITCH;
/**
* 从配置中获取秘钥
* :默认值填写自己生成的秘钥
* @param key
*/
@Value("${sys.aes.salt:0}")
public void setAESSalt(String key){
ADESUtils.aesSalt = key;
}

/**
* 获取开关
* 默认为不加密
* @param val
*/
@Value("${sys.aes.switch:0}")
public void setCrypticSwitch(String val) {
ADESUtils.CRYPTIC_SWITCH = val;
}
/**
* encryptCipher、decryptChipher初始化
*/
public static void init(){
try {
encryptCipher = Cipher.getInstance(ENCRYPT_TYPE);
decryptChipher = Cipher.getInstance(ENCRYPT_TYPE);
encryptCipher.init(Cipher.ENCRYPT_MODE, generateMySQLAESKey(aesSalt));
decryptChipher.init(Cipher.DECRYPT_MODE, generateMySQLAESKey(aesSalt));
} catch (InvalidKeyException e) {
throw new RuntimeException(e);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
} catch (NoSuchPaddingException e) {
throw new RuntimeException(e);
}
}

private ADESUtils() { }

/**
* 获取单例
* @return
*/
public static ADESUtils getInstance(){
if(adesUtils == null){
// 当需要创建的时候在加锁
synchronized(ADESUtils.class) {
if (adesUtils == null) {
adesUtils = new ADESUtils();
init();
}
}
}
return adesUtils;
}

/**
* 对明文加密
* @param pString
* @return
*/
public String encrypt(String pString) {

if (StringUtils.isBlank(pString) || StringUtils.equals("0", CRYPTIC_SWITCH))
return StringUtils.trimToEmpty(pString);
try{
return new String(Hex.encodeHex(encryptCipher.doFinal(pString.getBytes(ENCODING)))).toUpperCase();
} catch (Exception e) {
e.printStackTrace();
return pString;
}
}

/**
* 对密文解密
* @param eString
* @return
*/
public String decrypt(String eString) {
if (StringUtils.isBlank(eString) || StringUtils.equals("0", CRYPTIC_SWITCH))
return StringUtils.trimToEmpty(eString);
try {
return new String(decryptChipher.doFinal(Hex.decodeHex(eString.toCharArray())));
} catch (Exception e) {
e.printStackTrace();
return eString;
}
}
/**
* 产生mysql-aes_encrypt
* @param key 加密的密盐
* @return
*/
public static SecretKeySpec generateMySQLAESKey(final String key) {
try {
final byte[] finalKey = new byte[16];
int i = 0;
for(byte b : Hex.decodeHex(key.toCharArray()))
finalKey[i++ % 16] ^= b;
return new SecretKeySpec(finalKey, "AES");
} catch(Exception e) {
throw new RuntimeException(e);
}
}

/**
* 生成秘钥(128位)
* @return
* @throws Exception
*/
public static String generateAESKey() throws Exception{
//实例化
KeyGenerator kgen = KeyGenerator.getInstance("AES");
//设置密钥长度
kgen.init(128);
//生成密钥
SecretKey skey = kgen.generateKey();
// 转为16进制字串
String key = new String(Hex.encodeHex(skey.getEncoded()));
//返回密钥的16进制字串
return key.toUpperCase();
}
}

加解密字段注解

注解标识字段是否需要加密或者解密,用于通过反射获取需要进行加解密的字段,防止需求变动,将加密和解密注解分开。

加密注解

1
2
3
4
5
6
7
8
9
10
11
/**
* @decription EncryptField
* <p>字段加密注解</p>
* @author Yampery
* @date 2017/10/24 13:01
*/
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface EncryptField {
String value() default "";
}

解密注解

1
2
3
4
5
6
7
8
9
10
11
/**
* @decription DecryptField
* <p>字段解密注解</p>
* @author Yampery
* @date 2017/10/24 13:05
*/
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DecryptField {
String value() default "";
}

封装加解密工具

为了在项目中方便使用,将上节中的加解密工具进行封装,封装后的工具可以作用于对象,通过反射获取注解,而对原对象进行改变。另外,项目中也实现了对象的自加解密CrypticPojo,原理是CrypticPojo实现clone方法,并在内部实现加解密方法,需要进行字段加解密的业务对象只需要继承CrypticPojo,每次返回调用一次克隆并加密方法即可,具体见源码。

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
/**
* @decription CryptPojoUtils
* <p>对象加解密工具
* 其子类可以通过调用<tt>encrypt(T t)</tt>方法实现自加密,返回参数类型;
* 调用<tt>decrypt(T t)</tt>实现自解密,返回参数类型;
* <tt>encrypt</tt>对注解{@link EncryptField}字段有效;
* <tt>decrypt</tt>对注解{@link DecryptField}字段有效。</p>
* @author Yampery
* @date 2017/10/24 13:36
*/
public class CryptPojoUtils {

/**
* 对对象t加密
* @param t
* @param <T>
* @return
*/
public static <T> T encrypt(T t) {
Field[] declaredFields = t.getClass().getDeclaredFields();
try {
if (declaredFields != null && declaredFields.length > 0) {
for (Field field : declaredFields) {
if (field.isAnnotationPresent(EncryptField.class) && field.getType().toString().endsWith("String")) {
field.setAccessible(true);
String fieldValue = (String) field.get(t);
if (StringUtils.isNotEmpty(fieldValue)) {
field.set(t, ADESUtils.getInstance().encrypt(fieldValue));
}
field.setAccessible(false);
}
}
}
} catch (IllegalAccessException e) {
throw new RuntimeException(e);
}
return t;
}

/**
* 对象解密
* @param t
* @param <T>
* @return
*/
public static <T> T decrypt(T t) {
Field[] declaredFields = t.getClass().getDeclaredFields();
try {
if (declaredFields != null && declaredFields.length > 0) {
for (Field field : declaredFields) {
if (field.isAnnotationPresent(DecryptField.class) && field.getType().toString().endsWith("String")) {
field.setAccessible(true);
String fieldValue = (String)field.get(t);
if(StringUtils.isNotEmpty(fieldValue)) {
field.set(t, ADESUtils.getInstance().decrypt(fieldValue));
}
}
}
}
} catch (IllegalAccessException e) {
throw new RuntimeException(e);
}
return t;
}

/**
* 对含注解字段解密
* @param t
* @param <T>
*/
public static <T> void decryptField(T t) {
Field[] declaredFields = t.getClass().getDeclaredFields();
try {
if (declaredFields != null && declaredFields.length > 0) {
for (Field field : declaredFields) {
if (field.isAnnotationPresent(DecryptField.class) && field.getType().toString().endsWith("String")) {
field.setAccessible(true);
String fieldValue = (String)field.get(t);
if(StringUtils.isNotEmpty(fieldValue)) {
field.set(t, ADESUtils.getInstance().decrypt(fieldValue));
}
}
}
}
} catch (IllegalAccessException e) {
throw new RuntimeException(e);
}
// return t;
}

/**
* 对含注解字段加密
* @param t
* @param <T>
*/
public static <T> void encryptField(T t) {
Field[] declaredFields = t.getClass().getDeclaredFields();
try {
if (declaredFields != null && declaredFields.length > 0) {
for (Field field : declaredFields) {
if (field.isAnnotationPresent(EncryptField.class) && field.getType().toString().endsWith("String")) {
field.setAccessible(true);
String fieldValue = (String)field.get(t);
if(StringUtils.isNotEmpty(fieldValue)) {
field.set(t, ADESUtils.getInstance().encrypt(fieldValue));
}
}
}
}
} catch (IllegalAccessException e) {
throw new RuntimeException(e);
}
}

/**
* 隐藏号码中间4位
* @param t
* @param <T>
*/
public static <T> void hidePhone(T t) {
Field[] declaredFields = t.getClass().getDeclaredFields();
try {
if (declaredFields != null && declaredFields.length > 0) {
for (Field field : declaredFields) {
if (field.isAnnotationPresent(DecryptField.class) && field.getType().toString().endsWith("String")) {
field.setAccessible(true);
String fieldValue = (String)field.get(t);
if(StringUtils.isNotEmpty(fieldValue)) {
// 暂时与解密注解共用一个注解,该注解隐藏手机号中间四位
field.set(t, StringUtils.overlay(fieldValue, "****", 3, 7));
}
}
}
}
} catch (IllegalAccessException e) {
throw new RuntimeException(e);
}
}
}

拦截器

使用sql拦截器处理加解密基本是对项目影响比较小的。该拦截器通过拦截sql,对写入数据和查询结果进行重写,然后再放行从而更改对象。 关于sql语句参数,文章中并没有在拦截器处理,而是使用一个LinkedMap封装了查询参数,在封装的过程中会对字段进行加密。 关于springboot中和spring中拦截器使用的区别下文将会介绍。

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
/**
* @decription DBInterceptor
* <p>实现Mybatis拦截器,用于拦截修改,插入和返回需要加密或者解密的对象</p>
* @author Yampery
* @date 2018/4/4 14:17
*/
@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})
})
@Component
public class DBInterceptor implements Interceptor {
private final Logger logger = LoggerFactory.getLogger(DBInterceptor.class);
@Value("${sys.aes.switch}") private String CRYPTIC_SWITCH;
@Override
public Object intercept(Invocation invocation) throws Throwable {
MappedStatement statement = (MappedStatement) invocation.getArgs()[0];
String methodName = invocation.getMethod().getName();
Object parameter = invocation.getArgs()[1];
BoundSql sql = statement.getBoundSql(parameter);
logger.info("sql is: {}", sql.getSql());

/**
* @TODO 处理查询
*/
if (StringUtils.equalsIgnoreCase("query", methodName)) {
/**
* 在这里可以处理查询参数,如传递的参数为明文,要按照密文查询
* 本文选择使用同一参数封装处理方案{@link git.yampery.cryptic.common.QueryParams}
*/
}
/**
* 拦截批量插入操作不仅繁琐,而且为了通用逐一通过反射加密不妥
* 如果有批量操作,最好在传递参数之前,向list中添加之前就加密
*/
if (!"0".equals(CRYPTIC_SWITCH)) {
if (StringUtils.equalsIgnoreCase("update", methodName)
|| StringUtils.equalsIgnoreCase("insert", methodName)) {
CryptPojoUtils.encryptField(parameter);
}
}

Object returnValue = invocation.proceed();

try {
if (!"0".equals(CRYPTIC_SWITCH)) {
if (returnValue instanceof ArrayList<?>) {
List<?> list = (ArrayList<?>) returnValue;
if (null == list || 1 > list.size())
return returnValue;
Object obj = list.get(0);
if (null == obj) // 这里虽然list不是空,但是返回字符串等有可能为空
return returnValue;
// 判断第一个对象是否有DecryptField注解
Field[] fields = obj.getClass().getDeclaredFields();
int len;
if (null != fields && 0 < (len = fields.length)) {
// 标记是否有解密注解
boolean isD = false;
for (int i = 0; i < len; i++) {
/**
* 由于返回的是同一种类型列表,因此这里判断出来之后可以保存field的名称
* 之后处理所有对象直接按照field名称查找Field从而改之即可
* 有可能该类存在多个注解字段,所以需要保存到数组(项目中目前最多是2个)
* @TODO 保存带DecryptField注解的字段名称到数组,按照名称获取字段并解密
* */
if (fields[i].isAnnotationPresent(DecryptField.class)) {
isD = true;
break;
}
} /// for end ~
if (isD) // 将含有DecryptField注解的字段解密
list.forEach(l -> CryptPojoUtils.decryptField(l));
} /// if end ~
} /// if end ~
}

} catch (Exception e) {
// 打印异常,由于拦截器本身抛出异常,比如拦截到很奇葩的返回,应算正常
// 直接返回原结果即可
logger.info("抛出异常,正常返回==> " + e.getMessage());
e.printStackTrace();
return returnValue;
}
return returnValue;
}

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

@Override
public void setProperties(Properties properties) {
// TODO Auto-generated method stub
}
}

不同框架配置说明

springboot下的配置

  • 启动主类需要添加mapper扫描注解
1
2
3
4
5
6
7
8
@SpringBootApplication
@MapperScan("git.yampery.cryptic.dao")
public class Application {

public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
  • 配置文件application.properties需要添加映射
1
2
3
4
5
6
7
8
9
10
11
12
# Mybatis
mybatis.config-location =classpath:mybatis/mybatis-config.xml
mybatis.mapper-locations =classpath:mybatis/mapper/*.xml
mybatis.type-aliases-package =git.yampery.cryptic.pojo

# 开启debug模式可以在控制台查看springboot加载流程
# debug =true

# 密盐(使用工具ADESUtils生成)
sys.aes.salt =4BB90812C2B9B0882A6FA7C203E4717F
# 加解密开关(1:开启加解密;0:关闭加解密)
sys.aes.switch =1
  • 拦截器会自动扫描,注意@Component注解

spring的xml配置

  • mybatis当然和传统配置一致,在spring上下文配置中添加
  • 拦截器配置,在spring上下文配置文件sqlsessionfactory中
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
<property name="dataSource" ref="dataSource" />
<property name="configLocation" value="classpath:mybatis.xml" />
<!-- 自动扫描mapping.xml文件 -->
<property name="mapperLocations">
<array>
<value>classpath:mybatis/mapper/*.xml</value>
</array>
</property>
<property name="plugins">
<array>
<bean class="git.yampery.cryptic.interceptor.DBInterceptor">
<property name="properties" value="property-value"/>
</bean>
</array>
</property>
</bean>
<bean class="org.mybatis.spring.mapper.MapperScannerConfigurer">
<property name="basePackage" value="git.yampery.cryptic.dao" />
<property name="sqlSessionFactoryBeanName" value="sqlSessionFactory"></property>
</bean>

说明

注意:文章中的代码只是部分,源码包含完整的测试和说明,点击访问项目源码。