对加密字段进行模糊查询:基于分词密文映射表的实现方案

引言

在当今数据安全日益重要的背景下,数据库字段加密已成为保护敏感信息的常见做法。然而,加密后的数据给模糊查询带来了巨大挑战。本文将介绍一种基于分词密文映射表的解决方案,实现对加密字段的高效模糊查询。

一、问题背景

考虑一个用户管理系统,其中包含手机号、身份证号、住址等敏感信息。这些字段需要加密存储以保证安全,但同时业务上又需要支持模糊查询(如根据手机号前几位查询用户)。

传统加密方式直接阻碍了模糊查询功能,因为:

  1. 加密后的数据与原始数据无相似性

  2. 相同明文加密后可能产生不同密文(取决于加密算法)

  3. 无法使用数据库的LIKE等模糊查询操作符

二、解决方案设计

1. 整体思路

  1. 建立分词密文映射表:在敏感字段数据新增、修改时,对字段值进行分词组合

  2. 加密分词:对每个分词进行加密,建立分词密文与目标数据行主键的关联

  3. 查询处理:对查询关键字加密后,在映射表中进行LIKE查询获取主键

  4. 精确查询:用获取的主键回原表查询完整数据

2. 具体实现步骤

2.1 数据模型设计

用户表(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 = '人员的手机号码分词密文映射表';
2.2 注解定义
// 加密字段注解
@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 {
}
2.3 切面类
@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);
                    }
                }
            }
        }
    }


}
2.4 用户实体类
@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;
}
2.5 加密分词处理
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);
    }
}
2.6 数据插入/更新处理
 @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;
    }


2.7 模糊查询实现
  @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);
    }

    
    

最终返回结果

对加密字段进行模糊查询:基于分词密文映射表的实现方案_第1张图片

三、方案优势与局限性

优势

  1. 安全性:原始敏感数据始终以加密形式存储

  2. 查询灵活性:支持前缀、中缀等多种模糊查询

  3. 性能可控:分词长度可调整以平衡查询精度和性能

  4. 兼容性:不依赖特定数据库特性,可跨平台使用

局限性

  1. 存储开销:分词密文映射表会显著增加存储空间

  2. 写入性能:数据插入和更新时需要额外处理分词加密

  3. 分词策略:需要根据业务特点调整分词长度以获得最佳效果

四、实际应用建议

  1. 分词长度选择:通常4-6位分词长度适合手机号、身份证等场景

  2. 索引优化:为cipher_mapping表的cipher_text字段添加适当索引

  3. 缓存策略:对高频查询条件可考虑缓存查询结果

  4. 定期维护:清理不再使用的分词密文记录

五、总结

本文提出的基于分词密文映射表的加密字段模糊查询方案,通过预处理加密分词和查询时反向匹配的方式,有效解决了加密数据的模糊查询难题。该方案在保证数据安全性的同时,提供了良好的查询灵活性,适合对安全性要求较高的应用场景。

你可能感兴趣的:(数据库)