切入点
logback-spring.xml
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<!--此日志appender是为开发使用,只配置最底级别,控制台输出的日志级别是大于或等于此级别的日志信息-->
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>debug</level>
</filter>
<encoder>
<Pattern>${CONSOLE_LOG_PATTERN}</Pattern>
<!-- 设置字符集 -->
<charset>UTF-8</charset>
</encoder>
</appender>
思路就是将logback的xml配置文件中的ConsoleAppender、RollingFileAppender替换成我们自己的Appender,通过拦截LoggingEvent,对log方法的入参进行脱敏实现全局控制。看似简单,要做的开箱即用还是需要花点时间。
效果
在需要脱敏的字段上添加自定义注解@Desensitize
@SpringBootTest
@Slf4j
class LogDesensitiveApplicationTests {
@Test
void contextLoads() {
School school = getSchool();
log.info("{}->msg:{}", System.currentTimeMillis(), school);
}
private School getSchool(){
School school = new School();
school.setId(1);
school.setSchoolName("常乐村男子技术学校");
school.setSchoolAddress("四川省成都市航空港111号");
Teacher teacher1 = new Teacher();
teacher1.setTeacherName("lilili");
teacher1.setTeacherAge(30);
teacher1.setPhone("18888888888");
teacher1.setEmail("1334455@qq.com");
teacher1.setIdCard("33010198704251315");
teacher1.setBankAccount("6161234589761252");
Teacher teacher2 = new Teacher();
teacher2.setTeacherName("zhoushurong");
teacher2.setTeacherAge(32);
teacher2.setPhone("16666666666");
teacher2.setEmail("1334465@qq.com");
teacher2.setIdCard("33010199904251316");
teacher2.setBankAccount("6161234589761255");
Student student = new Student();
//student.setTeacher(teacher2);
student.setEmail("1334465@qq.com");
student.setStuNo("12366666");
student.setPhone("11122221231");
student.setStuName("liuchj");
student.setMoney("2000RMB");
Map<String,Student> studentMap = new HashMap<>();
studentMap.put(student.getStuNo(),student);
teacher2.setStudentMap(studentMap);
List<Teacher> teacherList = new ArrayList<>();
teacherList.add(teacher1);
teacherList.add(teacher2);
school.setTeacherList(teacherList);
return school;
}
}
@Data
class BaseModel {
int id;
}
@Data
@ToString(callSuper = true)
class School extends BaseModel {
String schoolName;
String schoolAddress;
@Desensitize(type = DesensitizeTypeEnum.COLLECTION)
List<Teacher> teacherList;
}
@Data
class Teacher {
String teacherName;
int teacherAge;
@Desensitize(type = DesensitizeTypeEnum.PHNOE)
String phone;
@Desensitize(type = DesensitizeTypeEnum.EMAIL)
String email;
@Desensitize(type = DesensitizeTypeEnum.ACCOUNTNUMBER)
String bankAccount;
@Desensitize(type = DesensitizeTypeEnum.ACCOUNTNUMBER)
String idCard;
@Desensitize(type = DesensitizeTypeEnum.COLLECTION)
Map<String,Student> studentMap;
}
@Data
class Student{
@Desensitize(type = DesensitizeTypeEnum.CUSTOM,length = 1)
String stuName;
@Desensitize(type = DesensitizeTypeEnum.CUSTOM,length = 4)
String stuNo;
@Desensitize(type = DesensitizeTypeEnum.EMAIL)
String email;
@Desensitize(type = DesensitizeTypeEnum.PHNOE)
String phone;
@Desensitize(type = DesensitizeTypeEnum.PRICE)
String money;
}
结果
2022-09-21 22:52:35.357 INFO 13004 --- [ main] c.e.d.LogDesensitiveApplicationTests : 1663761155357->msg:{teacherList=[{bankAccount=61********761252, teacherAge=30, teacherName=lilili, phone=18****88888, idCard=33********4251315, studentMap=null, email=1****55@qq.com}, {bankAccount=61********761255, teacherAge=32, teacherName=zhoushurong, phone=16****66666, idCard=33********4251316, studentMap={12366666={money=****, phone=11****21231, stuName=liuchj, stuNo=12366666, email=1****65@qq.com}}, email=1****65@qq.com}], schoolAddress=四川省成都市航空港111号, schoolName=常乐村男子技术学校}
实现
1、自定义注解
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD})
public @interface Desensitize {
/**
* 字段类型
*/
DesensitizeTypeEnum type() default DesensitizeTypeEnum.CUSTOM;
/**
* 脱敏长度,非custom的取DesensitizeTypeEnum中
*/
int length() default -1;
}
2、预先定义一些常规的脱敏类型
public enum DesensitizeTypeEnum {
/**
* 邮箱
*/
EMAIL("EMAIL",4),
/**
* 电话
*/
PHNOE("PHNOE",4),
/**
* 用户名
*/
USERNAME("USERNAME",2),
/**
* 密码
*/
PASSWORD("PASSWORD",0),
/**
* 账户类(卡号、证件号)
*/
ACCOUNTNUMBER("ACCOUNTNUMBER",8),
/**
* 金额
*/
PRICE("PRICE",0),
/**
* 自定义类型,传入脱敏长度
*/
CUSTOM("CUSTOM",-1),
/**
* 集合字段
*/
COLLECTION("COLLECTION",-1);
/**
* 字段类型,email,username,password,phone等等,也可以是自定义
*/
private String dataType;
/**
* 脱敏长度,0的话全脱敏
*/
private int length;
DesensitizeTypeEnum(String dataType,int length ) {
this.dataType = dataType;
this.length = length;
}
public String getDataType() {
return dataType;
}
public int getLength() {
return length;
}
}
3、配置类
脱敏的规则因为是通过注解的方式去配置,常规的配置需要一个项目的包路径,这个后面递归处理日志入参对象时有用,可以通过yml来配置,类似于mybatis配置的mapscan,在这个包下的类会进行脱敏。
public class DesensitizeConfig {
// 这里可以做成从yml中取,做成start的话必配置,非此包下的类不会被脱敏
public static final String BASE_PACKAGE = "com.example.desensitive";
}
4、脱敏工具类(核心代码)
入参就是LoggingEvent,这个对象中包含以下几个关键属性:
log.info("hello:{}, I am {}","java","chenxi_lu");
- argumentArray ----- [“java”,“chenxi_lu”]
- message ------ “hello:{}, I am {}”
- formattedMessage ------ “hello:java, I am chenxi_lu”
formattedMessage就是我们最终打出来的日志内容,这个是通过argumentArray 生成的,源码如下:
public String getFormattedMessage() {
if (this.formattedMessage != null) {
return this.formattedMessage;
} else {
if (this.argumentArray != null) {
this.formattedMessage = MessageFormatter.arrayFormat(this.message, this.argumentArray).getMessage();
} else {
this.formattedMessage = this.message;
}
return this.formattedMessage;
}
}
那么方案就有两大种:
- 直接获取 formattedMessage这个字符串,然后通过正则匹配邮箱数字这些常规的敏感词,然后替换成*;
- 获取argumentArray中的每个入参,通过反射获类信息,类中的字段信息,把带有自定义注解的字段格式化掉,最后替换掉argumentArray中的元素;这种的话更灵活,不至于一棍子打死全部;
@Slf4j
public class DesensitiveUtil {
/**
* 脱敏处理
*/
public static void operation(LoggingEvent event) throws IllegalAccessException, NoSuchMethodException, InstantiationException, InvocationTargetException {
// 获取log参数,替换占位符{}的入参
Object[] args = event.getArgumentArray();
if(!Objects.isNull(args)){
for(int i =0; i<args.length; i++){
Object arg = args[i];
args[i] = toArgMap(arg);
}
}
event.setArgumentArray(args);
}
/**
* 将参数格式化,非自己工程的包不处理,自己的对象递归处理带注解的属性,进行格式化,替换原来的参数。
*/
private static Object toArgMap(Object arg) throws IllegalAccessException, NoSuchMethodException, InvocationTargetException, InstantiationException {
// 通过反射的获取到参数对象
Class argClass = arg.getClass();
Package classPath = argClass.getPackage();
// 不是目标包不处理
if (!Objects.isNull(classPath) && classPath.getName().startsWith(DesensitizeConfig.BASE_PACKAGE)){
Map<String,Object> entityMap = new HashMap<>();
// 获取字段
entityMap = loop(arg);
return entityMap;
}
return arg;
}
/**
* 递归处理自有类属性,对象相互引用的情况暂时未处理,会抛出堆栈异常。
*/
private static Map<String,Object> loop(Object arg) throws IllegalAccessException, NoSuchMethodException, InstantiationException, InvocationTargetException {
Class argClass = arg.getClass();
Map<String,Object> entityMap = new HashMap<>();
Field[] fields = argClass.getDeclaredFields();
if(!Objects.isNull(fields)){
for (int k = 0; k < fields.length; k++) {
Field field = fields[k];
field.setAccessible(true);
//Class fieldClass = field.getDeclaringClass();
Class fieldTypeClass = field.getType();
Package classPath = fieldTypeClass.getPackage();
Object fieldValue = field.get(arg);
if(Objects.isNull(classPath)){
// 基本数据类型,int,long这些
entityMap.put(field.getName(),fieldValue);
continue;
}
String fieldClassPath = classPath.getName();
if(Objects.isNull(fieldValue)){
// 空值字段,如果确实有对象相互依赖的,一定要处理(a1==>b==>a1 调整成 a1==>b==>a2),这样a2里的b就是空的。
entityMap.put(field.getName(),fieldValue);
continue;
}
if(fieldClassPath.startsWith(DesensitizeConfig.BASE_PACKAGE)){
Map<String,Object> loopEntity = loop(fieldValue);
entityMap.put(field.getName(),loopEntity);
} else {
// 判断属性是否带注解
Desensitize desensitizeAnnotation = field.getAnnotation(Desensitize.class);
if(Objects.isNull(desensitizeAnnotation)) {
// 不脱敏的保存原来值
entityMap.put(field.getName(),fieldValue);
}else {
// 带注解的进行脱敏
DesensitizeTypeEnum desensitizeTypeEnum = desensitizeAnnotation.type();
int length = desensitizeAnnotation.length() < 0 ? desensitizeTypeEnum.getLength() : desensitizeAnnotation.length();
switch (desensitizeTypeEnum){
case EMAIL:
if(isStr(field)){
String val = desensitizeEmail(fieldValue,length);
entityMap.put(field.getName(),val);
}else {
log.error("{} is need String field!",desensitizeTypeEnum.getDataType());
// 不脱敏的保存原来值
entityMap.put(field.getName(),fieldValue);
}
break;
case PHNOE:
if(isStr(field)){
String val = desensitizePhone(fieldValue,length);
entityMap.put(field.getName(),val);
}else {
log.error("{} is need String field!",desensitizeTypeEnum.getDataType());
// 不脱敏的保存原来值
entityMap.put(field.getName(),fieldValue);
}
break;
case USERNAME:
if(isStr(field)){
String val = desensitizeUserName(fieldValue,length);
entityMap.put(field.getName(),val);
}else {
log.error("{} is need String field!",desensitizeTypeEnum.getDataType());
// 不脱敏的保存原来值
entityMap.put(field.getName(),fieldValue);
}
break;
case PASSWORD:
// 密码全脱敏
entityMap.put(field.getName(),"****");
break;
case ACCOUNTNUMBER:
if(isStr(field)){
String val = desensitizeAccountNumber(fieldValue,length);
entityMap.put(field.getName(),val);
}else {
log.error("{} is need String field!",desensitizeTypeEnum.getDataType());
// 不脱敏的保存原来值
entityMap.put(field.getName(),fieldValue);
}
break;
case PRICE:
// 价格的全脱敏
entityMap.put(field.getName(),"****");
break;
case CUSTOM:
entityMap.put(field.getName(),fieldValue);
break;
case COLLECTION:
// 对集合类型的字段,需要判断集合里存的是什么对象
entityMap.put(field.getName(),desensitizeCollect(field,fieldValue));
break;
default:
entityMap.put(field.getName(),fieldValue);
}
}
}
}
}
return entityMap;
}
/**
* 脱敏嵌套的集合类型
* 支持:Collection,Map
*/
private static Object desensitizeCollect(Field field, Object fieldValue) throws IllegalAccessException, NoSuchMethodException, InvocationTargetException, InstantiationException {
if(fieldValue instanceof Collection){
Collection coll = (Collection) fieldValue;
Iterator iterator = coll.iterator();
Collection desColl = (Collection)fieldValue.getClass().newInstance();
desColl.clear();
while(iterator.hasNext()){
Object item = iterator.next();
Object desensitizeItem = toArgMap(item);
desColl.add(desensitizeItem);
}
return desColl;
}else if(fieldValue instanceof Map){
// Map 字段
Map<Object,Object> objectMap = (Map)fieldValue;
Map<Object,Object> desMap = (Map)fieldValue.getClass().newInstance();
desMap.clear();
for(Object key : objectMap.keySet()){
Object deObject = toArgMap(objectMap.get(key));
desMap.put(key,deObject);
}
return desMap;
}else {
log.error("{} is not support to desensitize!",field.getType());
}
return fieldValue;
}
/**
* 脱敏账号
*/
private static String desensitizeAccountNumber(Object fieldValue,int length) {
String account = String.valueOf(fieldValue);
// 邮箱地址取前半部分
int accountLength = account.length();
// 取开始脱敏的位置
int index = accountLength/length;
// 如果有邮箱地址特别短的
int finalLength = accountLength<length?accountLength:length;
// 替换字符
char[] chars = account.toCharArray();
for(int i =index;i<index+finalLength;i++){
chars[i] = '*';
}
return String.valueOf(chars);
}
/**
* 脱敏用户名
*/
private static String desensitizeUserName(Object fieldValue,int length) {
String username = String.valueOf(fieldValue);
// 邮箱地址取前半部分
int usernameLength = username.length();
// 取开始脱敏的位置
int index = usernameLength/length;
// 如果有邮箱地址特别短的
int finalLength = usernameLength<length?usernameLength:length;
// 替换字符
char[] chars = username.toCharArray();
for(int i =index;i<index+finalLength;i++){
chars[i] = '*';
}
return String.valueOf(chars);
}
/**
* 脱敏手机号码
*/
private static String desensitizePhone(Object fieldValue,int length) {
String phone = String.valueOf(fieldValue);
// 邮箱地址取前半部分
int phoneLength = phone.length();
// 取开始脱敏的位置
int index = phoneLength/length;
// 如果有邮箱地址特别短的
int finalLength = phoneLength<length?phoneLength:length;
// 替换字符
char[] chars = phone.toCharArray();
for(int i =index;i<index+finalLength;i++){
chars[i] = '*';
}
return String.valueOf(chars);
}
/**
* 脱敏邮件
*/
private static String desensitizeEmail(Object fieldValue,int length) {
String email = String.valueOf(fieldValue);
String[] emailName = email.split("@");
// 邮箱地址取前半部分
int emailNameLength = emailName[0].length();
// 取开始脱敏的位置
int index = emailNameLength/length;
// 如果有邮箱地址特别短的
int finalLength = emailNameLength<length?emailNameLength:length;
// 替换字符
char[] chars = email.toCharArray();
for(int i =index;i<index+finalLength;i++){
chars[i] = '*';
}
return String.valueOf(chars);
}
/**
* 判断字段是否是String类型
*/
private static boolean isStr(Field field){
return field.getType().equals(String.class);
}
}
5、自定义Appender
@Slf4j
public class DesensitiveConsoleAppender extends ConsoleAppender {
@Override
protected void subAppend(Object event) {
try {
DesensitiveUtil.operation((LoggingEvent) event);
}catch (Exception e){
log.error("log error!",e);
}finally {
super.subAppend(event);
}
}
}
其他几个Appender一样。
6、替换logback-spring.xml
<!--输出到控制台-->
<appender name="CONSOLE" class="com.example.desensitive.appender.DesensitiveConsoleAppender">
<!--此日志appender是为开发使用,只配置最底级别,控制台输出的日志级别是大于或等于此级别的日志信息-->
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>info</level>
</filter>
<encoder>
<Pattern>${CONSOLE_LOG_PATTERN}</Pattern>
<!-- 设置字符集 -->
<charset>UTF-8</charset>
</encoder>
</appender>
完成!!
注意点
1、对象间相互引用
暂时处理不了,有大佬知道的请不吝赐教。
A a = new A();
B b = new B();
a.setB(b);
b.setA(a);
log.info("a:{}",a);
这种情况下,递归会死掉,堆栈溢出。
2、集合字段
目前只支持Collection与Map接口的实现类,其他的看情况添加可以;