package com.dsh.privacy;
|
|
import org.springframework.core.MethodParameter;
|
import org.springframework.http.MediaType;
|
import org.springframework.http.converter.HttpMessageConverter;
|
import org.springframework.web.bind.annotation.ControllerAdvice;
|
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
|
|
import javax.servlet.http.HttpServletRequest;
|
import java.util.Arrays;
|
import java.util.HashSet;
|
import java.util.Set;
|
import java.lang.reflect.Array;
|
import java.lang.reflect.Field;
|
import java.lang.reflect.Modifier;
|
import java.util.Collection;
|
import java.util.IdentityHashMap;
|
import java.util.Map;
|
|
/**
|
* 手机号和身份证号等个人敏感数据的响应边界保护。
|
* 基于字段名进行识别,因此也可覆盖报表接口临时生成的 Map 数据。
|
*/
|
@ControllerAdvice
|
public class SensitiveDataMaskingAdvice implements ResponseBodyAdvice<Object> {
|
private static final String INTERNAL_SENSITIVE_ACCESS = "X-Internal-Sensitive-Access";
|
/** 客户端接收原始数据、仅在展示时脱敏的 APP 接口。 */
|
private static final Set<String> APP_UNMASKED_PATHS = new HashSet<String>(Arrays.asList(
|
"/base/exploreWP/storeDetail",
|
"/base/exploreWP/customerList"
|
));
|
|
private final HttpServletRequest request;
|
|
public SensitiveDataMaskingAdvice(HttpServletRequest request) {
|
this.request = request;
|
}
|
|
@Override
|
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
|
String path = request.getRequestURI();
|
// 内部管理调用必须携带敏感数据访问请求头。明确列出的两个 APP 接口返回原始数据,
|
// 由客户端在展示时脱敏;其他账户服务响应均进行脱敏。
|
return !(APP_UNMASKED_PATHS.contains(path)
|
|| (path.startsWith("/internal/sensitive/")
|
&& "true".equalsIgnoreCase(request.getHeader(INTERNAL_SENSITIVE_ACCESS))));
|
}
|
|
@Override
|
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
|
Class<? extends HttpMessageConverter<?>> selectedConverterType,
|
org.springframework.http.server.ServerHttpRequest request,
|
org.springframework.http.server.ServerHttpResponse response) {
|
mask(body, new IdentityHashMap<Object, Boolean>());
|
return body;
|
}
|
|
private void mask(Object value, IdentityHashMap<Object, Boolean> visited) {
|
if (value == null || value instanceof String || value.getClass().isEnum() || visited.put(value, Boolean.TRUE) != null) return;
|
// Path 的实现类(如 sun.nio.fs.UnixPath)本身是 Iterable;遍历其子路径会不断创建新对象,
|
// IdentityHashMap 无法识别该循环,因而会导致 StackOverflowError。
|
if (value instanceof java.nio.file.Path || isJdkType(value.getClass())) return;
|
if (value instanceof Map) {
|
for (Object entryObject : ((Map<?, ?>) value).entrySet()) {
|
Map.Entry entry = (Map.Entry) entryObject;
|
Object entryValue = entry.getValue();
|
if (entryValue instanceof String && sensitiveType(String.valueOf(entry.getKey())) != 0) {
|
entry.setValue(maskText((String) entryValue, sensitiveType(String.valueOf(entry.getKey()))));
|
} else mask(entryValue, visited);
|
}
|
return;
|
}
|
if (value instanceof Iterable) { for (Object item : (Iterable<?>) value) mask(item, visited); return; }
|
if (value.getClass().isArray()) { for (int i = 0; i < Array.getLength(value); i++) mask(Array.get(value, i), visited); return; }
|
for (Class<?> type = value.getClass(); type != null && type != Object.class; type = type.getSuperclass()) {
|
for (Field field : type.getDeclaredFields()) {
|
if (Modifier.isStatic(field.getModifiers()) || field.isSynthetic()) continue;
|
try {
|
field.setAccessible(true);
|
Object fieldValue = field.get(value);
|
int sensitive = sensitiveType(field.getName());
|
if (fieldValue instanceof String && sensitive != 0 && !field.isAnnotationPresent(UnmaskedSensitiveData.class)) {
|
field.set(value, maskText((String) fieldValue, sensitive));
|
}
|
else mask(fieldValue, visited);
|
} catch (IllegalAccessException ignored) { }
|
}
|
}
|
}
|
|
private boolean isJdkType(Class<?> type) {
|
String name = type.getName();
|
return name.startsWith("java.") || name.startsWith("javax.") || name.startsWith("sun.");
|
}
|
|
private int sensitiveType(String name) {
|
String key = name == null ? "" : name.replace("_", "").replace("-", "").toLowerCase();
|
if (key.equals("idcard") || key.equals("identitycard") || key.equals("identitynumber") || key.equals("idnumber")) return 2;
|
return key.equals("phone") || key.equals("mobile") || key.equals("telephone") || key.equals("tel") || key.endsWith("phone") || key.endsWith("mobile") ? 1 : 0;
|
}
|
|
private String maskText(String value, int sensitive) {
|
if (value == null) return null;
|
if (sensitive == 1) return value.length() <= 7 ? stars(value.length()) : value.substring(0, 3) + "****" + value.substring(7);
|
return value.length() <= 10 ? stars(value.length()) : value.substring(0, 6) + stars(value.length() - 10) + value.substring(value.length() - 4);
|
}
|
|
private String stars(int count) {
|
StringBuilder result = new StringBuilder();
|
for (int i = 0; i < count; i++) result.append('*');
|
return result.toString();
|
}
|
}
|