系统全局脱敏处理

870 阅读5分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

业务背景

大数据时代的到来,颠覆了传统业态的运作模式,激发出新的生产潜能。数据成为重要的生产要素,是信息的载体,数据间的流动也潜藏着更高阶维度的价值信息。数据脱敏(Data Masking),顾名思义,是屏蔽敏感数据,对某些敏感信息(比如,身份证号、手机号、卡号、客户姓名、客户地址、邮箱地址、薪资等等 )通过脱敏规则进行数据的变形,实现隐私数据的可靠保护。

方案分析

由于业务要求需要把部分功能所展示的数据进行脱敏处理,对于这一需求,讨论了以下几种方案:

  1. 在数据库层做数据迁移的时候,就将对应的值进行脱敏处理,系统根据其他条件直接查询就可以;但是这样做会出现数据同步不及时,或者报错,两个库的数据不一致,不可取;
  2. 在Dao层对需要脱敏的字段进行sql拼接处理,返回页面;这样做效率太低且sql不好写,业务功能量少的情况下可以使用;
  3. 系统使用注解以及工具类,进行全局脱敏;使用起来方便且代码会很规范,可取;

实现思路

  1. 自定义注解,且仅能作用在字段中,在方法返回使用到的DTO中,根据业务需要,分别在对应字段上加入此注解,以及数据脱敏类型(此处用到了枚举);
import com.wd.basic.common.desensitization.enums.SensitiveTypeEnum;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * 脱敏注解
 *
 * @author 上官婉儿
 * @date 2022/03/24
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)//字段、枚举的常量
public @interface Desensitize {

    /**
     * 脱敏字段类型
     *
     * @return {@link SensitiveTypeEnum}
     */
    SensitiveTypeEnum type() default SensitiveTypeEnum.REGULAR_EXPRESSION;

    /**
     * 正则表达式
     * @return
     */
     String pattern() default "";
}
package com.wd.basic.common.desensitization.enums;

import com.wd.basic.common.pojo.base.enums.IEnum;
import lombok.Getter;

/**
 * 脱敏数据类型
 */
public enum SensitiveTypeEnum implements IEnum {

    REGULAR_EXPRESSION("REGULAR_EXPRESSION", ""),
    CHINESE_NAME("CHINESE_NAME", "中文名"),
    PHONE("PHONE", "手机号"),
    ADRESS("ADRESS", "地址"),
    CERNO("CERNO", "身份证号"),
    EMAIL("EMAIL", "邮箱"),
    LANDLINE("LANDLINE", "座机");
    /**
     * 代码
     */
    @Getter
    private String code;

    @Getter
    private String desc;


    SensitiveTypeEnum(String code, String desc) {
        this.code = code;
        this.desc = desc;
    }
}

2.自定义工具类,根据传入的对象,一层层解析得到每个需要进行脱敏的字段,再根据注解中传入的数据脱敏类型,调用不同的脱敏规则方法,最后将对象返回,降低代码耦合度;

import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.toolkit.CollectionUtils;
import com.google.common.collect.Lists;
import com.wd.basic.common.desensitization.comment.Desensitize;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;

import java.lang.reflect.Array;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

/**
 * 敏感数据脱敏处理工具类
 *
 * @author 上官婉儿
 * @date 2021/1/26
 */
@Slf4j
public class DesensitizedUtils {

    /**
     * 递归深度计数器:防止循环依赖
     */
    private static ThreadLocal<AtomicInteger> threadLocalCounter = new ThreadLocal<>();

    /**
     * 递归的最大深度
     */
    private static final Integer MAX_DEPTH = 3;

    /**
     * 敏感数据脱敏
     * 脱敏对象 的字段 需要添加 {@Sensitive} 注解并标注敏感类型
     *
     * @param bean
     * @return
     */
    public static Object desensitization(Object bean) {
        try {
            // 控制递归的深度:防止循环依赖
            threadLocalCounter.set(new AtomicInteger(MAX_DEPTH));
            doDesensitization(bean);
        } catch (Exception e) {
            log.warn("数据脱敏工具异常", e);
        } finally {
            threadLocalCounter.remove();
        }
        return bean;
    }

    /**
     * 遍历递归脱敏操作
     *
     * @param bean
     * @throws Exception
     */
    private static void doDesensitization(Object bean) throws Exception {
        if (Objects.isNull(bean) || isBeyoudMaxDepth(threadLocalCounter.get())) {
            return;
        }
        //处理子属性,包括集合中的
        Object object = null;
        if (bean.getClass().isArray()) {
            //对数组类型的字段进行递归过滤
            for (int i = 0; i < Array.getLength(bean); i++) {
                object = Array.get(bean, i);
                if (isNotGeneralType(object.getClass())) {
                    desensitizationField(object);
                }
            }
        } else if (bean instanceof Collection<?>) {
            //对集合类型的字段进行递归过滤
            Iterator<?> it = ((Collection<?>) bean).iterator();
            while (it.hasNext()) {
                object = it.next();
                if (isNotGeneralType(object.getClass())) {
                    desensitizationField(object);
                }
            }
        } else if (bean instanceof Map<?, ?>) {
            //对Map类型的字段进行递归过滤
            Map<?, ?> map = (Map<?, ?>) bean;
            for (Object o : map.entrySet()) {
                object = ((Map.Entry<?, ?>) o).getValue();
                if (isNotGeneralType(object.getClass())) {
                    desensitizationField(object);
                }
            }
        } else {//自定义类等
            if (isNotGeneralType(bean.getClass())) {
                desensitizationField(bean);
            }
        }
    }

    /**
     * 脱敏bean所有属性
     *
     * @param bean
     * @throws Exception
     */
    private static void desensitizationField(Object bean) throws Exception {
        // 获取对象的所有属性
        List<Field> fields = getAllFields(bean);
        if (CollectionUtils.isEmpty(fields)) {
            return;
        }
        Object value;
        for (Field field : fields) {
            field.setAccessible(true);
            value = field.get(bean);
            // 属性是final类型  不处理
            if (Objects.isNull(value)) {
                continue;
            }
            //不是基本数据类型
            if (isNotGeneralType(value.getClass())) {
                doDesensitization(value);
            } else {
                //脱敏操作
                setNewValueForField(bean, field, value);
            }
        }
    }

    /**
     * 获取包括父类所有 使用了{@Desensitize}注解的属性
     *
     * @param objSource
     * @return
     */
    private static List<Field> getAllFields(Object objSource) {
        if (Objects.isNull(objSource)) {
            return null;
        }
        AtomicInteger atomicInteger = new AtomicInteger(MAX_DEPTH);
        // 获得当前类的所有属性(private、protected、public)
        List<Field> fieldList = Lists.newArrayList();
        Class tempClass = objSource.getClass();
        while (Objects.nonNull(tempClass) && !tempClass.getName().toLowerCase().equals("java.lang.object") && !isBeyoudMaxDepth(atomicInteger)) {
            fieldList.addAll(Arrays.asList(tempClass.getDeclaredFields()));
            //得到父类,然后赋给自己
            tempClass = tempClass.getSuperclass();
        }
        // 过滤出来需要
        return fieldList.stream().filter(field -> !Modifier.isFinal(field.getModifiers()) && Objects.nonNull(field.getAnnotation(Desensitize.class))).collect(Collectors.toList());
    }

    /**
     * 排除基础类型、枚举类型、扩展库类型的字段
     *
     * @param clazz
     * @return
     */
    private static boolean isNotGeneralType(Class<?> clazz) {
        return !clazz.isPrimitive()
                && !clazz.isEnum()
                && Objects.nonNull(clazz.getPackage())
                && clazz.getPackage().getName().indexOf("javax.") == -1
                && clazz.getPackage().getName().indexOf("java.lang") == -1
                && clazz.getPackage().getName().indexOf("java.time") == -1;
    }

    /**
     * 脱敏操作(按照规则转化需要脱敏的字段并设置新值)
     * 目前只支持String类型的字段
     *
     * @param bean
     * @param field
     * @param value
     * @throws IllegalAccessException
     */
    public static void setNewValueForField(Object bean, Field field, Object value) {
        try {
            //处理自身的属性
            Desensitize annotation = field.getAnnotation(Desensitize.class);
            String valueStr;
            if (!field.getType().equals(String.class) || Objects.isNull(value)
                    || ObjectUtils.isEmpty(valueStr = value.toString()) || Objects.isNull(annotation)) {
                return;
            }

            switch (annotation.type()) {
                case CHINESE_NAME: {
                    field.set(bean, chineseName(valueStr));
                    break;
                }
                case PHONE: {
                    field.set(bean, phone(valueStr));
                    break;
                }
                case ADRESS: {
                    field.set(bean, adress(valueStr));
                    break;
                }
                case CERNO: {
                    field.set(bean, cerno(valueStr));
                    break;
                }
                case EMAIL: {
                    field.set(bean, email(valueStr));
                    break;
                }
                case LANDLINE: {
                    field.set(bean, landline(valueStr));
                }
                case REGULAR_EXPRESSION: {
                    field.set(bean, regReplace(valueStr, annotation.pattern()));
                    break;
                }
                default:
                    field.set(bean, defaultDesensitized(valueStr));
                    break;
            }
        } catch (Exception e) {
//            log.warn("数据脱敏工具异常,bean:{},field:{},value:{}", GsonUtils.obj2Json(bean), field.getName(), value, e);
        }
    }

    private static Boolean isBeyoudMaxDepth(AtomicInteger atomicInteger) {
        if (Objects.isNull(atomicInteger)) {
            return Boolean.FALSE;
        }
        final int increment = atomicInteger.getAndDecrement();
        if (increment <= 0) {
            return Boolean.TRUE;
        }
        return Boolean.FALSE;
    }

    public static String defaultDesensitized(String value) {
        if (Objects.isNull(value)) {
            return null;
        }
        return value.replaceAll(value, StrUtil.fillBefore("*", '*', value.length()));
    }

    /**
     * 【姓名】真实姓名脱敏
     * 中文姓名不显示中间的文字,其他隐藏为星号
     * 张三丰 :张*丰
     *
     * @param value
     * @return
     */
    public static String chineseName(String value) {
        if (Objects.isNull(value)) {
            return null;
        }
        if (value.length() == 1) {
            return value;
        } else if (value.length() == 2) {
            return value.substring(0) + "*";
        } else {
            return value.replaceAll(value.substring(1, value.length() - 1),
                    StrUtil.fillBefore("*", '*', value.length() - 2));
        }
    }

    /**
     * 手机号脱敏规则
     *
     * @param value
     * @return
     */
    public static String phone(String value) {
        if (Objects.isNull(value)) {
            return null;
        }
        return value.replaceAll("(\w{3})\w*(\w{4})", "$1****$2");
    }

    /**
     * 身份证号脱敏规则 :  保留前六后三 适用于15位和18位身份证号
     *
     * @param value
     * @return
     */
    public static String cerno(String value) {
        if (!Objects.isNull(value)) {
            if (value.length() == 15) {
                value = value.replaceAll("(\w{6})\w*(\w{3})", "$1******$2");
            }
            if (value.length() == 18) {
                value = value.replaceAll("(\w{6})\w*(\w{3})", "$1*********$2");
            }
        }
        return value;
    }

    /**
     * 座机:
     * 没有 "-" 前3位 + 后2位
     * 有 “-” 后前3位 + 后2位
     *
     * @param value
     * @return
     */
    public static String landline(String value) {
        if (Objects.isNull(value)) {
            return null;
        }
        int s = value.indexOf("-");
        if (s == -1) {
            return value.replaceAll(value.substring(2, value.length() - 2), StrUtil.fillBefore("*", '*', value.length() - 5));
        }
        return value.replaceAll(value.substring(s + 3, value.length() - 2), StrUtil.fillBefore("*", '*', value.length() - s - 5));
    }

    /**
     * 邮箱规则
     *
     * @param value
     * @return
     * @前数字 前两位+*。。。*+后两位
     */
    public static String email(String value) {
        if (Objects.isNull(value)) {
            return null;
        }
        String regExp = "^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$";
        Pattern p = Pattern.compile(regExp);
        Matcher matcher = p.matcher(value);
        boolean isMatched = matcher.matches();
        if (isMatched) {
            int s = value.indexOf("@");
            return value.replaceAll(value.substring(2, s - 2), StrUtil.fillBefore("*", '*', s - 4));
        } else {
            return value.replaceAll(value, StrUtil.fillBefore("*", '*', value.length()));
        }
    }

    /**
     * 地址脱敏规则:
     * 3位以下全是 *
     * 3-7 前三位+ *。。。*
     * 其余 前三位+ *。。。* + 后三位
     *
     * @param value
     * @return
     */
    public static String adress(String value) {
        if (Objects.isNull(value)) {
            return null;
        }
        if (value.length() <= 3) {
            return "***";
        } else if (value.length() > 3 && value.length() <= 7) {
            return value.substring(0, 2) + StrUtil.fillBefore("*", '*', value.length() - 3);
        } else {
            return value.replaceAll(value.substring(3, value.length() - 3), StrUtil.fillBefore("*", '*', value.length() - 6));
        }
    }

    /**
     * 正则表达式字符串替换
     *
     * @param content 字符串
     * @param pattern 正则表达式
     * @return 返回替换后的字符串
     */
    public static String regReplace(String content, String pattern) {
        if (Objects.isNull(pattern)) {
            return content;
        }
        Pattern p = Pattern.compile(pattern);
        Matcher m = p.matcher(content);
        String result = m.replaceAll("*");
        return result;
    }
}
  1. 实现根据枚举类中的脱敏数据类型,使用不同的数据脱敏处理方法;若在枚举中新增数据脱敏类型,需要在DesensitizedUtils.setNewValueForField()中的 switch()循环中新增case值,以及在此处调用的该数据脱敏类型的对应处理规则方法;
  2. 新增切面实现系统全局脱敏处理,且控制到service业务逻辑层;在系统内配置文件中新增 参数: desensitized.isOpen:true;可以灵活控制是否开启此功能;
@Component
public class DesensitizedConfig {

    @Value(value = "${desensitized.isOpen:true}")
    private Boolean isOpen;

    /**
     * 切入点
     */
    @Pointcut("execution(* com..*.service..*.*(..))")
    public void pointcut() {

    }
    /**
     * @param joinPoint 连接点
     * @return {@link Object}
     * @throws Throwable throwable
     */
    @Around("pointcut()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        Object obj = joinPoint.proceed();//执行方法,获取返回值
        if(isOpen){
             DesensitizedUtils.desensitization(obj);//脱敏处理
        }
        return obj;
    }
}

使用规则

  1. 在系统内配置文件中设置参数: desensitized.isOpen:true;
  2. 加入切面控制类DesensitizedConfig;@Pointcut("execution(在service实现层)");
  3. 在方法返回使用到的DTO中,根据业务需要,分别在对应字段上加入注解@Desensitize(), 属性type:数据脱敏类型SensitiveTypeEnum();属性pattern:正则表达式pattern(),默认为空; 1648193163(1).jpg 4.现系统提供姓名、身份证号、手机号、地址、邮箱、座机的字段脱敏处理;若在枚举中新增数据脱敏类型,需要在DesensitizedUtils.setNewValueForField()中的 switch()循环中新增case值,以及在此处调用的该数据脱敏类型的对应处理规则方法; 1648193194(1).jpg
  4. 当type=SensitiveTypeEnum.REGULAR_EXPRESSION时,注解中需传入pattern = “正则表达式“,规则为:Pattern.compile(pattern).matcher(value)..replaceAll("*");

测试

根据使用规则,将demo实体类中 demo_code和demo_name两个字段加上了该自定义注解,但不同的类型,调用接口测试结果如下:

1648193256(1).jpg

OVER