package cn.mb.cloud.gateway.filter;
|
|
import org.reactivestreams.Publisher;
|
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
|
import org.springframework.cloud.gateway.filter.GlobalFilter;
|
import org.springframework.core.Ordered;
|
import org.springframework.core.io.buffer.DataBuffer;
|
import org.springframework.core.io.buffer.DataBufferFactory;
|
import org.springframework.core.io.buffer.DataBufferUtils;
|
import org.springframework.http.MediaType;
|
import org.springframework.http.server.reactive.ServerHttpResponse;
|
import org.springframework.http.server.reactive.ServerHttpResponseDecorator;
|
import org.springframework.stereotype.Component;
|
import org.springframework.web.server.ServerWebExchange;
|
import reactor.core.publisher.Mono;
|
|
import java.nio.charset.StandardCharsets;
|
import java.util.regex.Matcher;
|
import java.util.regex.Pattern;
|
|
/** Final API boundary for APP traffic, including responses returned by every service. */
|
@Component
|
public class SensitiveDataMaskingGlobalFilter implements GlobalFilter, Ordered {
|
/**
|
* This response contains the current user's own phone number, which the APP
|
* needs in its original form. The account service marks the field with
|
* {@code @UnmaskedSensitiveData}; the gateway works on JSON text, so it
|
* must mirror that narrow exception here.
|
*/
|
private static final String UNMASKED_USER_DETAILS_PATH = "/account/api/useBenefit/userDetails";
|
private static final Pattern FIELD = Pattern.compile("(?i)(\\\"(?:[a-z]*phone|[a-z]*mobile|telephone|tel)\\\"\\s*:\\s*\\\")([^\\\"]*)(\\\")");
|
private static final Pattern IDCARD = Pattern.compile("(?i)(\\\"(?:[a-z]*idcard|[a-z]*identitycard|identitynumber|idnumber)\\\"\\s*:\\s*\\\")([^\\\"]*)(\\\")");
|
|
@Override public int getOrder() { return -2; }
|
|
@Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
|
// This route itself enforces the admin check and writes an audit record.
|
String path = exchange.getRequest().getURI().getPath();
|
if (path.startsWith("/system/sensitive/") || UNMASKED_USER_DETAILS_PATH.equals(path)) return chain.filter(exchange);
|
ServerHttpResponseDecorator decorated = new ServerHttpResponseDecorator(exchange.getResponse()) {
|
@Override public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {
|
MediaType type = getHeaders().getContentType();
|
if (type == null || !MediaType.APPLICATION_JSON.isCompatibleWith(type)) return super.writeWith(body);
|
return DataBufferUtils.join(body).flatMap(buffer -> {
|
byte[] bytes = new byte[buffer.readableByteCount()]; buffer.read(bytes);
|
DataBufferUtils.release(buffer);
|
String json = new String(bytes, StandardCharsets.UTF_8);
|
byte[] masked = mask(json).getBytes(StandardCharsets.UTF_8);
|
getHeaders().setContentLength(masked.length);
|
DataBufferFactory factory = getDelegate().bufferFactory();
|
return super.writeWith(Mono.just(factory.wrap(masked)));
|
});
|
}
|
};
|
return chain.filter(exchange.mutate().response(decorated).build());
|
}
|
|
private String mask(String json) { return replace(replace(json, FIELD, false), IDCARD, true); }
|
private String replace(String json, Pattern pattern, boolean idCard) {
|
Matcher matcher = pattern.matcher(json); StringBuffer output = new StringBuffer();
|
while (matcher.find()) matcher.appendReplacement(output, Matcher.quoteReplacement(matcher.group(1) + maskValue(matcher.group(2), idCard) + matcher.group(3)));
|
matcher.appendTail(output); return output.toString();
|
}
|
private String maskValue(String value, boolean idCard) {
|
if (idCard) return value.length() <= 10 ? stars(value.length()) : value.substring(0, 6) + stars(value.length() - 10) + value.substring(value.length() - 4);
|
return value.length() <= 7 ? stars(value.length()) : value.substring(0, 3) + "****" + value.substring(7);
|
}
|
private String stars(int count) { StringBuilder result = new StringBuilder(); for (int i = 0; i < count; i++) result.append('*'); return result.toString(); }
|
}
|