详解Mybatis拦截器安全加解密MySQL数据实战
需求背景
- 公司为了通过一些金融安全指标(政策问题)和防止数据泄漏,需要对用户敏感数据进行加密,所以在公司项目中所有存储了用户信息的数据库都需要进行数据加密改造。包括Mysql、redis、mongodb、es、HBase等。
- 因为在项目中是使用springboot+mybatis方式连接数据库进行增删改查,并且项目是中途改造数据。所以为了不影响正常业务,打算这次改动尽量不侵入到业务代码,加上mybatis开放的各种拦截器接口,所以就以此进行改造数据。
- 本篇文章讲述如何在现有项目中尽量不侵入业务方式进行Mysql加密数据,最后为了不降低查询性能使用了注解,所以最后还是部分侵入业务。
Mybatis拦截器
Mybatis只能拦截指定类里面的方法:Executor、ParameterHandler、StatementHandler、ResultSetHandler。
- Executor:拦截执行器方法;
- ParameterHandler:拦截参数方法;
- StatementHandler:拦截sql构建方法;
- ResultSetHandler:拦截查询结果方法;
Mybatis提供的拦截器接口Interceptor
public interface Interceptor {
Object intercept(Invocation invocation) throws Throwable;
default Object plugin(Object target) {
return Plugin.wrap(target, this);
}
default void setProperties(Properties properties) {
// NOP
}
}
- Object intercept():代理对象都会调用的方法,这里可以执行自定义拦截处理;
- Object plugin():可以用于判断拦截器执行类型;
- void setProperties():指定配置文件的属性;
自定义拦截器中除了要实现Interceptor接口,还需要添加@Intercepts注解指定拦截对象。@Intercepts注解需配合@Signature注解使用
@Intercepts注解可以指定多个@Signature,type指定拦截类,method指定拦截方法,args拦截方法里的参数类型。
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Intercepts {
Signature[] value();
}
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({})
public @interface Signature {
Class<?> type();
String method();
Class<?>[] args();
}
案例实战
依据上述的mybatis拦截器的使用,下面就把实战案例代码提供一下。
Mybatis自定义拦截器
- 在业务代码里用户信息是以明文传递的,所以为了不改动业务代码,那么需要拦截器在插入或查询数据库数据前先加密,查询结果解密操作。
- 首先搭建一个springboot的项目,这里指定两个mybatis拦截器,一个拦截请求参数,一个拦截响应数据,并把拦截器注入到spring容器内。
@Slf4j
@Component
@Intercepts(@Signature(type = ParameterHandler.class, method = "setParameters", args = {PreparedStatement.class}))
public class MybatisEncryptInterceptor implements Interceptor {
@Resource
private com.mysql.web.mybatis.Interceptor.MybatisCryptHandler handler;
@Override
public Object intercept (Invocation invocation) {
return invocation;
}
@SneakyThrows
@Override
public Object plugin (Object target) {
if (target instanceof ParameterHandler) {
// 对请求参数进行加密操作
handler.parameterEncrypt((ParameterHandler) target);
}
return target;
}
@Override
public void setProperties (Properties properties) {
}
}
注意:ResultSetHandler对象对增删改方法没有拦截,需要增加Executor对象;
@Slf4j
@Component
@Intercepts({
@Signature(type = ResultSetHandler.class, method = "handleResultSets", args = {Statement.class}),
@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}),
})
public class MybatisDecryptInterceptor implements Interceptor {
@Resource
private MybatisCryptHandler handler;
@Override
public Object intercept (Invocation invocation) throws Exception {
// 获取执行mysql执行结果
Object result = invocation.proceed();
if (invocation.getTarget() instanceof Executor) {
// 对增删改操作方法的请求参数进行解密还原操作
checkEncryptByUpdate(invocation.getArgs());
return result;
}
// 对查询方法的请求参数进行解密还原操作
checkEncryptByQuery(invocation.getTarget());
// 对查询结果进行解密
return handler.resultDecrypt(result);
}
@Override
public Object plugin (Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties (Properties properties) {
}
private void checkEncryptByQuery (Object target) {
try {
final Class<?> targetClass = target.getClass();
final Field parameterHandlerFiled = targetClass.getDeclaredField("parameterHandler");
parameterHandlerFiled.setAccessible(true);
final Object parameterHandler = parameterHandlerFiled.get(target);
final Class<?> parameterHandlerClass = parameterHandler.getClass();
final Field parameterObjectField = parameterHandlerClass.getDeclaredField("parameterObject");
parameterObjectField.setAccessible(true);
final Object parameterObject = parameterObjectField.get(parameterHandler);
handler.decryptFieldHandler(parameterObject);
} catch (Exception e) {
log.error("对请求参数进行解密还原操作异常:", e);
}
}
private void checkEncryptByUpdate (Object[] args) {
try {
Arrays.stream(args).forEach(handler::decryptFieldHandler);
} catch (Exception e) {
log.error("对请求参数进行解密还原操作异常:", e);
}
}
}
在上述拦截器中,除了对入参进行加密和查询结果解密操作外,还多了一步对请求参数进行解密还原操作。
这是因为对请求参数进行加密操作时改动的是原对象,如果不还原解密数据,这个对象如果在后续还有其他操作,那就会使用密文,导致数据紊乱。
这里其实想过不改动原对象,而是把原请求对象克隆一份,在克隆对象上进行加密,然后在去查询数据库。可惜可能是自己对mybatis不够熟悉吧,试了很久也不能把mybatis内的原对象替换为克隆对象,所以才就想了这个还原解密参数的方式。
如果对请求参数对象和查询结果对象里的所有字段都进行加解密,那上述配置就基本完成。但在本次安全加解密需求中只针对指定字段(如手机号和真实姓名),现在这种全量字段加解密就不行,而且性能也低,毕竟加解密是很耗费服务器CPU运算资源的。
所以需要增加注解,在指定对象的属性字段才进行加解密。
@Documented
@Inherited
@Target({ElementType.FIELD, ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Crypt {
boolean decrypt () default true;
boolean encrypt () default true;
boolean subObject () default false;
int[] encryptParamIndex () default {};
}
其注解使用方式如下:
AesTools是对数据进行AES对称加解密工具类
@Slf4j
public final class AesTools {
private AesTools () {
}
private static final String KEY_ALGORITHM = "AES";
private static final String ENCODING = "UTF-8";
private static final String DEFAULT_CIPHER_ALGORITHM = "AES/ECB/PKCS5Padding";
private static Cipher ENCODING_CIPHER = null;
private static Cipher DECRYPT_CIPHER = null;
private static final String KEY = "cab041-3c46-fed5";
static {
try {
// 初始化cipher
ENCODING_CIPHER = Cipher.getInstance(DEFAULT_CIPHER_ALGORITHM);
DECRYPT_CIPHER = Cipher.getInstance(DEFAULT_CIPHER_ALGORITHM);
//转化成JAVA的密钥格式
SecretKeySpec keySpec = new SecretKeySpec(KEY.getBytes("ASCII"), KEY_ALGORITHM);
ENCODING_CIPHER.init(Cipher.ENCRYPT_MODE, keySpec);
DECRYPT_CIPHER.init(Cipher.DECRYPT_MODE, keySpec);
} catch (Exception e) {
log.error("初始化mybatis -> AES加解密参数异常:", e);
}
}
public static String encryptECB (String content) {
if (StringUtils.isEmpty(content)) {
return content;
}
String encryptStr = content;
try {
byte[] encrypted = ENCODING_CIPHER.doFinal(content.getBytes(ENCODING));
encryptStr = Base64.getEncoder().encodeToString(encrypted);
} catch (Exception e) {
log.info("mybatis -> AES加密出错:{}", content);
}
return encryptStr;
}
public static String decryptECB (String content) {
if (StringUtils.isEmpty(content)) {
return content;
}
String decryptStr = content;
try {
byte[] decrypt = DECRYPT_CIPHER.doFinal(Base64.getDecoder().decode(content));
decryptStr = new String(decrypt, ENCODING);
} catch (Exception e) {
log.info("mybatis -> AES解密出错:{}", content);
}
return decryptStr;
}
}
MybatisCryptHandler是对请求入参对象和查询结果对象进行加解密操作工具类。
代码稍许复杂,但实现逻辑简单,主要为了防止重复加密,内置缓存,对递归对象扫描检索,反射+注解获取需要加解密字段等。
@Slf4j
@Component
public class MybatisCryptHandler {
private final static ThreadLocal<List> THREAD_LOCAL = ThreadLocal.withInitial(() -> new ArrayList());
private static final List<Field> EMPTY_FIELD_ARRAY = new ArrayList();
private static final Map<Class<?>, List<Field>> declaredFieldsCache = new ConcurrentHashMap<>(256);
public void parameterEncrypt (ParameterHandler handler) {
Object parameterObject = handler.getParameterObject();
if (null == parameterObject || parameterObject instanceof String) {
return;
}
encryptFieldHandler(parameterObject);
removeLocal();
}
private void encryptFieldHandler (Object sourceObject) {
if (null == sourceObject) {
return;
}
if (sourceObject instanceof Map) {
((Map<?, Object>) sourceObject).values().forEach(this::encryptFieldHandler);
return;
}
if (sourceObject instanceof List) {
((List<?>) sourceObject).stream().forEach(this::encryptFieldHandler);
return;
}
Class<?> clazz = sourceObject.getClass();
if (!clazz.isAnnotationPresent(Crypt.class)) {
return;
}
if (checkLocal(sourceObject)) {
return;
}
setLocal(sourceObject);
try {
Field[] declaredFields = clazz.getDeclaredFields();
// 获取满足加密注解条件的字段
final List<Field> collect = Arrays.stream(declaredFields).filter(this::checkEncrypt).collect(Collectors.toList());
for (Field item : collect) {
item.setAccessible(true);
Object value = item.get(sourceObject);
if (null != value && value instanceof String) {
item.set(sourceObject, AesTools.encryptECB((String) value));
}
}
} catch (Exception e) {
}
}
private boolean checkEncrypt (Field field) {
Crypt crypt = field.getAnnotation(Crypt.class);
return null != crypt && crypt.encrypt();
}
public Object resultDecrypt (Object resultData) {
if (resultData instanceof List) {
return ((List<?>) resultData).stream().map(this::resultObjHandler).collect(Collectors.toList());
}
return resultObjHandler(resultData);
}
private Object resultObjHandler (Object result) {
if (null == result) {
return null;
}
Class<?> clazz = result.getClass();
//获取所有要解密的字段
Field[] declaredFields = getAllFieldsCache(clazz);
Arrays.stream(declaredFields).forEach(item -> {
try {
item.setAccessible(true);
Object value = item.get(result);
if (null != value && value instanceof String) {
item.set(result, AesTools.decryptECB((String) value));
}
} catch (Exception e) {
log.error("DecryptException -> checkDecrypt:", e);
}
});
Arrays.stream(declaredFields).filter(item -> checkSubObject(item)).forEach(item -> {
item.setAccessible(true);
try {
Object data = item.get(result);
if (data instanceof List) {
((List<?>) data).forEach(this::resultObjHandler);
}
} catch (IllegalAccessException e) {
log.error("DecryptException -> checkSubObject:{}", e);
}
});
return result;
}
private static boolean checkDecrypt (Field field) {
Crypt crypt = field.getAnnotation(Crypt.class);
return null != crypt && crypt.decrypt();
}
private static boolean checkSubObject (Field field) {
Crypt crypt = field.getAnnotation(Crypt.class);
return null != crypt && crypt.subObject();
}
public void decryptFieldHandler (Object requestObject) {
if (null == requestObject) {
return;
}
if (requestObject instanceof Map) {
((Map<?, Object>) requestObject).values().forEach(this::decryptFieldHandler);
return;
}
if (requestObject instanceof List) {
((List<?>) requestObject).stream().forEach(this::decryptFieldHandler);
return;
}
Class<?> clazz = requestObject.getClass();
if (!clazz.isAnnotationPresent(Crypt.class)) {
return;
}
try {
Field[] declaredFields = clazz.getDeclaredFields();
// 获取满足加密注解条件的字段
final List<Field> collect = Arrays.stream(declaredFields).filter(this::checkEncrypt).collect(Collectors.toList());
for (Field item : collect) {
item.setAccessible(true);
Object value = item.get(requestObject);
if (null != value && value instanceof String) {
item.set(requestObject, AesTools.decryptECB((String) value));
}
}
} catch (Exception e) {
}
}
private boolean checkLocal (Object o) {
return THREAD_LOCAL.get().contains(o);
}
private void setLocal (Object o) {
THREAD_LOCAL.get().add(o);
}
private void removeLocal () {
THREAD_LOCAL.get().clear();
}
private static Field[] getAllFields (Class<?> clazz) {
List<Field> fieldList = new ArrayList<>();
while (clazz != null) {
fieldList.addAll(new ArrayList<>(Arrays.asList(clazz.getDeclaredFields())));
clazz = clazz.getSuperclass();
}
Field[] fields = new Field[fieldList.size()];
return fieldList.toArray(fields);
}
private static Field[] getAllFieldsCache (Class<?> clazz) {
List<Field> fieldList = new ArrayList<>();
while (clazz != null) {
if (clazz.isAnnotationPresent(Crypt.class)) {
fieldList.addAll(getDeclaredFields(clazz));
}
clazz = clazz.getSuperclass();
}
Field[] fields = new Field[fieldList.size()];
return fieldList.toArray(fields);
}
private static List<Field> getDeclaredFields (Class<?> clazz) {
List<Field> result = declaredFieldsCache.get(clazz);
if (result == null) {
try {
// 获取满足注解解密条件的字段
result = Arrays.stream(clazz.getDeclaredFields()).filter(MybatisCryptHandler::checkDecrypt).collect(Collectors.toList());
// 放入本地缓存
declaredFieldsCache.put(clazz, (result.isEmpty() ? EMPTY_FIELD_ARRAY : result));
} catch (Exception e) {
log.error("getDeclaredFields:", e);
}
}
return result;
}
}
数据表准备
用户的敏感信息包括有手机号、真实姓名、身份证、银行卡号、支付宝账号等几种。下面使用手机号和姓名字段进行加解密案例。
先准备一张Mysql数据表,表里有两个手机号和两个姓名字段,可以用于安全加解密对比。
CREATE TABLE `phone_data` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
`phone` varchar(122) DEFAULT NULL COMMENT '明文手机号',
`user_phone` varchar(122) DEFAULT NULL COMMENT '密文手机号',
`name` varchar(122) DEFAULT NULL COMMENT '明文姓名',
`real_name` varchar(122) DEFAULT NULL COMMENT '密文姓名',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT='测试加解密数据表';
项目demo搭建
首先搭建一个springboot的项目,把一些基础配置类创建:如controller、service、mapper、xml、entity,为了快速简易的demo示例,这里去掉service层
@Data
public class PhoneData {
private Integer id;
private String phone;
private String userPhone;
private String name;
private String realName;
public static PhoneData build (String phone) {
return build(null, phone);
}
public static PhoneData build (Integer id, String phone) {
final PhoneData phoneData = new PhoneData();
phoneData.setId(id);
phoneData.setPhone(phone);
phoneData.setUserPhone(phone);
phoneData.setName(phone);
phoneData.setRealName(phone);
return phoneData;
}
}
@Slf4j
@RestController
public class AopMapperController {
@Autowired
private PhoneDataMapper phoneDataMapper;
@PostMapping("/aop/insert")
public String insert (@RequestParam String phone) {
PhoneData build = PhoneData.build(phone);
phoneDataMapper.insert(build);
log.info(" 插入的原数据 = {}", JSON.toJSONString(build));
return "ok";
}
@PostMapping("/aop/update")
public String update (@RequestParam Integer id, @RequestParam String phone) {
PhoneData build = PhoneData.build(id, phone);
phoneDataMapper.updateById(build);
log.info(" 插入的原数据 = {}", JSON.toJSONString(build));
return "ok";
}
@GetMapping("/aop/select")
public String select (@RequestParam String phone) {
final PhoneData build = PhoneData.build(phone);
// 对象类型入参查询对象数据
List<PhoneData> selectList = phoneDataMapper.selectList(build);
log.info(" selectList = {}", JSON.toJSONString(selectList));
return "ok";
}
}
@Mapper
public interface PhoneDataMapper {
@Insert("insert into phone_data (phone, user_phone, name, real_name) values (#{phone}, #{userPhone}, #{name}, #{realName})")
void insert (PhoneData phoneData);
@Update("update phone_data set phone = #{phone}, user_phone = #{userPhone}, name = #{name}, real_name = #{realName} where id = #{id}")
void updateById (PhoneData phoneData);
@Select("select id, phone, user_phone userPhone, name, real_name realName from phone_data where user_phone = #{userPhone}")
List<PhoneData> selectList (PhoneData phoneData);
}
项目启动,访问添加、更新、查询接口,其sql日志打印出结果如下:
2022-01-07 14:46:35.348 DEBUG 6688 --- [ XNIO-1 task-1] c.m.web.mapper.PhoneDataMapper.insert : ==> Preparing: insert into phone_data (phone, user_phone, name, real_name) values (?, ?, ?, ?)
2022-01-07 14:46:35.348 DEBUG 6688 --- [ XNIO-1 task-1] c.m.web.mapper.PhoneDataMapper.insert : ==> Parameters: 15222222222(String), ZHlSotVArLBAviP2KWi3Cg==(String), 15222222222(String), ZHlSotVArLBAviP2KWi3Cg==(String)
2022-01-07 14:46:35.421 DEBUG 6688 --- [ XNIO-1 task-1] c.m.web.mapper.PhoneDataMapper.insert : <== Updates: 1
2022-01-07 14:46:35.422 INFO 6688 --- [ XNIO-1 task-1] c.m.web.controller.AopMapperController : 插入的原数据 = {"name":"15222222222","phone":"15222222222","realName":"15222222222","userPhone":"15222222222"}
2022-01-07 14:46:54.470 DEBUG 6688 --- [ XNIO-1 task-1] c.m.w.mapper.PhoneDataMapper.updateById : ==> Preparing: update phone_data set phone = ?, user_phone = ?, name = ?, real_name = ? where id = ?
2022-01-07 14:46:54.470 DEBUG 6688 --- [ XNIO-1 task-1] c.m.w.mapper.PhoneDataMapper.updateById : ==> Parameters: 15222222222(String), ZHlSotVArLBAviP2KWi3Cg==(String), 15222222222(String), ZHlSotVArLBAviP2KWi3Cg==(String), 1(Integer)
2022-01-07 14:46:54.540 DEBUG 6688 --- [ XNIO-1 task-1] c.m.w.mapper.PhoneDataMapper.updateById : <== Updates: 1
2022-01-07 14:46:54.540 INFO 6688 --- [ XNIO-1 task-1] c.m.web.controller.AopMapperController : 插入的原数据 = {"id":1,"name":"15222222222","phone":"15222222222","realName":"15222222222","userPhone":"15222222222"}
2022-01-07 14:46:55.754 DEBUG 6688 --- [ XNIO-1 task-1] c.m.w.mapper.PhoneDataMapper.selectList : ==> Preparing: select id, phone, user_phone userPhone, name, real_name realName from phone_data where user_phone = ?
2022-01-07 14:46:55.754 DEBUG 6688 --- [ XNIO-1 task-1] c.m.w.mapper.PhoneDataMapper.selectList : ==> Parameters: ZHlSotVArLBAviP2KWi3Cg==(String)
2022-01-07 14:46:55.790 DEBUG 6688 --- [ XNIO-1 task-1] c.m.w.mapper.PhoneDataMapper.selectList : <== Total: 1
2022-01-07 14:46:55.790 INFO 6688 --- [ XNIO-1 task-1] c.m.web.controller.AopMapperController : selectList = [{"id":1,"name":"15222222222","phone":"15222222222","realName":"15222222222","userPhone":"15222222222"}]
MySQL数据库中的数据
总结
总结一下上述实现逻辑:
- 在Mybatis自定义拦截器中,对各种对数据库的查询参数进行拦截,判断当前对象内字段是否需要加密,如果有注解就进行加密操作,否则就不操作。再对数据库查询出的结果进行拦截,判断查询结果对象内字段是否需要解密,如果有注解就进行解密操作,否则就不操作。
- 并且增加了一步对本次请求参数进行参数还原解密操作。
- 这样通过类和字段上增加注解,就完成自动安全加解密操作。
上述拦截器实现方式有一定局限性:
- 整个代码逻辑还可以在优化,比如之前对请求参数还原解密方式的优化。
- 主要实现逻辑是在MybatisCryptHandler处理工具类中,当前方式现只能处理请求参数和查询结果是对象类而不是字符串类型,在下篇文章中会介绍如何针对字符串进行过滤拦截。
到此这篇关于详解Mybatis拦截器安全加解密MySQL数据实战的文章就介绍到这了,更多相关Mybatis拦截器安全加解密MySQL内容请搜索编程网以前的文章或继续浏览下面的相关文章希望大家以后多多支持编程网!
免责声明:
① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。
② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341