聊聊如何自定義資料脫敏

linyb極客之路發表於2021-12-28

前言

什麼是資料脫敏

資料脫敏是指對某些敏感資訊通過脫敏規則進行資料的變形,實現敏感隱私資料的可靠保護

常用脫敏規則

替換、重排、加密、截斷、掩碼

良好的資料脫敏實施

1、儘可能地為脫敏後的應用,保留脫敏前的有意義資訊
2、最大程度地防止黑客進行破解

今天我們聊聊如何自定義資料脫敏

整體思路

本示例通過替換的手段實現脫敏,然後配合常用的框架特性,比如mybatis的攔截器機制或者json的序列化來快速實現脫敏

具體落地

1、定義一個脫敏工具類

可以直接引用hutool工具包,不過它在5.6+版本以上才提供了這個工具
https://www.hutool.cn/docs/#/core/工具類/資訊脫敏工具-DesensitizedUtil

不然就自己實現一個,形如下

public class DesensitizedUtils {



    /**
     * 脫敏,使用預設的脫敏策略
     * <pre>
     * DesensitizedUtil.desensitized("100", DesensitizedUtils.DesensitizedType.USER_ID)) =  "0"
     * DesensitizedUtil.desensitized("段正淳", DesensitizedUtils.DesensitizedType.CHINESE_NAME)) = "段**"
     * DesensitizedUtil.desensitized("51343620000320711X", DesensitizedUtils.DesensitizedType.ID_CARD)) = "5***************1X"
     * DesensitizedUtil.desensitized("09157518479", DesensitizedUtils.DesensitizedType.FIXED_PHONE)) = "0915*****79"
     * DesensitizedUtil.desensitized("18049531999", DesensitizedUtils.DesensitizedType.MOBILE_PHONE)) = "180****1999"
     * DesensitizedUtil.desensitized("北京市海淀區馬連窪街道289號", DesensitizedUtils.DesensitizedType.ADDRESS)) = "北京市海淀區馬********"
     * DesensitizedUtil.desensitized("duandazhi-jack@gmail.com.cn", DesensitizedUtils.DesensitizedType.EMAIL)) = "d*************@gmail.com.cn"
     * DesensitizedUtil.desensitized("1234567890", DesensitizedUtils.DesensitizedType.PASSWORD)) = "**********"
     * DesensitizedUtil.desensitized("蘇D40000", DesensitizedUtils.DesensitizedType.CAR_LICENSE)) = "蘇D4***0"
     * DesensitizedUtil.desensitized("11011111222233333256", DesensitizedUtils.DesensitizedType.BANK_CARD)) = "1101 **** **** **** 3256"
     * </pre>
     *
     * @param str              字串
     * @param desensitizedType 脫敏型別;可以脫敏:使用者id、中文名、身份證號、座機號、手機號、地址、電子郵件、密碼
     * @return 脫敏之後的字串
     * @author dazer and neusoft and qiaomu
     * @since 5.6.2
     */
    public static String desensitized(CharSequence str, DesensitizedType desensitizedType) {
        if (StrUtil.isBlank(str)) {
            return StrUtil.EMPTY;
        }
        String newStr = String.valueOf(str);
        switch (desensitizedType) {
            case USER_ID:
                newStr = String.valueOf(DesensitizedUtils.userId());
                break;
            case CHINESE_NAME:
                newStr = DesensitizedUtils.chineseName(String.valueOf(str));
                break;
            case ID_CARD:
                newStr = DesensitizedUtils.idCardNum(String.valueOf(str), 1, 2);
                break;
            case FIXED_PHONE:
                newStr = DesensitizedUtils.fixedPhone(String.valueOf(str));
                break;
            case MOBILE_PHONE:
                newStr = DesensitizedUtils.mobilePhone(String.valueOf(str));
                break;
            case ADDRESS:
                newStr = DesensitizedUtils.address(String.valueOf(str), 8);
                break;
            case EMAIL:
                newStr = DesensitizedUtils.email(String.valueOf(str));
                break;
            case PASSWORD:
                newStr = DesensitizedUtils.password(String.valueOf(str));
                break;
            case CAR_LICENSE:
                newStr = DesensitizedUtils.carLicense(String.valueOf(str));
                break;
            case BANK_CARD:
                newStr = DesensitizedUtils.bankCard(String.valueOf(str));
                break;
            default:
        }
        return newStr;
    }

    /**
     * 【使用者id】不對外提供userId
     *
     * @return 脫敏後的主鍵
     */
    public static Long userId() {
        return 0L;
    }

    /**
     * 【中文姓名】只顯示第一個漢字,其他隱藏為2個星號,比如:李**
     *
     * @param fullName 姓名
     * @return 脫敏後的姓名
     */
    public static String chineseName(String fullName) {
        if (StrUtil.isBlank(fullName)) {
            return StrUtil.EMPTY;
        }
        return StrUtil.hide(fullName, 1, fullName.length());
    }

    /**
     * 【身份證號】前1位 和後2位
     *
     * @param idCardNum 身份證
     * @param front     保留:前面的front位數;從1開始
     * @param end       保留:後面的end位數;從1開始
     * @return 脫敏後的身份證
     */
    public static String idCardNum(String idCardNum, int front, int end) {
        //身份證不能為空
        if (StrUtil.isBlank(idCardNum)) {
            return StrUtil.EMPTY;
        }
        //需要擷取的長度不能大於身份證號長度
        if ((front + end) > idCardNum.length()) {
            return StrUtil.EMPTY;
        }
        //需要擷取的不能小於0
        if (front < 0 || end < 0) {
            return StrUtil.EMPTY;
        }
        return StrUtil.hide(idCardNum, front, idCardNum.length() - end);
    }

    /**
     * 【固定電話 前四位,後兩位
     *
     * @param num 固定電話
     * @return 脫敏後的固定電話;
     */
    public static String fixedPhone(String num) {
        if (StrUtil.isBlank(num)) {
            return StrUtil.EMPTY;
        }
        return StrUtil.hide(num, 4, num.length() - 2);
    }

    /**
     * 【手機號碼】前三位,後4位,其他隱藏,比如135****2210
     *
     * @param num 行動電話;
     * @return 脫敏後的行動電話;
     */
    public static String mobilePhone(String num) {
        if (StrUtil.isBlank(num)) {
            return StrUtil.EMPTY;
        }
        return StrUtil.hide(num, 3, num.length() - 4);
    }

    /**
     * 【地址】只顯示到地區,不顯示詳細地址,比如:北京市海淀區****
     *
     * @param address       家庭住址
     * @param sensitiveSize 敏感資訊長度
     * @return 脫敏後的家庭地址
     */
    public static String address(String address, int sensitiveSize) {
        if (StrUtil.isBlank(address)) {
            return StrUtil.EMPTY;
        }
        int length = address.length();
        return StrUtil.hide(address, length - sensitiveSize, length);
    }

    /**
     * 【電子郵箱】郵箱字首僅顯示第一個字母,字首其他隱藏,用星號代替,@及後面的地址顯示,比如:d**@126.com
     *
     * @param email 郵箱
     * @return 脫敏後的郵箱
     */
    public static String email(String email) {
        if (StrUtil.isBlank(email)) {
            return StrUtil.EMPTY;
        }
        int index = StrUtil.indexOf(email, '@');
        if (index <= 1) {
            return email;
        }
        return StrUtil.hide(email, 1, index);
    }

    /**
     * 【密碼】密碼的全部字元都用*代替,比如:******
     *
     * @param password 密碼
     * @return 脫敏後的密碼
     */
    public static String password(String password) {
        if (StrUtil.isBlank(password)) {
            return StrUtil.EMPTY;
        }
        return StrUtil.repeat('*', password.length());
    }

    /**
     * 【中國車牌】車牌中間用*代替
     * eg1:null       -》 ""
     * eg1:""         -》 ""
     * eg3:蘇D40000   -》 蘇D4***0
     * eg4:陝A12345D  -》 陝A1****D
     * eg5:京A123     -》 京A123     如果是錯誤的車牌,不處理
     *
     * @param carLicense 完整的車牌號
     * @return 脫敏後的車牌
     */
    public static String carLicense(String carLicense) {
        if (StrUtil.isBlank(carLicense)) {
            return StrUtil.EMPTY;
        }
        // 普通車牌
        if (carLicense.length() == 7) {
            carLicense = StrUtil.hide(carLicense, 3, 6);
        } else if (carLicense.length() == 8) {
            // 新能源車牌
            carLicense = StrUtil.hide(carLicense, 3, 7);
        }
        return carLicense;
    }

    /**
     * 銀行卡號脫敏
     * eg: 1101 **** **** **** 3256
     *
     * @param bankCardNo 銀行卡號
     * @return 脫敏之後的銀行卡號
     * @since 5.6.3
     */
    public static String bankCard(String bankCardNo) {
        if (StrUtil.isBlank(bankCardNo)) {
            return bankCardNo;
        }
        bankCardNo = StrUtil.trim(bankCardNo);
        if (bankCardNo.length() < 9) {
            return bankCardNo;
        }

        final int length = bankCardNo.length();
        final int midLength = length - 8;
        final StringBuilder buf = new StringBuilder();

        buf.append(bankCardNo, 0, 4);
        for (int i = 0; i < midLength; ++i) {
            if (i % 4 == 0) {
                buf.append(CharUtil.SPACE);
            }
            buf.append('*');
        }
        buf.append(CharUtil.SPACE).append(bankCardNo, length - 4, length);
        return buf.toString();
    }
}

其實正常到這個步驟,通過替換實現脫敏就可以完成,可以直接在程式中,直接呼叫這個工具就行。但是作為一個懂得偷懶的程式設計師,肯定不滿足這樣。於是我們會進一步封裝

2、自定義脫敏註解
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Sensitive {

    DesensitizedType strategy() default DesensitizedType.NONE;

    /**
     * 是否使用dfa演算法
     * @return
     */
    boolean useDFA() default false;

    /**
     * dfa敏感字元替換,預設替換成 "*"
     * @return
     */
    String dfaReplaceChar() default "*";


    /**
     * dfa敏感字元替換次數
     * @return
     */
    int dfaReplaceCharRepeatCount() default 1;

}
3、利用一些框架特性提升效率

a、如果專案已經有用mybatis,則可以利用mybatis攔截器特性。實現原理就是攔截響應回來的結果,然後對結果進行脫敏處理

@Intercepts(@Signature(type = ResultSetHandler.class,method = "handleResultSets",args = Statement.class))
public class DesensitizedInterceptor implements Interceptor {
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        List<Object> list = (List<Object>) invocation.proceed();
        list.forEach(EntityUtils::desensitized);

        return list;
    }

}

b、 如果專案是基於springboot的web專案,則可以利用springboot自帶的jackson自定義序列化實現。它的實現原來其實就是在json進行序列化渲染給前端時,進行脫敏。

如果是這種方案,則需對自定義註解進行改造一下,加上

@JacksonAnnotationsInside
@JsonSerialize(using = DesensitizedJsonSerializer.class)

註解。形如下

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@JacksonAnnotationsInside
@JsonSerialize(using = DesensitizedJsonSerializer.class)
public @interface Sensitive {

    DesensitizedType strategy() default DesensitizedType.NONE;

    /**
     * 是否使用dfa演算法
     * @return
     */
    boolean useDFA() default false;

    /**
     * dfa敏感字元替換,預設替換成 "*"
     * @return
     */
    String dfaReplaceChar() default "*";


    /**
     * dfa敏感字元替換次數
     * @return
     */
    int dfaReplaceCharRepeatCount() default 1;

}

序列化脫敏邏輯核心程式碼如下

public class DesensitizedJsonSerializer extends JsonSerializer<String> implements ContextualSerializer {

    private Sensitive sensitive;
    @Override
    public void serialize(String s, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
        jsonGenerator.writeString(EntityUtils.getDesensitizedValue(sensitive,s));

    }

    @Override
    public JsonSerializer<?> createContextual(SerializerProvider serializerProvider, BeanProperty beanProperty) throws JsonMappingException {

        sensitive = beanProperty.getAnnotation(Sensitive.class);

        if(!ObjectUtils.isEmpty(sensitive) && String.class.isAssignableFrom(beanProperty.getType().getRawClass())){
            return this;
        }
        return serializerProvider.findValueSerializer(beanProperty.getType(),beanProperty);
    }
}

示例

以json那種方式為例

1、定義實體物件,需要進行脫敏的屬性上加上脫敏註解
@Data
@EqualsAndHashCode(callSuper = false)
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class UserDTO {

    private Integer id;

    private String username;

    @Sensitive(strategy = DesensitizedType.PASSWORD)
    private String password;

    @Sensitive(strategy = DesensitizedType.CHINESE_NAME)
    private String fullname;

    @Sensitive(strategy = DesensitizedType.MOBILE_PHONE)
    private String mobile;

    @Sensitive(strategy = DesensitizedType.EMAIL)
    private String email;

    @Sensitive(useDFA = true,dfaReplaceChar = "#",dfaReplaceCharRepeatCount = 3)
    private String remark;
}
2、編寫一個測試controller
@RestController
@RequestMapping("/user")
public class UserController {

    @Autowired
    private UserService userService;


    @GetMapping(value="/list")
    public AjaxResult listUsers(){
       return AjaxResult.success(userService.listUserDTO());
    }

}
測試結果


如圖所示已經進行脫敏

其他方案

1、基於Sharding Sphere實現資料脫敏

具體實現可以參考如下文章

https://jaskey.github.io/blog/2020/03/18/sharding-sphere-data-desensitization/

2、自定義註解格式化

主要實現步驟如下

  • 1、實現AnnotationFormatterFactory介面
  • 2、建立脫敏格式化類實現Formatter
  • 3、將AnnotationFormatterFactory實現的介面註冊到FormatterRegistry

具體實現可以參考如下文章

https://blog.csdn.net/qq_27081015/article/details/103295983

4、利用fastjson進行脫敏

主要實現步驟如下

  • 1、實現ValueFilter介面,在process進行脫敏
  • 2、配置fastjson為預設JSON轉換
/**
     * 配置fastjson為預設JSON轉換
     *
     * @return
     */
    @Bean
    public HttpMessageConverters fastJsonHttpMessageConverters() {
        // 1.定義一個converters轉換訊息的物件
        FastJsonHttpMessageConverter fastConverter = new FastJsonHttpMessageConverter();
        // 2.新增fastjson的配置資訊,比如: 是否需要格式化返回的json資料
        FastJsonConfig fastJsonConfig = new FastJsonConfig();
        fastJsonConfig.setSerializerFeatures(SerializerFeature.PrettyFormat);
        fastJsonConfig.setSerializeFilters(new ValueDesensitizeFilter());//新增自己寫的攔截器
        // 3.在converter中新增配置資訊
        fastConverter.setFastJsonConfig(fastJsonConfig);
        // 4.將converter賦值給HttpMessageConverter
        HttpMessageConverter<?> converter = fastConverter;
        // 5.返回HttpMessageConverters物件
        return new HttpMessageConverters(converter);
    }

具體實現可以參考如下文章

https://blog.csdn.net/qq_27081015/article/details/103297316

5、利用mybatis-mate

mybatis-plus 企業(資料優雅處理)模組,使用時要配置一下授權碼。如下

mybatis-mate:
  cert:
    grant: jinTianYiXueKe
    license: GKXP9r4MCJhGID/DTGigcBcLmZjb1YZGjE4GXaAoxbtGsPC20sxpEtiUr2F7Nb1ANTUekvF6Syo6DzraA4M4oacwoLVTglzfvaEyUogW8L7mydqlsZ4+hlm20kK85eLJK1QsskrSJmreMnEaNh9lsV7Lpbxy9JeGCeM0HPEbRvq8Y+8dUt5bQYLklsa3ZIBexir+4XykZY15uqn1pYIp4pEK0+aINTa57xjJNoWuBIqm7BdFIb4l1TAcPYMTsMXhF5hfMmKD2h391HxWTshJ6jbt4YqdKD167AgeoM+B+DE1jxlLjcpskY+kFs9piOS7RCcmKBBUOgX2BD/JxhR2gQ==

他的實現機理就是利用json序列化那種,如果感興趣可以參考如下連結

https://gitee.com/baomidou/mybatis-mate-examples

本文的demo也有基於mybatis-mate實現脫敏,連結如下
https://github.com/lyb-geek/springboot-learning/tree/master/springboot-desensitization/springboot-desensitzation-mybatis-mate

總結

有時候業務場景的實現方式有多種多樣,大家要懂得取捨判斷,比如上面的方案如果你的專案本來就沒用mybatis,但為了脫敏又引入mybatis,這種方案就額外有加入了複雜度,後面維護估計就有得折騰了

demo連結

https://github.com/lyb-geek/springboot-learning/tree/master/springboot-desensitization

相關文章