在当今数据安全日益重要的背景下,数据库字段加密已成为保护敏感信息的常见做法。然而,加密后的数据给模糊查询带来了巨大挑战。本文将介绍一种基于分词密文映射表的解决方案,实现对加密字段的高效模糊查询。
考虑一个用户管理系统,其中包含手机号、身份证号、住址等敏感信息。这些字段需要加密存储以保证安全,但同时业务上又需要支持模糊查询(如根据手机号前几位查询用户)。
传统加密方式直接阻碍了模糊查询功能,因为:
加密后的数据与原始数据无相似性
相同明文加密后可能产生不同密文(取决于加密算法)
无法使用数据库的LIKE等模糊查询操作符
建立分词密文映射表:在敏感字段数据新增、修改时,对字段值进行分词组合
加密分词:对每个分词进行加密,建立分词密文与目标数据行主键的关联
查询处理:对查询关键字加密后,在映射表中进行LIKE查询获取主键
精确查询:用获取的主键回原表查询完整数据
用户表(user)
CREATE TABLE user (
CREATE TABLE `sys_person` (
`id` int NOT NULL AUTO_INCREMENT,
`user_name` varchar(255) CHARACTER SET utf8mb4 DEFAULT NULL,
`login_no` varchar(255) CHARACTER SET utf8mb4 DEFAULT NULL,
`phone_number` varchar(255) CHARACTER SET utf8mb4 DEFAULT NULL, --加密存储
`sex` varchar(255) CHARACTER SET utf8mb4 DEFAULT NULL,
`id_card` varchar(255) CHARACTER SET utf8mb4 DEFAULT NULL, --加密存储
`address` varchar(255) CHARACTER SET utf8mb4 DEFAULT NULL,
`house_number` varchar(255) CHARACTER SET utf8mb4 DEFAULT NULL, --加密存储
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4;
分词密文映射表(cipher_mapping)
CREATE TABLE `sys_person_phone_encrypt` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`person_id` int NOT NULL COMMENT '关联人员信息表主键',
`phone_key` varchar(500) CHARACTER SET utf8mb4 NOT NULL COMMENT '手机号码分词密文',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COMMENT = '人员的手机号码分词密文映射表';
// 加密字段注解
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface EncryptField {
}
// 解密字段注解
@Target(value = {ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface DecryptField {
//是否开启敏感数据脱敏处理,默认不开启
boolean open() default false;
//脱敏开始位置索引
int start() default 0;
//脱敏从开始位置向后偏移量
int offset() default 6;
}
// 加密方法注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NeedEncrypt {
}
// 解密方法注解
@Target(value = {ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface NeedDecrypt {
}
@Component
@Aspect
@Slf4j
public class DecryptAspect {
/**
* 定义需要解密的切入点
*/
@Pointcut(value = "@annotation(com.huzhongiui.aop.annotation.NeedDecrypt)")
public void pointcut() {
}
/**
* 命中的切入点时的环绕通知
*
* @param proceedingJoinPoint
* @return
* @throws Throwable
*/
@Around("pointcut()")
public Object around(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
log.info("环绕通知 start");
//执行目标方法
Object result = proceedingJoinPoint.proceed();
//判断目标方法的返回值类型
if (result instanceof List) {
for (Object tmp : ((List) result)) {
//数据脱敏处理逻辑
this.deepProcess(tmp);
}
} else {
this.deepProcess(result);
}
log.info("环绕通知 end");
return result;
}
public void deepProcess(Object obj) throws IllegalAccessException {
if (obj != null) {
//取出输出对象的所有字段属性,并遍历
Field[] declaredFields = obj.getClass().getDeclaredFields();
for (Field declaredField : declaredFields) {
//判断字段属性上是否标记DecryptField注解
if (declaredField.isAnnotationPresent(DecryptField.class)) {
//如果判断结果为真,则取出字段属性数据进行解密处理
declaredField.setAccessible(true);
Object valObj = declaredField.get(obj);
if (valObj != null) {
String value = valObj.toString();
//加密数据的解密处理
value = EncryptAndDecryptUtils.decrypt(value);
DecryptField annotation = declaredField.getAnnotation(DecryptField.class);
boolean open = annotation.open();
//数据解密后,判断是否开启了数据脱敏处理;
if (open) {
//如果开启,则开始进行数据脱敏处理
int start = annotation.start();
int offset = annotation.offset();
value = EncryptAndDecryptUtils.secret(value, start, offset);
}
//把解密、脱敏后的数据重新赋值
declaredField.set(obj, value);
}
}
}
}
}
}
@Component
@Aspect
@Slf4j
public class EncryptAspect {
/**
* 定义加密切入点
*/
@Pointcut(value = "@annotation(com.huzhongiui.aop.annotation.NeedEncrypt)")
public void pointcut() {
}
/**
* 命中加密切入点的环绕通知
*
* @param proceedingJoinPoint
* @return
* @throws Throwable
*/
@Around("pointcut()")
public Object around(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
log.info("环绕通知 start");
//获取命中目标方法的入参数
Object[] args = proceedingJoinPoint.getArgs();
if (args.length > 0) {
for (Object arg : args) {
//按参数的类型进行判断,如果业务中还有其他的类型,可增加
if (arg != null) {
if (arg instanceof List) {
for (Object tmp : ((List) arg)) {
//加密处理
this.deepProcess(tmp);
}
} else {
this.deepProcess(arg);
}
}
}
}
//对敏感数据加密后执行目标方法
Object result = proceedingJoinPoint.proceed();
log.info("环绕通知 end");
return result;
}
public void deepProcess(Object obj) throws IllegalAccessException {
if (obj != null) {
//获取对象的所有字段属性并遍历
Field[] declaredFields = obj.getClass().getDeclaredFields();
for (Field declaredField : declaredFields) {
//判断字段属性上是否标记了@EncryptField注解
if (declaredField.isAnnotationPresent(EncryptField.class)) {
//如果判断结果为真,则取出字段属性值,进行加密、重新赋值
declaredField.setAccessible(true);
Object valObj = declaredField.get(obj);
if (valObj != null) {
String value = valObj.toString();
//开始敏感字段属性值加密
String decrypt = EncryptAndDecryptUtils.encrypt(value);
//把加密后的字段属性值重新赋值
declaredField.set(obj, decrypt);
}
}
}
}
}
}
@Slf4j
@Data
public class Person {
private Integer id;
private String userName;
private String loginNo;
private String sex;
private String address;
@EncryptField
@DecryptField(open = true,start = 3,offset = 4)
private String phoneNumber; // 手机号
@EncryptField
@DecryptField(open = true,start = 6,offset = 8)
private String card; // 身份证号
@EncryptField
@DecryptField(open = false,start = 0,offset = 3)
private String houseNumber;
}
public class EncryptAndDecryptUtils {
//“686868hzk” 是我自定义的密钥,这里仅作演示使用,实际业务中,这个密钥要以安全的方式存储;
private static String KeyRSA = "686868hzk";
public static String encrypt(String value) {
//这里特别注意一下,对称加密是根据密钥进行加密和解密的,加密和解密的密钥是相同的,一旦泄漏,就无秘密可言,
byte[] key = SecureUtil.generateKey(SymmetricAlgorithm.DES.getValue(), KeyRSA.getBytes()).getEncoded();
SymmetricCrypto aes = new SymmetricCrypto(SymmetricAlgorithm.DES, key);
String encryptValue = aes.encryptBase64(value);
return encryptValue;
}
public static String decrypt(String value) {
//这里特别注意一下,对称加密是根据密钥进行加密和解密的,加密和解密的密钥是相同的,一旦泄漏,就无秘密可言,
byte[] key = SecureUtil.generateKey(SymmetricAlgorithm.DES.getValue(), KeyRSA.getBytes()).getEncoded();
SymmetricCrypto aes = new SymmetricCrypto(SymmetricAlgorithm.DES, key);
String decryptStr = aes.decryptStr(value);
return decryptStr;
}
public static String secret(String value, Integer start, Integer limit) {
//如果有特殊需要,还可以定义其他用于代替敏感数据的字符,一般情况下,使用的是“*”
char[] chars = value.toCharArray();
for (int i = start; i < start + limit; i++) {
chars[i] = '*';
}
return String.valueOf(chars);
}
}
@Override
public Person registe(Person person) {
this.personMapper.insert(person);
String phone = EncryptAndDecryptUtils.decrypt(person.getPhoneNumber());
String phoneKeywords = this.phoneKeywords(phone);
this.personMapper.insertPhoneKeyworkds(person.getId(), phoneKeywords);
return person;
}
@GetMapping("/list")
@NeedDecrypt
public List getPerson(String phoneVal) {
List persons = this.personService.getPersonList(phoneVal);
log.info("//查询person列表执行完成");
return persons;
}
@Override
public List getPersonList(String phoneVal) {
if (phoneVal != null) {
return this.personMapper.queryByPhoneEncrypt(EncryptAndDecryptUtils.encrypt(phoneVal));
}
return this.personMapper.queryList(phoneVal);
}
安全性:原始敏感数据始终以加密形式存储
查询灵活性:支持前缀、中缀等多种模糊查询
性能可控:分词长度可调整以平衡查询精度和性能
兼容性:不依赖特定数据库特性,可跨平台使用
存储开销:分词密文映射表会显著增加存储空间
写入性能:数据插入和更新时需要额外处理分词加密
分词策略:需要根据业务特点调整分词长度以获得最佳效果
分词长度选择:通常4-6位分词长度适合手机号、身份证等场景
索引优化:为cipher_mapping表的cipher_text字段添加适当索引
缓存策略:对高频查询条件可考虑缓存查询结果
定期维护:清理不再使用的分词密文记录
本文提出的基于分词密文映射表的加密字段模糊查询方案,通过预处理加密分词和查询时反向匹配的方式,有效解决了加密数据的模糊查询难题。该方案在保证数据安全性的同时,提供了良好的查询灵活性,适合对安全性要求较高的应用场景。