刘帅
2026-09-29 8a89ba02db5c19db669a5cf9bb7d443223bdfb71
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
package cn.mb.cloud.gateway.filter;
 
import cn.mb.cloud.gateway.util.RedisUtil;
import com.alibaba.fastjson.JSON;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.client.discovery.DiscoveryClient;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
 
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
 
/**
 * 所有经网关进入系统的请求统一写入审计服务。
 * 使用服务发现直连,避免审计请求再次经过网关产生递归;写入异常不会影响业务响应。
 */
@Slf4j
@Component
public class AccountAuditGlobalFilter implements GlobalFilter, Ordered {
    private static final String AUDIT_SERVICE = "mb-cloud-other";
 
    @Autowired private DiscoveryClient discoveryClient;
    @Autowired private RedisUtil redisUtil;
 
    @Override public int getOrder() { return 1000; }
 
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        String uri = request.getURI().getRawPath();
        if (uri.startsWith("/internal/audit-logs") || !shouldAudit(request.getMethodValue(), uri)) {
            return chain.filter(exchange);
        }
        long startedAt = System.currentTimeMillis();
        return chain.filter(exchange).then(Mono.<Void>fromRunnable(() -> writeAudit(exchange, startedAt))
                .subscribeOn(Schedulers.boundedElastic()));
    }
 
    private void writeAudit(ServerWebExchange exchange, long startedAt) {
        try {
            ServerHttpRequest request = exchange.getRequest();
            String uri = request.getURI().getRawPath();
            Map<String, Object> audit = new LinkedHashMap<String, Object>();
            audit.put("traceId", request.getHeaders().getFirst("X-Audit-Trace-Id") == null
                    ? UUID.randomUUID().toString().replace("-", "") : request.getHeaders().getFirst("X-Audit-Trace-Id"));
            audit.put("sourceService", "mb-cloud-gateway");
            audit.put("sourceType", "HTTP");
            audit.put("operationType", operationType(request.getMethodValue(), uri));
            audit.put("riskLevel", riskLevel(uri, (String) audit.get("operationType")));
            audit.put("operationName", request.getMethodValue() + " " + uri);
            audit.put("requestMethod", request.getMethodValue());
            audit.put("requestUri", uri);
            audit.put("requestParams", maskedQuery(request));
            // 审计账号用于追溯操作人,必须保留原始账号;手机号、证件号等请求参数仍按规则脱敏。
            audit.put("userAccount", username(request));
            audit.put("clientIp", clientIp(request));
            int status = exchange.getResponse().getStatusCode() == null ? 200 : exchange.getResponse().getStatusCode().value();
            audit.put("result", status < 400 ? "SUCCESS" : "FAIL");
            audit.put("errorMessage", status < 400 ? null : "HTTP " + status);
            audit.put("createdAt", new Date());
            audit.put("durationMs", System.currentTimeMillis() - startedAt);
            post(audit);
        } catch (Exception e) {
            log.warn("统一审计写入失败", e);
        }
    }
 
    private void post(Map<String, Object> audit) throws Exception {
        List<ServiceInstance> instances = discoveryClient.getInstances(AUDIT_SERVICE);
        if (instances == null || instances.isEmpty()) return;
        ServiceInstance instance = instances.get(0);
        URL url = new URL(instance.getUri().toString() + "/internal/audit-logs");
        HttpURLConnection connection = (HttpURLConnection) url.openConnection();
        connection.setConnectTimeout(1000);
        connection.setReadTimeout(1500);
        connection.setRequestMethod("POST");
        connection.setDoOutput(true);
        connection.setRequestProperty("Content-Type", "application/json;charset=UTF-8");
        byte[] bytes = JSON.toJSONString(audit).getBytes(StandardCharsets.UTF_8);
        try (OutputStream output = connection.getOutputStream()) { output.write(bytes); }
        connection.getResponseCode();
        connection.disconnect();
    }
 
    private String username(ServerHttpRequest request) {
        String authorization = request.getHeaders().getFirst("Authorization");
        if (StringUtils.isBlank(authorization)) return null;
        String token = authorization.replaceFirst("(?i)^Bearer\\s+", "").trim();
        return token.length() < 32 ? null : redisUtil.getValue(token.substring(token.length() - 32));
    }
    private String operationType(String method, String uri) {
        String lower = uri.toLowerCase();
        if (lower.contains("login") || lower.contains("token")) return "LOGIN";
        if (lower.matches(".*(status|state|ticket|payment|finance|refund|withdraw|balance).*")) return "STATUS_CHANGE";
        if (lower.matches(".*(add|create|save|insert|commit|submit|upload).*")) return "CREATE";
        if ("PUT".equalsIgnoreCase(method) || "PATCH".equalsIgnoreCase(method)) return "UPDATE";
        if ("DELETE".equalsIgnoreCase(method)) return "DELETE";
        if (lower.matches(".*(edit|update|modify|change|set|reset|freeze|unfreeze|enable|disable|audit|approve|reject).*")) return "UPDATE";
        if (lower.matches(".*(phone|mobile|idcard|identity|bank|wallet).*")) return "VIEW_SENSITIVE";
        return "READ";
    }
 
    /**
     * 网关只记录外部用户真正触发的业务变更、登录和敏感数据访问。
     * POST 在本系统中大量用于列表/详情查询,不能再按 HTTP 方法一概当作新增。
     */
    private boolean shouldAudit(String method, String uri) {
        String lower = uri == null ? "" : uri.toLowerCase();
        if (lower.matches(".*(login|token|phone|mobile|idcard|identity|bank|wallet).*")) return true;
        if ("PUT".equalsIgnoreCase(method) || "PATCH".equalsIgnoreCase(method)
                || "DELETE".equalsIgnoreCase(method)) return true;
        return "POST".equalsIgnoreCase(method)
                && lower.matches(".*(add|create|save|insert|edit|update|modify|delete|remove|del|"
                + "change|set|reset|freeze|unfreeze|enable|disable|commit|submit|upload|"
                + "audit|approve|reject|refund|withdraw|payment).*" );
    }
    private String riskLevel(String uri, String type) {
        String lower = uri.toLowerCase();
        if ("STATUS_CHANGE".equals(type) || lower.matches(".*(finance|payment|refund|withdraw|balance|ticket).*")) return "CRITICAL";
        return lower.matches(".*(phone|mobile|idcard|identity|bank|wallet).*") ? "SENSITIVE" : "NORMAL";
    }
    private String maskedQuery(ServerHttpRequest request) {
        Map<String, Object> values = new LinkedHashMap<String, Object>();
        request.getQueryParams().forEach((key, value) -> values.put(key,
                key.toLowerCase().matches(".*(password|token|secret|idcard|bank|phone|mobile).*") ? "***" : value));
        return JSON.toJSONString(values);
    }
    private String clientIp(ServerHttpRequest request) { String value = request.getHeaders().getFirst("X-Forwarded-For"); return StringUtils.isBlank(value) ? String.valueOf(request.getRemoteAddress()) : value.split(",")[0].trim(); }
}