为了数据安全问题,有时候需要将部分敏感字段加密后再入库,查询时又需要将其解密后返回给前端使用。我们可以用Mybatis的拦截器来实现这一需求。
- 定义一个注解,用来标识需要加解密的字段。
为了尽量减少不必要的反射操作,可以将该注解同时标识在实体类上,对于没有被标识的实体类,无需利用反射来操作其属性。
import java.lang.annotation.*;
/**
* 标识需要加解密的类及其属性
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.TYPE})
public @interface FieldEncrypt {
}
- 定义加密拦截器
拦截所有insert和update操作,拿到实体对象,并通过反射获取到所有标记了@FieldExcrypt注解的属性,将其值进行加密并替换
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.binding.MapperMethod;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.SqlCommandType;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Intercepts;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.plugin.Signature;
/**
* 加密拦截器
* <p>拦截所有insert和update操作,</p>
* <p>如果实体类中有属性标记有@FieldEncrypt注解,则对其进行加密替换</p>
*/
@Slf4j
@Intercepts({@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})})
public class EncryptionInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
Object[] args = invocation.getArgs();
MappedStatement ms = (MappedStatement) args[0];
SqlCommandType sqlCommandType = ms.getSqlCommandType();
log.debug("EncryptionInterceptor 操作类型:{}", sqlCommandType);
MapperMethod.ParamMap paramMap = (MapperMethod.ParamMap)args[1];
Object entity = paramMap.get("et");
try {
if (SqlCommandType.INSERT == sqlCommandType) {
FieldEncryptUtil.encryptField(entity);
} else if (SqlCommandType.UPDATE == sqlCommandType) {
FieldEncryptUtil.encryptField(entity);
log.debug("[EncryptionInterceptor] update operation,encrypt field: {}", entity);
}
} catch (Exception e) {
log.error("[EncryptionInterceptor] encryptField failed entity:{}", entity.getClass().getName(), e);
}
return invocation.proceed();
}
}
- 定义解密拦截器
拦截所有query操作,先执行sql,拿到返回的结果,并通过反射获取到所有标记了@FieldExcrypt注解的属性,将其值进行解密并替换。
请注意:结果可能是单个Bean(如selectOne方法),也有可能是ArrayList。如果是集合,则以第一个元素的Class对象为准。
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.cache.CacheKey;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Intercepts;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.plugin.Signature;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;
import java.util.ArrayList;
/**
* 解密拦截器
* <p>拦截所有query操作</p>
* <p>如果结果集合元素实体中,存在属性标记了@FieldEncrypt注解,则解密并替换</p>
*/
@Slf4j
@Intercepts({
@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class})
})
public class DecryptionInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
Object result = invocation.proceed();
try {
if(result instanceof ArrayList) {
ArrayList list = (ArrayList) result;
if(list.isEmpty()) {
return result;
}
Class objClazz = list.get(0).getClass();
if (FieldEncryptUtil.hasEncryptFields(objClazz)) {
for (Object item : list) {
FieldEncryptUtil.decryptField(item);
}
}
return result;
} else {
FieldEncryptUtil.decryptField(result);
}
} catch (Exception e) {
log.error("[DecryptionInterceptor] decryptField failed entity:{}", result.getClass().getName(), e);
}
return result;
}
}
- 加解密工具类
import com.baomidou.mybatisplus.core.toolkit.AES;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.AnnotationUtils;
import java.lang.reflect.Field;
import java.lang.reflect.Type;
import java.util.Objects;
@Slf4j
public class FieldEncryptUtil {
private static final String SECRET = "{替换成AES密钥}";
private FieldEncryptUtil() {
}
public static String encrypt(String src) {
return AES.encrypt(src, SECRET);
}
public static String decrypt(String src) {
return AES.decrypt(src, SECRET);
}
public static boolean hasEncryptFields(Class clz) {
return AnnotationUtils.isCandidateClass(clz, FieldEncrypt.class);
}
private static boolean isFieldEncrypt(Field field) {
FieldEncrypt encryptAnno = AnnotationUtils.findAnnotation(field, FieldEncrypt.class);
return Objects.nonNull(encryptAnno);
}
/**
* 对实体类字段加密替换
*/
public static void encryptField(Object object) throws IllegalAccessException {
if (Objects.isNull(object) || !hasEncryptFields(object.getClass())) {
return;
}
Class clazz = object.getClass();
Field[] fields = clazz.getDeclaredFields();
for (Field field : fields) {
Type fieldClazz = field.getGenericType();
if (!(fieldClazz instanceof Class) || !String.class.isAssignableFrom((Class)fieldClazz)) {
continue;
}
if (isFieldEncrypt(field)) {
field.setAccessible(true);
Object originVal = field.get(object);
if (null == originVal || StringUtils.isBlank(originVal.toString())) {
continue;
}
String encryptedStr = encrypt(originVal.toString());
field.set(object, encryptedStr);
log.debug("[encryptField success] encrypt {}#{}", clazz.getName(), field.getName());
}
}
}
public static void decryptField(Object object) throws IllegalAccessException {
if (Objects.isNull(object) || !hasEncryptFields(object.getClass())) {
return;
}
Class clazz = object.getClass();
Field[] fields = clazz.getDeclaredFields();
for (Field field : fields) {
Type fieldClazz = field.getGenericType();
if (!(fieldClazz instanceof Class) || !String.class.isAssignableFrom((Class)fieldClazz)) {
continue;
}
if (isFieldEncrypt(field)) {
field.setAccessible(true);
Object originVal = field.get(object);
if (null == originVal || StringUtils.isBlank(originVal.toString())) {
continue;
}
String decryptedStr = decrypt(originVal.toString());
field.set(object, decryptedStr);
log.debug("[decryptField success] {}#{}", clazz.getName(), field.getName());
}
}
}
}
实体类示例(对身份证号进行加密存储)
@FieldEncrypt
@TableName("user")
public class User {
@TableId
private Long id;
private String username;
private String phone;
@FieldEncrypt
private String idCard;
}
注意事项
因为我是在starter组件里实现的,所以两个拦截器没有使用@Component注解标识,而是使用AutoConfiguration声明的。如果你是在项目中直接实现的,请将其交给Spring容器进行管理。
因为加解密是针对字符串而言的,所以,在进行加解密时需要判断字段类型,如果注解错误地标识在了非String类型字段上,要么忽略,要么根据实际需求实现逻辑。
工具类中写死了AES密钥,这个密钥必须全局唯一,否则会出错。
为防止意外错误发生,导致代码报错,我这里在加解密过程中若发生异常,默认是不替换,直接返回原值。