用户的一些敏感数据,例如手机号、邮箱、身份证等信息,在数据库以明文存储时会存在数据泄露的风险,因此需要进行加密, 但存储数据再被取出时,需要进行解密,因此加密算法需要使用对称加密算法。
常用的对称加密算法有AES、DES、RC、BASE64等等,各算法的区别与优劣请自行百度。
本案例采用AES算法对数据进行加密。
本文基于SpringBoot+MybatisPlus(3.5.X)+MySQL8架构,Dao层与DB中间使用MP的拦截器机制,对数据存取过程进行拦截,实现数据的加解密操作。
该加解密拦截器功能在wutong-base-dao包(公司内部包)已经实现,如果您的项目已经依赖了base-dao,就可以直接使用。
另外,在码云上有Demo案例,见: mybatis-plus加解密Demo
基于wutong-base-dao包的使用步骤如下。
com.talkweb wutong-base-dao请使用最新版本
mybatis-plus: wutong: encrypt: # 是否开启敏感数据加解密,默认false enable: true # AES加密秘钥,可以使用hutool的SecureUtil工具类生成 secretKey: yourSecretKey
// 必须使用@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; }
通过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 // LambdaUpdateWrapperwrapper = 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; } }
根据注解进行数据拦截
/** * 需要加解密的实体类用这个注解 * @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 { }
加密拦截器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, Pagepage); * * 不支持: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); } privateT 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等多种场景,因此未采用。
字段类型处理器的[官方文档点这里],不能处理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; }
自动填充功能的[官方文档点这里],不能处理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; }