SpringBoot中实现接口查询数据动态脱敏
有两个页面,调用同一个查询接口,一个页面要数据脱敏,另一个页面不脱敏。
目前情况是给字段加自定义注解,序列化的时候有注解的都脱敏了。如何不copy一份代码实现动态脱敏呢?
解决方案:ThreadLocal + Filter
序列化依赖
<dependency><groupId>com.alibaba.fastjson2</groupId><artifactId>fastjson2</artifactId><version>2.0.58</version></dependency>
代码
@Data
@Builder
public class DesensitizeContext {private boolean skipSensitive;
}
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();}
}
自定义脱敏注解
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@JacksonAnnotationsInside
@JsonSerialize(using = SensitiveJsonSerializer.class)
public @interface Sensitive {SensitiveType type();
}
实体类
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserDTO {private String name;@Sensitive(type = SensitiveType.ID_CARD)private String idCard;@Sensitive(type = SensitiveType.PHONE)private String phone;
}
脱敏规则枚举
public enum SensitiveType {PHONE {@Overridepublic 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 {@Overridepublic 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 {@Overridepublic 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);
}
}
过滤器
public class DesensitizeFilter implements Filter {private static final String BYPASS_DESENSITIZE_HEADER = "X-No-Desensitize";@Overridepublic 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();}}
}
注册过滤器
@Configuration
public class DesensitizeFilterConfiguration {@Beanpublic FilterRegistrationBean<DesensitizeFilter> desensitizeFilter() {FilterRegistrationBean<DesensitizeFilter> registrationBean = new FilterRegistrationBean<>();registrationBean.setFilter(new DesensitizeFilter());// 需要拦截的请求registrationBean.addUrlPatterns("/user/users");registrationBean.setOrder(1);return registrationBean;}
}
字段脱敏序列化器
public class SensitiveJsonSerializer extends JsonSerializer<String> implements ContextualSerializer {private SensitiveType sensitiveType;/*** 序列化时执行,调用 N 次(N = 对象数量 × 字段数量)** @param value 值* @param gen 生成器* @param serializerProvider -* @throws IOException -*/@Overridepublic 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 -*/@Overridepublic 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”: “1385678", “idCard”: “110101********XXXX” },
{ “phone”: "1394321”, “idCard”: “110101YYYY" },
{ “phone”: “150****8888”, “idCard”: "110101ZZZZ” }
]
┌─────────────────────────────┐
│ 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测试
@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进行拦截,前端换接口。