SpringBoot中实现接口查询数据动态脱敏

有两个页面,调用同一个查询接口,一个页面要数据脱敏,另一个页面不脱敏。

目前情况是给字段加自定义注解,序列化的时候有注解的都脱敏了。如何不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进行拦截,前端换接口。