一、背景
用户的一些敏感数据,例如手机号、邮箱、身份证等信息,在数据库以明文存储时会存在数据泄露的风险,因此需要进行加密, 但存储数据再被取出时,需要进行解密,因此加密算法需要使用对称加密算法。
常用的对称加密算法有AES、DES、RC、BASE64等等,各算法的区别与优劣请自行百度。
本案例采用AES算法对数据进行加密。
二、MybatisPlus拦截器介绍
本文基于SpringBoot+MybatisPlus(3.5.X)+MySQL8架构,Dao层与DB中间使用MP的拦截器机制,对数据存取过程进行拦截,实现数据的加解密操作。
三、使用方法
该加解密拦截器功能在wutong-base-dao包(公司内部包)已经实现,如果您的项目已经依赖了base-dao,就可以直接使用。
另外,在码云上有Demo案例,见: mybatis-plus加解密Demo
基于wutong-base-dao包的使用步骤如下。
1、添加wutong-base-dao依赖
com.talkweb
wutong-base-dao
请使用最新版本
2、在yaml配置开关,启用加解密
mybatis-plus:
wutong:
encrypt:
# 是否开启敏感数据加解密,默认false
enable: true
# AES加密秘钥,可以使用hutool的SecureUtil工具类生成
secretKey: yourSecretKey
3、定义PO类
实体类上使用自定义注解,来标记需要进行加解密
// 必须使用@EncryptedTable注解
@EncryptedTable
@TableName(value = "wsp_user")
public class UserEntity implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private String name;
// 使用@EncryptedColumn注解
@EncryptedColumn
private String mobile;
// 使用@EncryptedColumn注解
@EncryptedColumn
private String email;
}
4、定义API接口
通过MP自带API、Lambda、自定义mapper接口三种方式进行测试
/**
* 用户表控制器
*
* @author wangshaopeng@talkweb.com.cn
* @Date 2023-01-11
*/
@RestController
@RequestMapping("/user")
public class UserController {
@Resource(name = "userServiceImpl")
private IUserService userService;
@Resource(name = "userXmlServiceImpl")
private IUserService userXmlService;
/**
* 测试解密
*/
@GetMapping(name = "测试解密", value = "/detail")
public UserEntity detail(Long id) {
// 测试MP API
// UserEntity entity = userService.getById(id);
// 测试自定义Mapper接口
UserEntity entity = userXmlService.getById(id);
if (null == entity) {
return new UserEntity();
}
return entity;
}
/**
* 新增用户表,测试加密
*/
@GetMapping(name = "新增用户表,测试加密", value = "/add")
public UserEntity add(UserEntity entity) {
// 测试MP API
// userService.save(entity);
// 测试自定义Mapper接口
userXmlService.save(entity);
return entity;
}
/**
* 修改用户表
*/
@GetMapping(name = "修改用户表", value = "/update")
public UserEntity update(UserEntity entity) {
// 测试MP API
// userService.updateById(entity);
// 测试Lambda
// LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper();
// wrapper.eq(UserEntity::getId, entity.getId());
// wrapper.set(UserEntity::getMobile, entity.getMobile());
// wrapper.set(UserEntity::getName, entity.getName());
// wrapper.set(UserEntity::getEmail, entity.getEmail());
// userService.update(wrapper);
// 测试自定义Mapper接口
userXmlService.updateById(entity);
return entity;
}
}
四、实现原理
1、自定义注解
根据注解进行数据拦截
/**
* 需要加解密的实体类用这个注解
* @author wangshaopeng@talkweb.com.cn
* @Date 2023-05-31
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface EncryptedTable {
}
/**
* 需要加解密的字段用这个注解
* @author wangshaopeng@talkweb.com.cn
* @Date 2023-05-31
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface EncryptedColumn {
}
2、定义拦截器
加密拦截器EncryptInterceptor
/**
* 加密拦截器
*
* @author wangshaopeng@talkweb.com.cn
* @Date 2023-05-31
*/
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 beforeQuery(Executor executor, MappedStatement mappedStatement, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) {
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 page);
*
* 不支持:findOne(@Param(value = "mobile") String mobile);
* 不支持:findList(QueryWrapper wrapper);
*/
if (param instanceof AbstractWrapper || param instanceof String) {
// Wrapper、String类型查询参数,无法获取参数变量上的注解,无法确认是否需要加密,因此不做判断
continue;
}
if (annotateWithEncrypt(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 (annotateWithEncrypt(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 (annotateWithEncrypt(param.getClass())) {
encryptEntity(param);
}
return;
}
// 通过在mapper.xml中自定义API修改数据库时
if (paramMap.containsKey("entity") && null != (param = paramMap.get("entity"))) {
if (annotateWithEncrypt(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 (annotateWithEncrypt(entityClass)) {
encryptWrapper(entityClass, param);
}
}
return;
}
}
/**
* 校验该实例的类是否被@EncryptedTable所注解
*/
private boolean annotateWithEncrypt(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, AESUtils.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();
String[] elArr = sqlSet.split(",");
Map 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());
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, AESUtils.encrypt(value.toString()));
}
}
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());
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, AESUtils.encrypt(value.toString()));
}
}
}
}
解密拦截器
/**
* 解密拦截器
*
* @author wangshaopeng@talkweb.com.cn
* @Date 2023-05-31
*/
@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 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, AESUtils.decrypt(value));
}
}
}
return result;
}
}
四、其他实现方案
在技术调研过程中,还测试了另外两种便宜实现方案,由于无法覆盖MP自带API、Lambda、自定义API等多种场景,因此未采用。
1、使用字段类型处理器
字段类型处理器的[官方文档点这里],不能处理LambdaUpdateWrapper更新数据时加密的场景。
自定义类型处理器,实现加解密:
/**
* @author wangshaopeng@talkweb.com.cn
* @desccription 加密类型字段处理器
* @date 2023/5/31
*/
public class EncryptTypeHandler extends BaseTypeHandler {
@Override
public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException {
ps.setString(i, AESUtils.encrypt(parameter));
}
@Override
public String getNullableResult(ResultSet rs, String columnName) throws SQLException {
final String value = rs.getString(columnName);
return AESUtils.decrypt(value);
}
@Override
public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
final String value = rs.getString(columnIndex);
return AESUtils.decrypt(value);
}
@Override
public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
final String value = cs.getString(columnIndex);
return AESUtils.decrypt(value);
}
}
在实体属性上进行指定
// @TableName注解必须指定autoResultMap = true
@EncryptedTable
@TableName(value = "wsp_user", autoResultMap = true)
public class UserEntity implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private String name;
@TableField(typeHandler = EncryptTypeHandler.class)
private String mobile;
@TableField(typeHandler = EncryptTypeHandler.class)
private String email;
}
2、自动填充功能
自动填充功能的[官方文档点这里],不能处理LambdaUpdateWrapper、自定义mapper接口更新数据时加密的场景,不支持解密的需求。
自定义类型处理器,实现加解密:
/**
* Mybatis元数据填充处理类,仅能处理MP的函数,不能处理mapper.xml中自定义的insert、update
*
* @author wangshaopeng@talkweb.com.cn
* @Date 2023-01-11
*/
public class DBMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
String mobile = (String) metaObject.getValue("mobile");
this.strictInsertFill(metaObject, "mobile", String.class, AESUtils.encrypt(mobile));
String email = (String) metaObject.getValue("email");
this.strictInsertFill(metaObject, "email", String.class, AESUtils.encrypt(email));
}
@Override
public void updateFill(MetaObject metaObject) {
String mobile = (String) metaObject.getValue("mobile");
this.strictUpdateFill(metaObject, "mobile", String.class, AESUtils.encrypt(mobile));
String email = (String) metaObject.getValue("email");
this.strictUpdateFill(metaObject, "email", String.class, AESUtils.encrypt(email));
}
}
在实体类上指定自动填充策略
@EncryptedTable
@TableName(value = "wsp_user")
public class UserEntity implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private String name;
@TableField(fill = FieldFill.INSERT_UPDATE)
private String mobile;
@TableField(fill = FieldFill.INSERT_UPDATE)
private String email;
}