1. 概叙
数据脱敏是指对敏感数据进行部分或全部掩盖,以保护数据隐私。在实际应用中,我们常常需要在日志、API响应或者数据库中对敏感数据进行脱敏处理,比如身份证号、手机号、邮箱地址等。Spring Boot提供了强大的框架支持,使得我们可以轻松地实现数据脱敏。
下面是日志脱敏和api返回数据脱敏的效果
2. 数据脱敏的场景
常见的数据脱敏场景包括:
- 日志记录:防止敏感信息在日志中泄露。
- API响应:保护用户隐私,防止敏感信息暴露给客户端。
- 数据库存储:在存储之前对数据进行脱敏处理,以确保数据安全。
3.脱敏操作
3.1 定义脱敏注解和枚举
枚举
package com.zxx.study.web.common.myenum;
/**
* 脱敏类型枚举
* @author zhouxx
* @create 2024-07-30 20:26
*/
public enum SensitiveType {
/**
* 自定义
*/
CUSTOMER,
/**
* 名称
**/
CHINESE_NAME,
/**
* 身份证证件号
**/
ID_CARD_NUM,
/**
* 手机号
**/
MOBILE_PHONE,
/**
* 固定电话
*/
FIXED_PHONE,
/**
* 密码
**/
PASSWORD,
/**
* 银行卡号
*/
BANKCARD,
/**
* 邮箱
*/
EMAIL,
/**
* 地址
*/
ADDRESS,
}
3.2 定义脱敏处理工具类DesensitizedUtil
package com.zxx.study.web.util;
import com.zxx.study.web.annotation.Sensitive;
import com.zxx.study.web.common.myenum.SensitiveType;
import com.zxx.study.web.exception.BaseResultError;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import java.lang.reflect.Field;
import java.util.*;
/**
* @author zhouxx
* @create 2024-07-30 20:29
*/
@Slf4j
public class DesensitizedUtil<T> {
/**
* 脱敏数据列表
*/
private List<T> list;
/**
* 注解列表
*/
private List<Object[]> fields;
/**
* 实体对象
*/
public Class<T> clazz;
public DesensitizedUtil(Class<T> clazz) {
this.clazz = clazz;
}
/**
* 初始化数据
*
* @param list 需要处理数据
*/
public void init(List<T> list) {
if (list == null) {
list = new ArrayList<T>();
}
this.list = list;
// 得到所有定义字段
createSensitiveField();
}
/**
* 初始化数据
*
* @param t 需要处理数据
*/
public void init(T t) {
list = new ArrayList<T>();
if (t != null) {
list.add(t);
}
// 得到所有定义字段
createSensitiveField();
}
/**
* 得到所有定义字段
*/
private void createSensitiveField() {
this.fields = new ArrayList<Object[]>();
List<Field> tempFields = new ArrayList<>();
tempFields.addAll(Arrays.asList(clazz.getSuperclass().getDeclaredFields()));
tempFields.addAll(Arrays.asList(clazz.getDeclaredFields()));
for (Field field : tempFields) {
// 单注解
if (field.isAnnotationPresent(Sensitive.class)) {
putToField(field, field.getAnnotation(Sensitive.class));
}
// 多注解
// if (field.isAnnotationPresent(Excels.class))
// {
// Excels attrs = field.getAnnotation(Excels.class);
// Excel[] excels = attrs.value();
// for (Excel excel : excels)
// {
// putToField(field, excel);
// }
// }
}
}
/**
* 对list数据源将其里面的数据进行脱敏处理
*
* @param list
* @return 结果
*/
public ApiResult desensitizedList(List<T> list) {
if (list == null) {
return ApiResult.fail(BaseResultError.API_Sensitive_NULL);
}
// 初始化数据
this.init(list);
int failTimes = 0;
for (T t : this.list) {
if (desensitization(t).getStatus() != BaseResultError.API_Sensitive_OK.getCode()) {
failTimes++;
}
}
if (failTimes > 0) {
return ApiResult.fail(BaseResultError.API_Sensitive_Fail);
}
// BaseResultError.API_Sensitive_OK
return ApiResult.success(list);
}
/**
* 放到字段集合中
*/
private void putToField(Field field, Sensitive attr) {
if (attr != null) {
this.fields.add(new Object[]{field, attr});
}
}
/**
* 脱敏:JavaBean模式脱敏
*
* @param t 需要脱敏的对象
* @return
*/
public ApiResult desensitization(T t) {
if (t == null) {
return ApiResult.fail(BaseResultError.API_Sensitive_NULL);
}
// 初始化数据
init(t);
try {
// 遍历处理需要进行 脱敏的字段
for (Object[] os : fields) {
Field field = (Field) os[0];
Sensitive sensitive = (Sensitive) os[1];
// 设置实体类私有属性可访问
field.setAccessible(true);
desensitizeField(sensitive, t, field);
}
return ApiResult.success(t);
} catch (Exception e) {
// e.printStackTrace();
log.error("日志脱敏处理失败,回滚,详细信息:[{}]", e);
return ApiResult.fail(BaseResultError.API_Sensitive_Fail_Result);
}
}
/**
* 对类的属性进行脱敏
*
* @param attr 脱敏参数
* @param vo 脱敏对象
* @param field 脱敏属性
* @return
*/
private void desensitizeField(Sensitive attr, T vo, Field field) throws IllegalAccessException {
if (attr == null || vo == null || field == null) {
return;
}
// 读取对象中的属性
Object value = field.get(vo);
SensitiveType sensitiveType = attr.type();
int prefixNoMaskLen = attr.prefixNoMaskLen();
int suffixNoMaskLen = attr.suffixNoMaskLen();
String symbol = attr.symbol();
//获取属性后现在默认处理的是String类型,其他类型数据可扩展
Object val = convertByType(sensitiveType, value, prefixNoMaskLen, suffixNoMaskLen, symbol);
field.set(vo, val);
}
/**
* 以类的属性的get方法方法形式获取值
*
* @param o 对象
* @param name 属性名
* @return value
* @throws Exception
*/
private Object getValue(Object o, String name) throws Exception {
if ((o != null) && (name != null && !StringUtils.isBlank(name))) {
Class<?> clazz = o.getClass();
Field field = clazz.getDeclaredField(name);
field.setAccessible(true);
o = field.get(o);
}
return o;
}
/**
* 根据不同注解类型处理不同字段
*/
private Object convertByType(SensitiveType sensitiveType, Object value, int prefixNoMaskLen, int suffixNoMaskLen, String symbol) {
switch (sensitiveType) {
case CUSTOMER:
value = customer(value, prefixNoMaskLen, suffixNoMaskLen, symbol);
break;
case CHINESE_NAME:
value = chineseName(value, symbol);
break;
case ID_CARD_NUM:
value = idCardNum(value, symbol);
break;
case MOBILE_PHONE:
value = mobilePhone(value, symbol);
break;
case FIXED_PHONE:
value = fixedPhone(value, symbol);
break;
case PASSWORD:
value = password(value, symbol);
break;
case BANKCARD:
value = bankCard(value, symbol);
break;
case EMAIL:
value = email(value, symbol);
break;
case ADDRESS:
value = address(value, symbol);
break;
}
return value;
}
/*--------------------------下面的脱敏工具类也可以单独对某一个字段进行使用-------------------------*/
/**
* 【自定义】 根据设置进行配置
*
* @param value 需处理数据
* @param symbol 填充字符
* @return 脱敏后数据
*/
public Object customer(Object value, int prefixNoMaskLen, int suffixNoMaskLen, String symbol) {
//针对字符串的处理
if (value instanceof String) {
return handleString((String) value, prefixNoMaskLen, suffixNoMaskLen, symbol);
}
return value;
}
/**
* 对字符串进行脱敏处理
*
* @param s 需处理数据
* @param prefixNoMaskLen 开头展示字符长度
* @param suffixNoMaskLen 结尾展示字符长度
* @param symbol 填充字符
* @return
*/
private String handleString(String s, int prefixNoMaskLen, int suffixNoMaskLen, String symbol) {
// 是否为空
if (StringUtils.isBlank(s)) {
return "";
}
// 如果设置为空之类使用 * 代替
if (StringUtils.isBlank(symbol)) {
symbol = "*";
}
// 对长度进行判断
int length = s.length();
if (length > prefixNoMaskLen + suffixNoMaskLen) {
String namePrefix = StringUtils.left(s, prefixNoMaskLen);
String nameSuffix = StringUtils.right(s, suffixNoMaskLen);
s = StringUtils.rightPad(namePrefix, StringUtils.length(s) - suffixNoMaskLen, symbol).concat(nameSuffix);
}
return s;
}
/**
* 【中文姓名】只显示第一个汉字,其他隐藏为2个星号,比如:李**
*
* @param value 需处理数据
* @param symbol 填充字符
* @return 脱敏后数据
*/
public String chineseName(Object value, String symbol) {
//针对字符串的处理
if (value instanceof String) {
// 对前后长度进行设置 默认 开头只展示一个字符
int prefixNoMaskLen = 1;
int suffixNoMaskLen = 0;
return handleString((String) value, prefixNoMaskLen, suffixNoMaskLen, symbol);
}
return "";
}
/**
* 【身份证号】显示最后四位,其他隐藏。共计18位或者15位,比如:*************1234
*
* @param value 需处理数据
* @param symbol 填充字符
* @return 脱敏后数据
*/
public String idCardNum(Object value, String symbol) {
//针对字符串的处理
if (value instanceof String) {
// 对前后长度进行设置 默认 结尾只展示四个字符
int prefixNoMaskLen = 0;
int suffixNoMaskLen = 4;
return handleString((String) value, prefixNoMaskLen, suffixNoMaskLen, symbol);
}
return "";
}
/**
* 【固定电话】 显示后四位,其他隐藏,比如:*******3241
*
* @param value 需处理数据
* @param symbol 填充字符
* @return 脱敏后数据
*/
public String fixedPhone(Object value, String symbol) {
//针对字符串的处理
if (value instanceof String) {
// 对前后长度进行设置 默认 结尾只展示四个字符
int prefixNoMaskLen = 0;
int suffixNoMaskLen = 4;
return handleString((String) value, prefixNoMaskLen, suffixNoMaskLen, symbol);
}
return "";
}
/**
* 【手机号码】前三位,后四位,其他隐藏,比如:135****6810
*
* @param value 需处理数据
* @param symbol 填充字符
* @return 脱敏后数据
*/
public String mobilePhone(Object value, String symbol) {
//针对字符串的处理
if (value instanceof String) {
// 对前后长度进行设置 默认 开头只展示三个字符 结尾只展示四个字符
int prefixNoMaskLen = 3;
int suffixNoMaskLen = 4;
return handleString((String) value, prefixNoMaskLen, suffixNoMaskLen, symbol);
}
return "";
}
/**
* 【地址】只显示到地区,不显示详细地址,比如:湖南省长沙市岳麓区***
* 只能处理 省市区的数据
*
* @param value 需处理数据
* @param symbol 填充字符
* @return
*/
public String address(Object value, String symbol) {
//针对字符串的处理
if (value instanceof String) {
// 对前后长度进行设置 默认 开头只展示九个字符
int prefixNoMaskLen = 9;
int suffixNoMaskLen = 0;
return handleString((String) value, prefixNoMaskLen, suffixNoMaskLen, symbol);
}
return "";
}
/**
* 【电子邮箱】 邮箱前缀仅显示第一个字母,前缀其他隐藏,用星号代替,@及后面的地址显示,比如:d**@126.com
*
* @param value 需处理数据
* @param symbol 填充字符
* @return 脱敏后数据
*/
public String email(Object value, String symbol) {
//针对字符串的处理
if (value instanceof String) {
// 对前后长度进行设置 默认 开头只展示一个字符 结尾只展示@及后面的地址
int prefixNoMaskLen = 1;
int suffixNoMaskLen = 4;
String s = (String) value;
if (StringUtils.isBlank(s)) {
return "";
}
// 获取最后一个@
int lastIndex = StringUtils.lastIndexOf(s, "@");
if (lastIndex <= 1) {
return s;
} else {
suffixNoMaskLen = s.length() - lastIndex;
}
return handleString((String) value, prefixNoMaskLen, suffixNoMaskLen, symbol);
}
return "";
}
/**
* 【银行卡号】前六位,后四位,其他用星号隐藏每位1个星号,比如:6222600**********1234
*
* @param value 需处理数据
* @param symbol 填充字符
* @return 脱敏后数据
*/
public String bankCard(Object value, String symbol) {
//针对字符串的处理
if (value instanceof String) {
// 对前后长度进行设置 默认 开头只展示六个字符 结尾只展示四个字符
int prefixNoMaskLen = 6;
int suffixNoMaskLen = 4;
return handleString((String) value, prefixNoMaskLen, suffixNoMaskLen, symbol);
}
return "";
}
/**
* 【密码】密码的全部字符都用*代替,比如:******
*
* @param value 需处理数据
* @param symbol 填充字符
* @return
*/
public String password(Object value, String symbol) {
//针对字符串的处理
if (value instanceof String) {
// 对前后长度进行设置 默认 开头只展示六个字符 结尾只展示四个字符
int prefixNoMaskLen = 0;
int suffixNoMaskLen = 0;
return handleString((String) value, prefixNoMaskLen, suffixNoMaskLen, symbol);
}
return "";
}
}
3.3 自定义脱敏对象UserSensitiveVo
/**
* @author zhouxx
* @create 2024-07-30 20:56
*/
@Data
@Builder
public class UserSensitiveVo {
private static final long serialVersionUID = 1L;
/** 普通用户ID */
private Long userId;
/** 昵称 */
@Sensitive(type = SensitiveType.CUSTOMER,prefixNoMaskLen = 2,suffixNoMaskLen = 1)
private String nickName;
/** 姓名 */
@Sensitive(type = SensitiveType.CHINESE_NAME)
private String userName;
/** 身份证 */
@Sensitive(type = SensitiveType.ID_CARD_NUM)
private String identityCard;
/** 手机号码 */
@Sensitive(type = SensitiveType.MOBILE_PHONE)
private String phoneNumber;
}
3.4 自定义api返回数据脱敏
入口方法
@SneakyThrows
@GetMapping("/user")
public ApiResult user(@RequestParam(value ="name", required = false) String name) {
// 脱敏对象
UserSensitiveVo user = UserSensitiveVo.builder()
.userId(100L)
.userName("zhouxx")
.nickName("周星星")
.phoneNumber("1322341234")
.identityCard("330320194910013321").build();
DesensitizedUtil<UserSensitiveVo> desensitizedUtils = new DesensitizedUtil<>(UserSensitiveVo.class);
log.info("{}",user);
return desensitizedUtils.desensitization(user);
}
@SneakyThrows
@GetMapping("/users")
public ApiResult users(@RequestParam(value ="name", required = false) String name) {
//脱敏队列
List<UserSensitiveVo> users = new ArrayList<UserSensitiveVo>();
for(int i=0;i<5;i++){
UserSensitiveVo user = UserSensitiveVo.builder()
.userId(100L+i)
.userName("zhouxx"+i)
.nickName("周星星"+i)
.phoneNumber("13"+i+"2341234")
.identityCard("3"+i+"03201949100133"+i+"1").build();
users.add(user);
}
DesensitizedUtil<UserSensitiveVo> desensitizedUtils = new DesensitizedUtil<>(UserSensitiveVo.class);
log.info("{}",users);
return desensitizedUtils.desensitizedList(users);
}
效果
3.5 自定义api返回数据脱敏AOP切面统一处理
切入点注解Encryption
入口添加注解,不用每个方法都增加脱敏处理逻辑
切面EncryptAspect: 其实就是把“3.4 controller”中逐个处理的逻辑,移到aop,统一规范处理,减少重复代码。
脱敏效果
3.6日志脱敏:重写AbstractMatcherFilter的decide方法
在 logback.xml
中配置过滤器:
3.7日志脱敏: 使用 Logstash 的 Mutate 插件(可以自行配置)
Logstash 是一款开源的数据处理引擎,可以用于收集、处理、转换和输出日志等数据。Logstash 中的 Mutate 插件提供了多个常用的数据处理功能,包括脱敏。
比如,使用 Logstash 的 mutate
插件,可以对手机号码进行脱敏处理:
filter {
mutate {
gsub => [
"message", "(?<![\\d])[1][3-9]\\d{9}(?![\\d])", "****"
]
}
}
上述配置会将日志中所有的手机号码替换为 ****。
依赖包
logback-desensitize.yml配置说明
log.info("your email:{}, your phone:{}", "123456789@qq.com","15310763497");
log.info("your email={}, your cellphone={}", "123456789@qq.com","15310763497");
# 日志脱敏
log-desensitize:
# 是否忽略大小写匹配,默认为true
ignore: true
# 是否开启脱敏,默认为false
open: true
# pattern下的key/value为固定脱敏规则
pattern:
# 邮箱 - @前第4-7位脱敏
email: "@>(4,7)"
# qq邮箱 - @后1-3位脱敏
qqemail: "@<(1,3)"
# 姓名 - 姓脱敏,如*杰伦
name: 1,1
# 密码 - 所有需要完全脱敏的都可以使用内置的password
password: password
patterns:
# 身份证号,key后面的字段都可以匹配以下规则(用逗号分隔)
- key: identity,idcard
# 定义规则的标识
custom:
# defaultRegex表示使用组件内置的规则:identity表示身份证号 - 内置的18/15位
- defaultRegex: identity
position: 9,13
# 内置的other表示如果其他规则都无法匹配到,则按该规则处理
- defaultRegex: other
position: 9,10
# 电话号码,key后面的字段都可以匹配以下规则(用逗号分隔)
- key: phone,cellphone,mobile
custom:
# 手机号 - 内置的11位手机匹配规则
- defaultRegex: phone
position: 4,7
# 自定义正则匹配表达式:座机号(带区号,号码七位|八位)
- customRegex: "^0[0-9]{2,3}-[0-9]{7,8}"
# -后面的1-4位脱敏
position: "-<(1,4)"
# 自定义正则匹配表达式:座机号(不带区号)
- customRegex: "^[0-9]{7,8}"
position: 3,5
# 内置的other表示如果其他规则都无法匹配到,则按该规则处理
- defaultRegex: other
position: 1,3
# 这种方式不太推荐 - 一旦匹配不上,就不会脱敏
- key: localMobile
custom:
customRegex: "^0[0-9]{2,3}-[0-9]{7,8}"
position: 1,3
使用 AOP 实现日志脱敏
在 Spring Boot 中,我们还可以使用 AOP(面向切面编程)的方式,在日志输出前对敏感信息进行脱敏处理。
比如,我们可以定义一个 LogAspect 切面类,在 @Before 注解的方法中,对方法参数中的敏感信息进行脱敏处理,然后再调用目标方法。
@Aspect
@Component
public class LogAspect {
private static final String PHONE_PATTERN = "(?<![\\d])[1][3-9]\\d{9}(?![\\d])";
@Before("execution(* com.example.controller..*(..))")
public void beforeController(JoinPoint joinPoint) {
Object[] args = joinPoint.getArgs();
if (args == null) {
return;
}
for (int i = 0; i < args.length; i++) {
if (args[i] instanceof String) {
String msg = (String) args[i];
Pattern pattern = Pattern.compile(PHONE_PATTERN);
Matcher matcher = pattern.matcher(msg);
StringBuffer sb = new StringBuffer();
while (matcher.find()) {
String phone = matcher.group();
matcher.appendReplacement(sb, phone.substring(0, 3) + "****" + phone.substring(7));
}
matcher.appendTail(sb);
args[i] = sb.toString();
}
}
try {
joinPoint.proceed(args);
} catch (Throwable e) {
e.printStackTrace();
}
}
}
上面的切面类会在所有 com.example.controller 包下的方法调用前执行,对方法参数中的手机号码进行脱敏处理,然后再调用目标方法。