目录
1.场景介绍
- 当项目开发到一半,可能突然客户会要求对数据库里面比如手机号、身份证号的字段进行加密;
- 在保证开发最快、影响范围最小的情况下,我们需要选择一种介于数据库和代码之间的工具来帮我们实现自动加解密;
2.Maven依赖
<!-- mybatis-plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.1</version>
</dependency>
<!-- mybatis的分页插件 -->
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper</artifactId>
<version>5.1.11</version>
<!-- pagehelper 包含该依赖存在版本冲突,因此不建议和 mp 一起混用 -->
<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;
/**
* <p> @Title MyEncryptTypeHandler
* <p> @Description 字段加密处理
*
* @author ACGkaka
* @date 2023/2/21 17:20
*/
public class MyEncryptTypeHandler extends BaseTypeHandler<String> {
@Override
public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException {
ps.setString(i, AESUtil.defaultEncrypt(parameter));
}
@Override
public String getNullableResult(ResultSet rs, String column) throws SQLException {
return AESUtil.defaultDecrypt(rs.getString(column));
}
@Override
public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
return AESUtil.defaultDecrypt(rs.getString(columnIndex));
}
@Override
public 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
注解的 typeHandler
为 MyEncryptTypeHandler.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 | 非加密字段 | 出参解密成功 |
SELECT | QueryWrapper | 非加密字段 | 出参解密成功 |
SELECT | 原生SQL | 加密字段 | 入参加密成功 |
SELECT | QueryWrapper | 加密字段 | 入参加密失败 |
UPDATE | 原生SQL | 加密字段 | 入参加密成功 |
UPDATE | UpdateWrapper | 加密字段 | 入参加密失败 |
UPDATE | LambdaUpdateWrapper | 加密字段 | 入参加密成功 |
UPDATE | updateById | 加密字段 | 入参加密成功 |
INSERT | Service | 加密字段 | 入参加密成功 |
说明:
- 官方的解答是 QueryWrapper、UpdateWrapper 底层是通过 @Param 来实现的,目前没有做到入参支持 typeHandler,如果做的话会影响性能。
- LambdaUpdateWrapper 要求 MyBatis-Plus 版本为 3.5.3,PageHelper 也需要升级为 5.1.11,但是升级之后 PageHelper 分页不好使了,待优化。(升级后依赖参考 补充:2.3)
6.MyBatis Plus 缺陷
-
QueryWrapper 不支持入参加密;
-
UpdateWrapper 不支持入参加密;
-
加密字段不支持模糊查询。
7.历史数据加密处理程序
@Override
public 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 all
select '重复加密数量' state, COUNT(*) from t_user_info where length(phone_number) > 32;
补充:测试实例
1 查询测试
1.1 查询信息,SQL实现
@Test
public void getUserInfoTest1() {
UserInfo userInfo = userInfoService.findByAccount("testAccount");
System.out.println("userInfo:" + userInfo);
System.out.println("phone:" + userInfo.getPhone());
}
测试结果:出参解密成功
1.2 查询信息,QueryWrapper实现
@Test
public 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实现
@Test
public void getUserInfoTest3() {
UserInfo user = userInfoService.findByPhone("13888888888");
System.out.println("userInfo:" + user);
System.out.println("phone:" + user.getPhone());
}
(注意:入参需要使用el表达式指定 typeHandler)
测试结果:入参加密成功
1.4 查询信息,根据加密字段查询,QueryWrapper实现
@Test
public 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实现
@Test
public void updateUserInfoTest1() {
userInfoService.updatePhoneByAccount("testAccount", "13888888888");
}
测试结果:入参加密成功
2.2 更新信息,UpdateWrapper实现
@Test
public 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实现
@Test
public 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 分页不好使了,待优化)
升级后依赖:
<!-- mybatis-plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3</version>
</dependency>
<!-- mybatis的分页插件 -->
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper</artifactId>
<version>5.1.11</version>
<!-- pagehelper 包含该依赖存在版本冲突,因此不建议和 mp 一起混用 -->
<exclusions>
<exclusion>
<groupId>com.github.jsqlparser</groupId>
<artifactId>jsqlparser</artifactId>
</exclusion>
</exclusions>
</dependency>
2.4 更新信息,updateById实现
@Test
public void updateUserInfoTest4() {
UserInfo userInfo = userInfoService.findByAccount("testAccount");
userInfo.setPhone("13888888888");
userInfoService.updateById(userInfo);
}
测试结果:入参加密成功
3.测试插入
7.3.1 插入信息,SQL实现
@Test
public 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实现
@Test
public 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