基于logback+fastjson实现日志脱敏

发布于:2025-02-12 ⋅ 阅读:(12) ⋅ 点赞:(0)

一、需求背景

        日常工作中,必不可免的会将一些敏感信息,如用户名、密码、手机号、身份证号、银行账号等等打印出来,但往往为了安全,这些信息都需要进行脱敏。脱敏实际就是用一些特殊字符来替换部分值。

JSON 和 JSONObject

Fastjson 是阿里巴巴开源的一个高性能 JSON 库,其中 JSON 和 JSONObject 是两个常用但功能有所不同的类。JSON 类主要用于序列化和反序列化操作,而 JSONObject 则是用于直接操作 JSON 数据结构

二、基础数据

maven导入依赖

<dependencies>
    <dependency>
        <groupId>ch.qos.logback</groupId>
        <artifactId>logback-classic</artifactId>
        <version>1.2.12</version>
    </dependency>
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>fastjson</artifactId>
        <version>1.2.83</version>
    </dependency>
</dependencies>

  UserVO类

public class UserVO {
    //用户名
    private String username;
    //密码
    private String password;
    //身份证号
    private String certNo;
    //地址
    private String address;
    //工作年限
    private Integer workYear;
    //电话号码
    private String phone;

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public String getCertNo() {
        return certNo;
    }

    public void setCertNo(String certNo) {
        this.certNo = certNo;
    }

    public String getAddress() {
        return address;
    }

    public void setAddress(String address) {
        this.address = address;
    }

    public Integer getWorkYear() {
        return workYear;
    }

    public void setWorkYear(Integer workYear) {
        this.workYear = workYear;
    }

    public String getPhone() {
        return phone;
    }

    public void setPhone(String phone) {
        this.phone = phone;
    }

}

 logback.xml配置文件,之前一篇文章(Logback的使用-CSDN博客)分享过配置文件的各标签含义,这里就不做重复介绍,重点是将日志脱敏。

<configuration scan="true" scanPeriod="60 seconds" debug="false">

    <!--    property指定日志输出格式,他有两个属性:
                    name定义property节点的名称,value属性设置具体的日志输出格式
            property节点定义之后,下面的节点可以直接使用“${}”来引用value中定义的日志输出格式
     -->
    <!--
        日志输出格式:
        %-5level                      %level表示日志级别,-5表示占5个字符,如果不足,就向左对齐
        %d{yyyy-mm-dd H:mm:ss.sss}    %d表示日期,后面是日期的格式
        %c                            表示  类的完整名称
        %M                            表示  method
        %L                            表示  行号
        %thread                       表示  线程名称
        %m或者%msg                     表示  信息
        %X{key}                  %X表示输出MDC中特定键的值,key为具体的键名称,值不存在,则不会输出
        %logger{36}                   表示 使用哪个日志记录器,就会打印那个日志记录器的name,最多显示36个字符
        %n                            表示   换行
        被[]中括号括起来,只是为了方便区分,也可以将中括号去掉,不会有影响
    -->
    <!--%X{key}   %X表示输出MDC中特定键的值,key为具体的键名称,值不存在,则不会输出-->
    <property name="NEW_LOG_STYLE"
              value="[%-5level] [%thread] [%logger] [%d{yyyy-mm-dd H:mm:ss.sss}] [%c] [%M] [%L] %m%n"/>

    <conversionRule conversionWord="m" converterClass="cn.tedu.TuoMinConverter"/>
    <conversionRule conversionWord="msg" converterClass="cn.tedu.TuoMinConverter"/>

    <!-- 控制台输出设置 -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <!--encoder指定日志格式,class属性可以不写,默认会将值映射到PatternLayoutEncoder的变量中-->
        <!-- <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder"> -->
        <encoder>
            <!--使用上文定义的,全局的property配置-->
            <pattern>${NEW_LOG_STYLE}</pattern>
            <charset>UTF-8</charset>
        </encoder>
    </appender>

    <root level="info">
        <appender-ref ref="CONSOLE"/>
    </root>
    
</configuration>

三、基于logback的MessageConverter和JSONObject,进行全局脱敏

定义一个工具类JSONUtils,用于脱敏

import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;

import java.util.Iterator;

public class JSONUtils {

    /**
     * 更新json数据
     *
     * @param objJson
     * @param nodeKey   需要脱敏的节点名
     * @param maskValue
     * @return
     */
    public static Object updateJson(Object objJson, String nodeKey, String maskValue) {
        //如果传入的是json数组或者json对象,需要进行递归
        if (objJson instanceof JSONArray) {
            JSONArray jsonArray = (JSONArray) objJson;
            for (int i = 0; i < jsonArray.size(); i++) {
                updateJson(jsonArray.get(i), nodeKey, maskValue);
            }
        } else if (objJson instanceof JSONObject) {
            JSONObject jsonObject = (JSONObject) objJson;
            Iterator<String> iterator = jsonObject.keySet().iterator();
            while (iterator.hasNext()) {
                String key = iterator.next().toString();
                Object obj = jsonObject.get(key);
                if (obj instanceof JSONArray) {
                    updateJson(obj, nodeKey, maskValue);
                } else if (obj instanceof JSONObject) {
                    updateJson(obj, nodeKey, maskValue);
                } else {
                    //说明已经递归到最底层,开始判断key是否需要掩码
                    if (key.equals(nodeKey)) {
                        jsonObject.put(key, maskValue);
                    }
                }
            }
        }
        return objJson;
    }
}

四、自定义一个转换器TuoMinConverter

TuoMinConverter 继承 logback中的MessageConverter类

import ch.qos.logback.classic.pattern.MessageConverter;
import ch.qos.logback.classic.spi.ILoggingEvent;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import org.slf4j.helpers.MessageFormatter;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;


public class TuoMinConverter extends MessageConverter {
    private static final String[] maskParams = {"password","certNo", "phone"};

    @Override
    public String convert(ILoggingEvent event) {
        try {
            return doTuoMin(event);
        } catch (Exception e) {
            return super.convert(event);
        }
    }

    private String doTuoMin(ILoggingEvent event) {
        try {
            Object[] objects = Stream.of(event.getArgumentArray()).map(obj -> {
                String msg;
                if (obj instanceof String) {
                    msg = obj.toString();
                } else {
                    msg = maskJson(JSON.toJSONString(obj));
                }
                return msg;
            }).toArray();
            //将{}占位符的内容,替换为objects数组中的数据
            return MessageFormatter.arrayFormat(event.getMessage(), objects).getMessage();
        } catch (Exception e) {
            return event.getMessage();
        }
    }

    /**
     * 脱敏
     *
     * @return 返回脱敏之后的json串
     */
    private static String maskJson(String jsonStr) {
        Object maskString = null;
        List<String> marks = isMark(jsonStr);
        if (!marks.isEmpty()) {
            for (String mark : marks) {
                maskString = JSONUtils.updateJson(JSONObject.parseObject(jsonStr), mark, "*****");
                //这句代码是必须的,保证存在多个需要脱敏的字段时,不会将之前已经脱敏的字段给还原
                jsonStr = maskString.toString();
            }
            return maskString.toString();
        }else {
            //说明日志不需要脱敏
            return jsonStr;
        }
    }

    /**
     * 判断传入的字符串中是否包含需要脱敏的字段
     *
     * @param   value
     * @return  返回需要脱敏的字段集合
     */
    private static List<String> isMark(String value) {
        List<String> maskParamList = new ArrayList<>();
        for (String s : Arrays.asList(maskParams)) {
            if (value.contains(s)) {
                maskParamList.add(s);
            }
        }
        return maskParamList;
    }
}

logback.xml配置文件中声明这个转换器

        我这里是在配置文件中定义了两个转换器,conversionWord是转换词,当识别到这个转换词之后,才会走自定义的转换器。我配置文件中的日志输出格式是用 [%m],因此,成功匹配第一个转换器。

    <conversionRule conversionWord="m" converterClass="cn.tedu.TuoMinConverter"/>
    <conversionRule conversionWord="msg" converterClass="cn.tedu.TuoMinConverter"/>

 创建一个启动类

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class Demo {
    private static final Logger logger = LoggerFactory.getLogger(Demo.class);

    public static void main(String[] args) throws Exception {
        UserVO vo = new UserVO();
        vo.setUsername("UMR");
        vo.setPassword("12345678");
        vo.setCertNo("4008123123");
        vo.setWorkYear(3);
        vo.setAddress("北京");
        vo.setPhone("13608731439");
        logger.info("查询到的员工信息:{}", vo);
        logger.info("耗时:{} 毫秒", 5);
    }
}

运行,打印结果如下: 

五、全局脱敏2.0版,使用Fastjson的值过滤器

上面这种脱敏方式,难点在于对JSON数据进行递归。针对这种情况,可以使用fastjson的值过滤器,实现在序列化的时候,对指定字段的值进行修改,保证最后的json串符合预期。

自定义一个值过滤器 DefineJsonValueFilter

import com.alibaba.fastjson.serializer.ValueFilter;

public class DefineJsonValueFilter implements ValueFilter {
    private static final String[] maskParams = {"password", "certNo", "phone"};

    @Override
    public Object process(Object object, String name, Object value) {
        for (String maskParam : maskParams) {
            //如果匹配到对应字段,就说明需要进行脱敏
            if (maskParam.equalsIgnoreCase(name)) {
                //返回脱敏后的值
                return "***";
            }
        }
        //说明不需要脱敏
        return value;
    }
}

对 TuoMinConverter类 进行调整,不再调用自定义的JSONUtils

import ch.qos.logback.classic.pattern.MessageConverter;
import ch.qos.logback.classic.spi.ILoggingEvent;
import com.alibaba.fastjson.JSON;
import org.slf4j.helpers.MessageFormatter;

import java.util.stream.Stream;


public class TuoMinConverter extends MessageConverter {

    @Override
    public String convert(ILoggingEvent event) {
        try {
            return doTuoMin(event);
        } catch (Exception e) {
            return super.convert(event);
        }
    }

    private String doTuoMin(ILoggingEvent event) {
        try {
            Object[] objects = Stream.of(event.getArgumentArray()).map(obj -> {
                String msg;
                if (obj instanceof String) {
                    msg = obj.toString();
                } else {
                    //序列化时,使用自定义的值过滤器
                    msg = JSON.toJSONString(obj, new DefineJsonValueFilter());
                }
                return msg;
            }).toArray();
            //将{}占位符的内容,替换为objects数组中的数据
            return MessageFormatter.arrayFormat(event.getMessage(), objects).getMessage();
        } catch (Exception e) {
            return event.getMessage();
        }
    }

}

创建老师实体,成员变量包含学生信息

public class Teacher {
    private String teaName;
    private String phone;
    //学生信息
    private Student student;
    private String address;

    public Teacher(String teaName, String phone, Student student) {
        this.teaName = teaName;
        this.phone = phone;
        this.student = student;
    }

    public String getTeaName() {
        return teaName;
    }

    public void setTeaName(String teaName) {
        this.teaName = teaName;
    }

    public String getPhone() {
        return phone;
    }

    public void setPhone(String phone) {
        this.phone = phone;
    }

    public Student getStudent() {
        return student;
    }

    public void setStudent(Student student) {
        this.student = student;
    }

    public String getAddress() {
        return address;
    }

    public void setAddress(String address) {
        this.address = address;
    }
}

学生类实体

public class Student {
    private String stuName;
    private String phone;
    private String address;


    public Student(String stuName, String phone) {
        this.stuName = stuName;
        this.phone = phone;
    }

    public String getStuName() {
        return stuName;
    }

    public void setStuName(String stuName) {
        this.stuName = stuName;
    }

    public String getPhone() {
        return phone;
    }

    public void setPhone(String phone) {
        this.phone = phone;
    }

    public String getAddress() {
        return address;
    }

    public void setAddress(String address) {
        this.address = address;
    }
}

执行下面main方法

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class Demo {
    private static final Logger logger = LoggerFactory.getLogger(Demo.class);

    public static void main(String[] args) throws Exception {
        Student stu = new Student("UMR", "408123123");
        Teacher t = new Teacher("Mr.Hu", "123456789", stu);
        logger.info("老师信息:{}", t);
        logger.info("耗时:{} 毫秒", 5);
    }
}

六、针对不同的字段,需要使用不同的脱敏规则

        如果姓名要求保留第一个字符,手机号码要求保留前三和后四个字符,针对这种情况,就需要在值过滤器中进行特殊处理。

        第一种方式是每出现一个字段需要按照特定的脱敏规则,就添加一个if。这种比较简单,但每新增一个字段的规则,就需要新增一个if。

这里介绍第二种,利用枚举,每新增一个字段的规则,只需要定义一个枚举就行,不需要每次都在值过滤器中添加一个if判断。

public enum DataMaskRule {

    PHONE("手机号掩码,保留前三后四", "phone", "^(\\d{3})\\d+(\\d{4})$", "$1****$2"),
    NAME("姓名掩码,保留开头第一位", "teaName|stuName", "(.{1})(.+)", "$1**");

    DataMaskRule(String desc, String fieldName, String regex, String maskResult) {
        this.desc = desc;
        this.fieldName = fieldName;
        this.regex = regex;
        this.maskResult = maskResult;
    }

    /**
     * 脱敏规则描述
     */
    public String desc;
    /**
     * 要脱敏的属性名
     */
    public String fieldName;

    /**
     * 正则表达式(要脱敏的属性,匹配指定正则,才进行脱敏)
     */
    public String regex;

    /**
     * 脱敏结果
     */
    public String maskResult;

}

调整自定义的值过滤器 DefineJsonValueFilter

import com.alibaba.fastjson.serializer.ValueFilter;

public class DefineJsonValueFilter implements ValueFilter {

    @Override
    public Object process(Object object, String name, Object value) {
        for (DataMaskRule dataMaskRule : DataMaskRule.values()) {
            for (String filed : dataMaskRule.fieldName.split("\\|")) {
                if (filed.equalsIgnoreCase(name)) {
                    //如果匹配正则成功,将value按照枚举的maskResult定义的格式进行替换
                    return value.toString().replaceAll(dataMaskRule.regex, dataMaskRule.maskResult);
                }
            }
        }
        //说明不需要脱敏
        return value;
    }
}

执行下面main方法

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class Demo {
    private static final Logger logger = LoggerFactory.getLogger(Demo.class);

    public static void main(String[] args) throws Exception {
        Student stu = new Student("李四", "408123123");
        Teacher t = new Teacher("张三老师", "123456789", stu);
        logger.info("老师信息:{}", t);
        logger.info("耗时:{} 毫秒", 5);
    }
}

结果如下