目录

    • 1.场景介绍
    • 2.Maven依赖
    • 2.AESUtil.java 加解密工具类
    • 3.字段处理类
    • 4.修改 MyBatis Plus 查询
      • 4.1 修改表对应实体类
      • 4.2 修改加密字段对应属性
      • 4.3 修改 xml 使用 ResultMap
      • 4.4 修改 xml 中 el 表达式
    • 5.测试结果
    • 6.MyBatis Plus 缺陷
    • 7.历史数据加密处理程序
    • 补充:测试实例
      • 1 查询测试
        • 1.1 查询信息,SQL实现
        • 1.2 查询信息,QueryWrapper实现
        • 1.3 查询信息,根据加密字段查询,SQL实现
        • 1.4 查询信息,根据加密字段查询,QueryWrapper实现
      • 2.测试更新
        • 2.1 更新信息,SQL实现
        • 2.2 更新信息,UpdateWrapper实现
        • 2.3 更新信息,LambdaUpdateWrapper实现
        • 2.4 更新信息,updateById实现
      • 3.测试插入
        • 7.3.1 插入信息,SQL实现
        • 3.2 插入信息,Service实现

1.场景介绍

  • 当项目开发到一半,可能突然客户会要求对数据库里面比如手机号、身份证号的字段进行加密;
  • 在保证开发最快、影响范围最小的情况下,我们需要选择一种介于数据库和代码之间的工具来帮我们实现自动加解密;

2.Maven依赖

<dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-boot-starter</artifactId><version>3.4.1</version></dependency><dependency><groupId>com.github.pagehelper</groupId><artifactId>pagehelper</artifactId><version>5.1.11</version><exclusions><exclusion><groupId>com.github.jsqlparser</groupId><artifactId>jsqlparser</artifactId></exclusion></exclusions></dependency>

2.AESUtil.java 加解密工具类

这里我们选用AES对称加密算法,因为它是可逆算法。

AES加密介绍: https://blog.csdn.net/qq_33204709/article/details/126930720

具体实现代码如下:

import org.apache.commons.lang3.RandomStringUtils;import org.apache.commons.lang3.StringUtils;import org.slf4j.Logger;import org.slf4j.LoggerFactory;import org.springframework.util.Base64Utils;import javax.crypto.Cipher;import javax.crypto.spec.IvParameterSpec;import javax.crypto.spec.SecretKeySpec;/** * AES加密工具类 * * @author ACGkaka * @since 2021-06-18 19:11:03 */public class AESUtil {/** * 日志相关 */private static final Logger LOGGER = LoggerFactory.getLogger(AESUtil.class);/** * 编码 */private static final String ENCODING = "UTF-8";/** * 算法定义 */private static final String AES_ALGORITHM = "AES";/** * 指定填充方式 */private static final String CIPHER_PADDING = "AES/ECB/PKCS5Padding";private static final String CIPHER_CBC_PADDING = "AES/CBC/PKCS5Padding";/** * 偏移量(CBC中使用,增强加密算法强度) */private static final String IV_SEED = "1234567812345678";/** * AES加密 * @param content 待加密内容 * @param aesKey密码 * @return */public static String encrypt(String content, String aesKey){if(StringUtils.isBlank(content)){LOGGER.info("AES encrypt: the content is null!");return null;}//判断秘钥是否为16位if(StringUtils.isNotBlank(aesKey) && aesKey.length() == 16){try {//对密码进行编码byte[] bytes = aesKey.getBytes(ENCODING);//设置加密算法,生成秘钥SecretKeySpec skeySpec = new SecretKeySpec(bytes, AES_ALGORITHM);// "算法/模式/补码方式"Cipher cipher = Cipher.getInstance(CIPHER_PADDING);//选择加密cipher.init(Cipher.ENCRYPT_MODE, skeySpec);//根据待加密内容生成字节数组byte[] encrypted = cipher.doFinal(content.getBytes(ENCODING));//返回base64字符串return Base64Utils.encodeToString(encrypted);} catch (Exception e) {LOGGER.info("AES encrypt exception:" + e.getMessage());throw new RuntimeException(e);}}else {LOGGER.info("AES encrypt: the aesKey is null or error!");return null;}}/** * 解密 ** @param content 待解密内容 * @param aesKey密码 * @return */public static String decrypt(String content, String aesKey){if(StringUtils.isBlank(content)){LOGGER.info("AES decrypt: the content is null!");return null;}//判断秘钥是否为16位if(StringUtils.isNotBlank(aesKey) && aesKey.length() == 16){try {//对密码进行编码byte[] bytes = aesKey.getBytes(ENCODING);//设置解密算法,生成秘钥SecretKeySpec skeySpec = new SecretKeySpec(bytes, AES_ALGORITHM);// "算法/模式/补码方式"Cipher cipher = Cipher.getInstance(CIPHER_PADDING);//选择解密cipher.init(Cipher.DECRYPT_MODE, skeySpec);//先进行Base64解码byte[] decodeBase64 = Base64Utils.decodeFromString(content);//根据待解密内容进行解密byte[] decrypted = cipher.doFinal(decodeBase64);//将字节数组转成字符串return new String(decrypted, ENCODING);} catch (Exception e) {LOGGER.info("AES decrypt exception:" + e.getMessage());throw new RuntimeException(e);}}else {LOGGER.info("AES decrypt: the aesKey is null or error!");return null;}}/** * AES_CBC加密 ** @param content 待加密内容 * @param aesKey密码 * @return */public static String encryptCBC(String content, String aesKey){if(StringUtils.isBlank(content)){LOGGER.info("AES_CBC encrypt: the content is null!");return null;}//判断秘钥是否为16位if(StringUtils.isNotBlank(aesKey) && aesKey.length() == 16){try {//对密码进行编码byte[] bytes = aesKey.getBytes(ENCODING);//设置加密算法,生成秘钥SecretKeySpec skeySpec = new SecretKeySpec(bytes, AES_ALGORITHM);// "算法/模式/补码方式"Cipher cipher = Cipher.getInstance(CIPHER_CBC_PADDING);//偏移IvParameterSpec iv = new IvParameterSpec(IV_SEED.getBytes(ENCODING));//选择加密cipher.init(Cipher.ENCRYPT_MODE, skeySpec, iv);//根据待加密内容生成字节数组byte[] encrypted = cipher.doFinal(content.getBytes(ENCODING));//返回base64字符串return Base64Utils.encodeToString(encrypted);} catch (Exception e) {LOGGER.info("AES_CBC encrypt exception:" + e.getMessage());throw new RuntimeException(e);}}else {LOGGER.info("AES_CBC encrypt: the aesKey is null or error!");return null;}}/** * AES_CBC解密 ** @param content 待解密内容 * @param aesKey密码 * @return */public static String decryptCBC(String content, String aesKey){if(StringUtils.isBlank(content)){LOGGER.info("AES_CBC decrypt: the content is null!");return null;}//判断秘钥是否为16位if(StringUtils.isNotBlank(aesKey) && aesKey.length() == 16){try {//对密码进行编码byte[] bytes = aesKey.getBytes(ENCODING);//设置解密算法,生成秘钥SecretKeySpec skeySpec = new SecretKeySpec(bytes, AES_ALGORITHM);//偏移IvParameterSpec iv = new IvParameterSpec(IV_SEED.getBytes(ENCODING));// "算法/模式/补码方式"Cipher cipher = Cipher.getInstance(CIPHER_CBC_PADDING);//选择解密cipher.init(Cipher.DECRYPT_MODE, skeySpec, iv);//先进行Base64解码byte[] decodeBase64 = Base64Utils.decodeFromString(content);//根据待解密内容进行解密byte[] decrypted = cipher.doFinal(decodeBase64);//将字节数组转成字符串return new String(decrypted, ENCODING);} catch (Exception e) {LOGGER.info("AES_CBC decrypt exception:" + e.getMessage());throw new RuntimeException(e);}}else {LOGGER.info("AES_CBC decrypt: the aesKey is null or error!");return null;}}public static void main(String[] args) {// AES支持三种长度的密钥:128位、192位、256位。// 代码中这种就是128位的加密密钥,16字节 * 8位/字节 = 128位。String random = RandomStringUtils.random(16, "abcdefghijklmnopqrstuvwxyz1234567890");System.out.println("随机key:" + random);System.out.println();System.out.println("---------加密---------");String aesResult = encrypt("测试AES加密12", random);System.out.println("aes加密结果:" + aesResult);System.out.println();System.out.println("---------解密---------");String decrypt = decrypt(aesResult, random);System.out.println("aes解密结果:" + decrypt);System.out.println();System.out.println("--------AES_CBC加密解密---------");String cbcResult = encryptCBC("测试AES加密12456", random);System.out.println("aes_cbc加密结果:" + cbcResult);System.out.println();System.out.println("---------解密CBC---------");String cbcDecrypt = decryptCBC(cbcResult, random);System.out.println("aes解密结果:" + cbcDecrypt);System.out.println();}}

3.字段处理类

import com.demo.util.AESUtil;import org.apache.ibatis.type.BaseTypeHandler;import org.apache.ibatis.type.JdbcType;import java.sql.CallableStatement;import java.sql.PreparedStatement;import java.sql.ResultSet;import java.sql.SQLException;/** * 

@Title MyEncryptTypeHandler *

@Description 字段加密处理 * * @author ACGkaka * @date 2023/2/21 17:20 */public class MyEncryptTypeHandler extends BaseTypeHandler<String> {@Overridepublic void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException {ps.setString(i, AESUtil.defaultEncrypt(parameter));}@Overridepublic String getNullableResult(ResultSet rs, String column) throws SQLException {return AESUtil.defaultDecrypt(rs.getString(column));}@Overridepublic String getNullableResult(ResultSet rs, int columnIndex) throws SQLException {return AESUtil.defaultDecrypt(rs.getString(columnIndex));}@Overridepublic String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {return AESUtil.defaultDecrypt(cs.getString(columnIndex));}}

4.修改 MyBatis Plus 查询

4.1 修改表对应实体类

设置 @TableName 注解的 autoResultMap 为 true,默认 false。

import com.baomidou.mybatisplus.annotation.TableName;import lombok.Data;import java.io.Serializable;/** * 用户表 * * @author ACGkaka * @date 2023/2/21 17:20 */@Data@TableName(value = "t_user_info", autoResultMap = true)public class UserInfo implements Serializable {}

4.2 修改加密字段对应属性

设置 @TableField 注解的 typeHandlerMyEncryptTypeHandler.class

import com.demo.encrypt.MyEncryptTypeHandler;import com.baomidou.mybatisplus.annotation.TableField;import com.baomidou.mybatisplus.annotation.TableName;import lombok.Data;import java.io.Serializable;/** * 用户表 * * @author ACGkaka * @date 2023/2/21 17:20 */@Data@TableName(value = "t_user_info", autoResultMap = true)public class UserInfo implements Serializable {/** * 手机号码 */@TableField(value = "PHONE", typeHandler = MyEncryptTypeHandler.class)private String phone;}

4.3 修改 xml 使用 ResultMap

1)创建 ResultMap 映射,指定 typeHandler

2)查询语句使用 ResultMap 返回。

<!-- 通用查询映射结果 --><resultMap id="BaseResultMap" type="com.demo.model.UserInfo"><id column="ID" property="id" /><result column="ACCOUNT" property="staffCode" /><result column="PHONE" property="phone" typeHandler="com.demo.encrypt.MyEncryptTypeHandler" /></resultMap><!-- 查询全部 --><select id="findAll" resultMap="BaseResultMap">SELECT * FROM t_user_info</select>

4.4 修改 xml 中 el 表达式

设置好 4.1 和 4.2 就可以保证

修改前:

<update id="updatePhoneById">update t_user_info set phone = #{phone} where id = #{id}</update><select id="findByPhone" resultMap="BaseResultMap">SELECT * FROM t_user_info where phone = #{phone}</select>

修改后:

<update id="updatePhoneById">update t_user_info set phone = #{phone, typeHandler=com.demo.encrypt.MyEncryptTypeHandler} where id = #{id}</update><select id="findByPhone" resultMap="BaseResultMap">SELECT * FROM t_user_info where phone = #{phone, typeHandler=com.demo.encrypt.MyEncryptTypeHandler}</select>

5.测试结果

由于测试内容较多,这里先直接展示测试结果,具体测试示例可以看 补充:测试实例

操作实现方式入参测试结果
SELECT原生SQL非加密字段出参解密成功
SELECTQueryWrapper非加密字段出参解密成功
SELECT原生SQL加密字段入参加密成功
SELECTQueryWrapper加密字段入参加密失败
UPDATE原生SQL加密字段入参加密成功
UPDATEUpdateWrapper加密字段入参加密失败
UPDATELambdaUpdateWrapper加密字段入参加密成功
UPDATEupdateById加密字段入参加密成功
INSERTService加密字段入参加密成功

说明:

  • 官方的解答是 QueryWrapper、UpdateWrapper 底层是通过 @Param 来实现的,目前没有做到入参支持 typeHandler,如果做的话会影响性能。
  • LambdaUpdateWrapper 要求 MyBatis-Plus 版本为 3.5.3,PageHelper 也需要升级为 5.1.11,但是升级之后 PageHelper 分页不好使了,待优化。(升级后依赖参考 补充:2.3)

6.MyBatis Plus 缺陷

  • QueryWrapper 不支持入参加密;

  • UpdateWrapper 不支持入参加密;

  • 加密字段不支持模糊查询。

7.历史数据加密处理程序

@Overridepublic void encryptUser() {// 加密 用户信息int count = this.count();int pageSize = 1000;int pageCount = count / pageSize + 1;// 必须用唯一且非空字段进行排序,否则 pageHelper 查出来的数据可能会有重复。QueryWrapper<UserInfo> queryWrapper = new QueryWrapper<UserInfo>().orderByAsc("id");for (int i = 0; i < pageCount; i++) {log.info(">>>>>>>>>> 【INFO】加密用户信息,当前页数:{},总页数:{}", i + 1, pageCount);PageHelper.startPage(i + 1, pageSize);List<UserInfo> users = this.list(queryWrapper);new PageInfo<>(users);users.parallelStream().forEach(o -> {// 解密重复加密手机号while (AESUtil.defaultDecrypt(o.getPhoneNumber()) != null) {o.setPhoneNumber(AESUtil.defaultDecrypt(o.getPhoneNumber()));}// 解密重复加密身份证号while (AESUtil.defaultDecrypt(o.getIdCard()) != null) {o.setIdCard(AESUtil.defaultDecrypt(o.getIdCard()));}});this.updateBatchById(users);}}

一般手机号AES加密后长度为32,我们可以根据这点通过SQL检查加密情况:

select '未加密数量' state, COUNT(*) from t_user_info where length(phone_number) < 32 union allselect '重复加密数量' state, COUNT(*)from t_user_info where length(phone_number) > 32;

补充:测试实例

1 查询测试

1.1 查询信息,SQL实现

@Testpublic void getUserInfoTest1() {UserInfo userInfo = userInfoService.findByAccount("testAccount");System.out.println("userInfo:" + userInfo);System.out.println("phone:" + userInfo.getPhone());}

测试结果:出参解密成功

1.2 查询信息,QueryWrapper实现

@Testpublic void getUserInfoTest2() {QueryWrapper<UserInfo> wrapper = new QueryWrapper<>();wrapper.eq("account", "testAccount");List<UserInfo> users = userInfoService.list(wrapper);System.out.println("userInfo:" + users);System.out.println("phone:" + users.get(0).getPhone());}

测试结果:出参解密成功

1.3 查询信息,根据加密字段查询,SQL实现

@Testpublic void getUserInfoTest3() {UserInfo user = userInfoService.findByPhone("13888888888");System.out.println("userInfo:" + user);System.out.println("phone:" + user.getPhone());}

(注意:入参需要使用el表达式指定 typeHandler)

测试结果:入参加密成功

1.4 查询信息,根据加密字段查询,QueryWrapper实现

@Testpublic void getUserInfoTest3() {QueryWrapper<UserInfo> wrapper = new QueryWrapper<>();wrapper.lambda().eq(UserInfo::getPhone, "13888888888");List<UserInfo> users = userInfoService.list(wrapper);System.out.println("userInfo:" + users);System.out.println("phone:" + users.get(0).getPhone());}

测试结果:入参加密失败,QueryWrapper底层使用 @Param 实现,无法像 SQL 实现一样指定 typeHandler。

2.测试更新

2.1 更新信息,SQL实现

@Testpublic void updateUserInfoTest1() {userInfoService.updatePhoneByAccount("testAccount", "13888888888");}

测试结果:入参加密成功

2.2 更新信息,UpdateWrapper实现

@Testpublic void updateUserInfoTest2() {UpdateWrapper<UserInfo> wrapper = new UpdateWrapper<>();wrapper.set("phone", "13888888888");wrapper.eq("account", "testAccount");userInfoService.update(wrapper);getUserInfoTest1();}

测试结果:入参加密失败,UpdateWrapper底层使用 @Param 实现,无法像 SQL 实现一样指定 typeHandler。

2.3 更新信息,LambdaUpdateWrapper实现

@Testpublic void updateUserInfoTest3() {LambdaUpdateWrapper<UserInfo> wrapper = Wrappers.<UserInfo>lambdaUpdate().set(UserInfo::getPhone, "13888888888", "typeHandler=com.demo.encrypt.MyEncryptTypeHandler");wrapper.eq(UserInfo::getAccount, "testAccount");userInfoService.update(wrapper);getUserInfoTest1();}

测试结果:入参加密成功(3.5.3支持,但是升级之后 PageHelper 分页不好使了,待优化)

升级后依赖:

<dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-boot-starter</artifactId><version>3.5.3</version></dependency><dependency><groupId>com.github.pagehelper</groupId><artifactId>pagehelper</artifactId><version>5.1.11</version><exclusions><exclusion><groupId>com.github.jsqlparser</groupId><artifactId>jsqlparser</artifactId></exclusion></exclusions></dependency>

2.4 更新信息,updateById实现

@Testpublic void updateUserInfoTest4() {UserInfo userInfo = userInfoService.findByAccount("testAccount");userInfo.setPhone("13888888888");userInfoService.updateById(userInfo);}

测试结果:入参加密成功

3.测试插入

7.3.1 插入信息,SQL实现

@Testpublic void insertUserInfoTest1() {UserInfo userInfo = userInfoService.findByAccount("testAccount");userInfo.setAccount("testAccount_002");userInfo.setPhone("13888888888");userInfoService.save(userInfo);UserInfo newUserInfo = userInfoService.findByAccount("testAccount_002");System.out.println("userInfo:" + newUserInfo);System.out.println("phone:" + newUserInfo.getPhone());}

测试结果:入参加密成功

3.2 插入信息,Service实现

@Testpublic void insertUserInfoTest1() {UserInfo userInfo = userInfoService.findByAccount("testAccount");userInfo.setAccount("testAccount_002");userInfo.setPhone("13888888888");userInfoService.save(userInfo);UserInfo newUserInfo = userInfoService.findByAccount("testAccount_002");System.out.println("userInfo:" + newUserInfo);System.out.println("phone:" + newUserInfo.getPhone());}

测试结果:入参加密成功

整理完毕,完结撒花~

参考地址:

1.mybaits plus 字段加密与解密,https://blog.csdn.net/qq_21134059/article/details/121752978

2.mybatis plus 官方问题页面,https://github.com/baomidou/mybatis-plus/issues

3.更新时自定义的TypeHandler不生效,https://github.com/baomidou/mybatis-plus/issues/794

4.lambdaUpdate() 无法更新Json对象字段,https://github.com/baomidou/mybatis-plus/issues/5031

5.LambdaUpdateWrapper不支持自定义BaseTypeHandler,https://github.com/baomidou/mybatis-plus/issues/3317