文章目录
- 使用拦截器进行数据加解密
- 1. 加解密工具
- 3. 加解密字段注解
- 3.1 加密注解
- 3.2 解密注解
- 4. 封装加解密工具
- 5. 拦截器
- 6. 不同框架配置说明
- 6.1 springboot下的配置
- 6.2 spring的xml配置
- 7. 说明
使用拦截器进行数据加解密
本文并非详细探讨AES加解密内容,而是在Spring+Mybatis的项目基础上,以sql拦截器的形式,实现了对数据存取加解密的方案。文章项目示例采用springboot框架,对需要加解密的字段添加注解,sql执行过程中,拦截器进行拦截。可通过配置加解密开关决定是否对字段进行加解密。加密方式AES。
文章并未列出所有源码,依赖包等详细配置,在源码中有具体的sql脚本等文件,点击访问项目源码。
- 源码框架
java8 springboot mybatis gradle
1. 加解密工具
方法generateAESKey()生成128位秘钥,以16进制字符串保存,从配置文件读取,以单例模式初始化加解密工具,保证项目运行过程中对象不会被重新创建,避免多次初始化Cipher。加解密方法详见代码如下。
/**
* @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();
}
}
3. 加解密字段注解
注解标识字段是否需要加密或者解密,用于通过反射获取需要进行加解密的字段,防止需求变动,将加密和解密注解分开。
3.1 加密注解
/**
* @decription EncryptField
* <p>字段加密注解</p>
* @author Yampery
* @date 2017/10/24 13:01
*/
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface EncryptField {
String value() default "";
}
3.2 解密注解
/**
* @decription DecryptField
* <p>字段解密注解</p>
* @author Yampery
* @date 2017/10/24 13:05
*/
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DecryptField {
String value() default "";
}
4. 封装加解密工具
为了在项目中方便使用,将上节中的加解密工具进行封装,封装后的工具可以作用于对象,通过反射获取注解,而对原对象进行改变。另外,项目中也实现了对象的自加解密CrypticPojo,原理是CrypticPojo实现clone方法,并在内部实现加解密方法,需要进行字段加解密的业务对象只需要继承CrypticPojo,每次返回调用一次克隆并加密方法即可,具体见源码。
/**
* @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);
}
}
}
5. 拦截器
使用sql拦截器处理加解密基本是对项目影响比较小的。该拦截器通过拦截sql,对写入数据和查询结果进行重写,然后再放行从而更改对象。
关于sql语句参数,文章中并没有在拦截器处理,而是使用一个LinkedMap封装了查询参数,在封装的过程中会对字段进行加密。
关于springboot中和spring中拦截器使用的区别下文将会介绍。
/**
* @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
}
}
6. 不同框架配置说明
6.1 springboot下的配置
- 启动主类需要添加mapper扫描注解
@SpringBootApplication
@MapperScan("git.yampery.cryptic.dao")
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
- 配置文件application.properties需要添加映射
# 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注解
6.2 spring的xml配置
- mybatis当然和传统配置一致,在spring上下文配置中添加
- 拦截器配置,在spring上下文配置文件sqlsessionfactory中
<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>
7. 说明
注意:文章中的代码只是部分,源码包含完整的测试和说明,点击访问项目源码。