有两个页面,调用同一个查询接口,一个页面要数据脱敏,另一个页面不脱敏。
目前情况是给字段加自定义注解,序列化的时候有注解的都脱敏了。如何不copy一份代码实现动态脱敏呢?
解决方案:ThreadLocal + Filter
序列化依赖
java
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.58</version>
</dependency>
代码
java
@Data
@Builder
public class DesensitizeContext {
private boolean skipSensitive;
}
java
public class DesensitizeManager {
private static final ThreadLocal<DesensitizeContext> THREAD_LOCAL = new ThreadLocal<>();
public static void set(DesensitizeContext skip) {
THREAD_LOCAL.set(skip);
}
public static DesensitizeContext get() {
return THREAD_LOCAL.get();
}
public static void clear() {
THREAD_LOCAL.remove();
}
}
自定义脱敏注解
java
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@JacksonAnnotationsInside
@JsonSerialize(using = SensitiveJsonSerializer.class)
public @interface Sensitive {
SensitiveType type();
}
实体类
java
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserDTO {
private String name;
@Sensitive(type = SensitiveType.ID_CARD)
private String idCard;
@Sensitive(type = SensitiveType.PHONE)
private String phone;
}
脱敏规则枚举
java
public enum SensitiveType {
PHONE {
@Override
public String sensitize(String value) {
if (value == null || value.length() < 8) return value;
return value.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2");
}
},
ID_CARD {
@Override
public String sensitize(String value) {
if (value == null || value.length() < 15) return value;
// 保留前6位和后4位,中间用 * 代替
int length = value.length();
String prefix = value.substring(0, 6);
String suffix = value.substring(length - 4);
return prefix + "********" + suffix;
}
},
EMAIL {
@Override
public String sensitize(String value) {
if (value == null || !value.contains("@")) return value;
int atIndex = value.indexOf('@');
String username = value.substring(0, Math.max(1, atIndex));
return username + "****" + value.substring(atIndex);
}
};
/**
* 抽象方法:每个枚举值必须实现
*
* @param value 传入要脱敏的值
* @return -
*/
public abstract String sensitize(String value);
}
}
过滤器
java
public class DesensitizeFilter implements Filter {
private static final String BYPASS_DESENSITIZE_HEADER = "X-No-Desensitize";
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
/*
从参数判断是否跳过脱敏
*/
// String desensitize = request.getParameter("desensitize");
// boolean skipSensitive = "true".equalsIgnoreCase(desensitize);
/*
从 header判断是否跳过脱敏
*/
HttpServletRequest httpRequest = (HttpServletRequest) request;
String headerValue = httpRequest.getHeader(BYPASS_DESENSITIZE_HEADER);
boolean skipDesensitize = "true".equalsIgnoreCase(headerValue);
try {
DesensitizeManager.set(DesensitizeContext.builder().skipSensitive(skipDesensitize).build());
// 放行
chain.doFilter(request, response);
} finally {
// 必须清理!
DesensitizeManager.clear();
}
}
}
注册过滤器
java
@Configuration
public class DesensitizeFilterConfiguration {
@Bean
public FilterRegistrationBean<DesensitizeFilter> desensitizeFilter() {
FilterRegistrationBean<DesensitizeFilter> registrationBean = new FilterRegistrationBean<>();
registrationBean.setFilter(new DesensitizeFilter());
// 需要拦截的请求
registrationBean.addUrlPatterns(
"/user/users"
);
registrationBean.setOrder(1);
return registrationBean;
}
}
字段脱敏序列化器
java
public class SensitiveJsonSerializer extends JsonSerializer<String> implements ContextualSerializer {
private SensitiveType sensitiveType;
/**
* 序列化时执行,调用 N 次(N = 对象数量 × 字段数量)
*
* @param value 值
* @param gen 生成器
* @param serializerProvider -
* @throws IOException -
*/
@Override
public void serialize(String value, JsonGenerator gen, SerializerProvider serializerProvider) throws IOException {
String masked = value;
DesensitizeContext context = DesensitizeManager.get();
// 未被拦截的请求、拦截的请求并跳过脱敏的请求
if (context == null || !context.isSkipSensitive()) {
// 执行脱敏逻辑
masked = sensitiveType.sensitize(value);
}
// 明文
gen.writeString(masked);
}
/**
* 此方法相当于给需要脱敏的字段打脱敏类型标记(使用哪种脱敏规则)
*
* 第一次请求 / 应用启动后首次序列化,只调用一次 per field
*
* @param serializerProvider -
* @param property 属性
* @return -
*/
@Override
public JsonSerializer<?> createContextual(SerializerProvider serializerProvider, BeanProperty property) {
Sensitive sensitive = property.getAnnotation(Sensitive.class);
// 有脱敏注解并且为字符串
if (sensitive != null && String.class.equals(property.getType().getRawClass())) {
SensitiveJsonSerializer serializer = new SensitiveJsonSerializer();
serializer.sensitiveType = sensitive.type();
return serializer;
}
return this;
}
}
列化器中serialize、createContextual这两个方法的执行流程:
假设有如下3条数据(两个字段):
{ "phone": "138****5678", "idCard": "110101\*\*\*\*\*\*\*\*XXXX" }, { "phone": "139**** 4321", "idCard": "110101********YYYY" }, { "phone": "150\*\*\*\*8888", "idCard": "110101******** ZZZZ" }
┌─────────────────────────────┐
│ createContextual(phone) │ → 返回一个 type=PHONE 的 SensitiveJsonSerializer 实例
└─────────────────────────────┘
┌─────────────────────────────┐
│ createContextual(idCard) │ → 返回一个 type=ID_CARD 的 SensitiveJsonSerializer 实例
└─────────────────────────────┘
┌─────────────────────────────────────────┐
│ serialize("13812345678", gen, provider) │ → 使用 PHONE 实例脱敏
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ serialize("110101...XXXX",gen, provider)│ → 使用 ID_CARD 实例脱敏
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ serialize("13987654321", gen, provider) │ → 再次使用 PHONE 实例
└─────────────────────────────────────────┘
集合中是同一个对象类型,有几个字段就调用几次createContextual
以上流程共执行2次createContextual, 6次serialize(对象数量 × 字段数量)
controller测试
java
@RestController
public class UserController {
@GetMapping("/user/users")
public List<UserDTO> getUsers() {
return Arrays.asList(
new UserDTO("张三", "11010119900307XXXX", "13812345678"),
new UserDTO("李四", "11010119910408XXXX", "13987654321")
);
}
@GetMapping("/user1/users1")
public List<UserDTO> getUsers1() {
return Arrays.asList(
new UserDTO("张三", "11010119900307XXXX", "13812345678"),
new UserDTO("李四", "11010119910408XXXX", "13987654321")
);
}
}
测试结果
根据请求头进行动态脱敏
/user1/users1:
加不加X-No-Desensitize 请求头都是脱敏的数据
过滤器拦截的/user/users:
不加X-No-Desensitize 请求头
加X-No-Desensitize 请求头
建议
虽然这种方式可以实现不更改接口即可实现动态脱敏,但是通过外部传参来决定是否脱敏具有一定的安全问题。应该在controller复制一份接口,把映射的url改一下,过滤器针对这个url进行拦截,前端换接口。