Skip to content

Commit 2140f3b

Browse files
authored
Merge pull request #49 from yyyCode/feat/gateway-access-log
feat(gateway): 网关增加访问日志,记录来源 IP / 接口 / 状态码 / 耗时
2 parents 5b87e87 + 120df53 commit 2140f3b

6 files changed

Lines changed: 322 additions & 17 deletions

File tree

‎OpenBlog-gateway/src/main/java/com/yqz/openblog/gateway/config/GatewayProperties.java‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ public class GatewayProperties {
1111

1212
private Auth auth = new Auth();
1313
private RateLimit rateLimit = new RateLimit();
14+
private AccessLog accessLog = new AccessLog();
1415

1516
public Auth getAuth() {
1617
return auth;
@@ -28,6 +29,27 @@ public void setRateLimit(RateLimit rateLimit) {
2829
this.rateLimit = rateLimit;
2930
}
3031

32+
public AccessLog getAccessLog() {
33+
return accessLog;
34+
}
35+
36+
public void setAccessLog(AccessLog accessLog) {
37+
this.accessLog = accessLog;
38+
}
39+
40+
/** 访问日志开关(openblog.gateway.access-log.*)。 */
41+
public static class AccessLog {
42+
private boolean enabled = true;
43+
44+
public boolean isEnabled() {
45+
return enabled;
46+
}
47+
48+
public void setEnabled(boolean enabled) {
49+
this.enabled = enabled;
50+
}
51+
}
52+
3153
public static class Auth {
3254
private List<String> skipPaths = new ArrayList<>();
3355

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package com.yqz.openblog.gateway.filter;
2+
3+
import com.yqz.openblog.gateway.config.GatewayProperties;
4+
import org.slf4j.Logger;
5+
import org.slf4j.LoggerFactory;
6+
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
7+
import org.springframework.cloud.gateway.filter.GlobalFilter;
8+
import org.springframework.core.Ordered;
9+
import org.springframework.http.HttpMethod;
10+
import org.springframework.http.HttpStatusCode;
11+
import org.springframework.stereotype.Component;
12+
import org.springframework.web.server.ServerWebExchange;
13+
import reactor.core.publisher.Mono;
14+
import reactor.core.publisher.SignalType;
15+
16+
import java.util.concurrent.atomic.AtomicReference;
17+
18+
/**
19+
* 访问日志(order=-4,最外层):请求结束后打印来源 IP、方法、路径、状态码、耗时与 traceId。
20+
* <p>
21+
* 排在 {@link TraceIdFilter}(-3)之外,故记录发生在整条链(限流 / JWT / 下游调用)全部结束之后,
22+
* 状态码与耗时都是最终值。traceId 在此刻已写入 exchange 属性,直接取出显式打印——
23+
* 日志 pattern 里的 {@code %X{traceId}} 依赖 MDC,而 WebFlux 下 MDC 不跨线程,网关这里取不到值。
24+
* <p>
25+
* 只记录 path、<b>不记录 query string</b>:部分端点的查询参数可能带一次性令牌/邮箱等敏感值,
26+
* 一旦落盘难以回收。确需排查时再显式放开,并自行做脱敏。
27+
* <p>
28+
* 关闭开关:{@code openblog.gateway.access-log.enabled=false}。
29+
*/
30+
@Component
31+
public class AccessLogFilter implements GlobalFilter, Ordered {
32+
33+
private static final Logger log = LoggerFactory.getLogger(AccessLogFilter.class);
34+
35+
private final GatewayProperties props;
36+
37+
public AccessLogFilter(GatewayProperties props) {
38+
this.props = props;
39+
}
40+
41+
@Override
42+
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
43+
if (!props.getAccessLog().isEnabled()) {
44+
return chain.filter(exchange);
45+
}
46+
long startNanos = System.nanoTime();
47+
// doFinally 只给终止信号、拿不到异常对象,用 holder 记下异常供统一出口打印
48+
AtomicReference<Throwable> error = new AtomicReference<>();
49+
50+
return chain.filter(exchange)
51+
.doOnError(error::set)
52+
.doFinally(signal -> logAccess(exchange, startNanos, signal, error.get()));
53+
}
54+
55+
private void logAccess(ServerWebExchange exchange, long startNanos, SignalType signal, Throwable error) {
56+
String ip = ClientIpResolver.resolve(exchange);
57+
HttpMethod method = exchange.getRequest().getMethod();
58+
String path = exchange.getRequest().getPath().value();
59+
long costMs = (System.nanoTime() - startNanos) / 1_000_000;
60+
String traceId = TraceIdFilter.traceIdOf(exchange);
61+
62+
if (signal == SignalType.ON_ERROR) {
63+
// 此时响应码可能尚未由 GatewayErrorHandler 写入(异常处理器在过滤器链之外),故只报异常本身
64+
log.warn("gateway access failed: ip={} method={} path={} cost={}ms traceId={} err={}",
65+
ip, method, path, costMs, traceId, error != null ? error.toString() : signal);
66+
return;
67+
}
68+
if (signal == SignalType.CANCEL) {
69+
// 客户端提前断开:前端切路由 / 关页面都会触发,属日常流量而非故障,
70+
// 故记 INFO。若记 WARN,正常浏览就会持续刷 WARN,把真正的 ON_ERROR 淹掉。
71+
log.info("gateway access cancelled: ip={} method={} path={} cost={}ms traceId={}",
72+
ip, method, path, costMs, traceId);
73+
return;
74+
}
75+
HttpStatusCode status = exchange.getResponse().getStatusCode();
76+
log.info("gateway access: ip={} method={} path={} status={} cost={}ms traceId={}",
77+
ip, method, path, status != null ? status.value() : "-", costMs, traceId);
78+
}
79+
80+
@Override
81+
public int getOrder() {
82+
return -4;
83+
}
84+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package com.yqz.openblog.gateway.filter;
2+
3+
import org.springframework.web.server.ServerWebExchange;
4+
5+
/**
6+
* 客户端真实 IP 解析(网关各过滤器共用)。
7+
* <p>
8+
* 抽成独立工具类而非各自实现:IP 是限流分桶与访问审计的共同口径,两份实现一旦漂移,
9+
* 会出现「限流按 A 计、日志按 B 记」的排查黑洞。
10+
* <p>
11+
* 优先级:X-Real-IP → X-Forwarded-For 首跳 → TCP 对端地址 → "unknown"。
12+
* nginx 用 {@code $remote_addr} 覆写 X-Real-IP(不可伪造)故优先;
13+
* X-Forwarded-For 首跳由客户端自报、可伪造,仅作回退。
14+
*/
15+
final class ClientIpResolver {
16+
17+
private ClientIpResolver() {
18+
}
19+
20+
static String resolve(ServerWebExchange exchange) {
21+
// 一律 trim:该返回值既是审计口径也是限流分桶 key,带空格的头会让 "1.2.3.4" 与 "1.2.3.4 "
22+
// 落进不同桶,等于每请求换个后缀就能绕过 IP 限流。
23+
String realIp = exchange.getRequest().getHeaders().getFirst("X-Real-IP");
24+
if (realIp != null && !realIp.isBlank()) {
25+
return realIp.trim();
26+
}
27+
String xff = exchange.getRequest().getHeaders().getFirst("X-Forwarded-For");
28+
if (xff != null && !xff.isBlank()) {
29+
// 用 indexOf/substring 而非 split:",".split(",") 返回长度 0 的数组(Java 丢弃尾随空串),
30+
// 取 [0] 抛 AIOOBE。该异常在 AccessLogFilter 的 doFinally 回调里会被 Reactor 吞掉,
31+
// 于是请求方只要发一个畸形头就能让自己的访问日志整条消失——审计日志不能有这种后门。
32+
int comma = xff.indexOf(',');
33+
String first = (comma >= 0 ? xff.substring(0, comma) : xff).trim();
34+
if (!first.isEmpty()) {
35+
return first;
36+
}
37+
}
38+
if (exchange.getRequest().getRemoteAddress() != null) {
39+
return exchange.getRequest().getRemoteAddress().getAddress().getHostAddress();
40+
}
41+
return "unknown";
42+
}
43+
}

‎OpenBlog-gateway/src/main/java/com/yqz/openblog/gateway/filter/RateLimitFilter.java‎

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
7676
private Mono<Void> applyRule(ServerWebExchange exchange, GatewayFilterChain chain,
7777
GatewayProperties.Rule rule) {
7878
return Mono.fromCallable(() -> {
79-
String ip = clientIp(exchange);
79+
String ip = ClientIpResolver.resolve(exchange);
8080
// 第 1 层防构造预检:指纹是自报 header,脚本可每请求伪造新指纹。同一 IP 窗口内新指纹超预算
8181
// → 判定轮换,直接拒绝(不计数、guard 内不落库)。仅 FP_IP 规则需要携带指纹信号。
8282
String fp = rule.getScope() == GatewayProperties.Scope.FP_IP
@@ -151,22 +151,6 @@ private String bearerToken(ServerWebExchange exchange) {
151151
return (auth != null && auth.startsWith("Bearer ")) ? auth.substring(7) : null;
152152
}
153153

154-
private String clientIp(ServerWebExchange exchange) {
155-
// nginx 用 $remote_addr 覆写 X-Real-IP(不可伪造),优先使用;X-Forwarded-For 首跳是客户端可控的,仅作回退。
156-
String realIp = exchange.getRequest().getHeaders().getFirst("X-Real-IP");
157-
if (realIp != null && !realIp.isBlank()) {
158-
return realIp;
159-
}
160-
String xff = exchange.getRequest().getHeaders().getFirst("X-Forwarded-For");
161-
if (xff != null && !xff.isBlank()) {
162-
return xff.split(",")[0].trim();
163-
}
164-
if (exchange.getRequest().getRemoteAddress() != null) {
165-
return exchange.getRequest().getRemoteAddress().getAddress().getHostAddress();
166-
}
167-
return "unknown";
168-
}
169-
170154
@Override
171155
public int getOrder() {
172156
return -1;

‎OpenBlog-gateway/src/main/resources/application.yaml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ openblog:
5050
secret: ${OPENBLOG_DEVICE_TOKEN_SECRET:}
5151
ttl-seconds: 604800
5252
gateway:
53+
access-log:
54+
# 每请求一条 INFO 访问日志:来源 IP / 方法 / 路径 / 状态码 / 耗时 / traceId。
55+
# 只记 path 不记 query(防敏感参数落盘)。日志量敏感或压测时可置 false 关闭。
56+
enabled: true
5357
auth:
5458
skip-paths:
5559
- /api/v1/auth/login
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
package com.yqz.openblog.gateway.filter;
2+
3+
import ch.qos.logback.classic.Level;
4+
import ch.qos.logback.classic.Logger;
5+
import ch.qos.logback.classic.spi.ILoggingEvent;
6+
import ch.qos.logback.core.read.ListAppender;
7+
import com.yqz.openblog.gateway.config.GatewayProperties;
8+
import org.junit.jupiter.api.AfterEach;
9+
import org.junit.jupiter.api.BeforeEach;
10+
import org.junit.jupiter.api.Test;
11+
import org.slf4j.LoggerFactory;
12+
import org.springframework.http.HttpStatus;
13+
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
14+
import org.springframework.mock.web.server.MockServerWebExchange;
15+
import org.springframework.web.server.ServerWebExchange;
16+
import reactor.core.Disposable;
17+
import reactor.core.publisher.Mono;
18+
19+
import java.util.concurrent.atomic.AtomicBoolean;
20+
21+
import static org.assertj.core.api.Assertions.assertThat;
22+
23+
class AccessLogFilterTest {
24+
25+
private final GatewayProperties props = new GatewayProperties();
26+
private final AccessLogFilter filter = new AccessLogFilter(props);
27+
28+
private Logger filterLogger;
29+
private ListAppender<ILoggingEvent> appender;
30+
31+
@BeforeEach
32+
void attachAppender() {
33+
filterLogger = (Logger) LoggerFactory.getLogger(AccessLogFilter.class);
34+
appender = new ListAppender<>();
35+
appender.start();
36+
filterLogger.addAppender(appender);
37+
}
38+
39+
@AfterEach
40+
void detachAppender() {
41+
filterLogger.detachAppender(appender);
42+
}
43+
44+
@Test
45+
void logsIpMethodPathStatusAndTraceId() {
46+
ServerWebExchange exchange = MockServerWebExchange.from(
47+
MockServerHttpRequest.get("/api/v1/articles?keyword=spring")
48+
.header("X-Real-IP", "203.0.113.7")
49+
.build());
50+
// 单测直接调本过滤器,链上没有 TraceIdFilter:手动写入它本该留下的属性。
51+
// 生产环境两者都是 GlobalFilter,TraceIdFilter(-3) 先于本过滤器(-4) 收尾,属性必然已就位。
52+
exchange.getAttributes().put(TraceIdFilter.TRACE_ID_ATTR, "t-abc");
53+
exchange.getResponse().setStatusCode(HttpStatus.OK);
54+
55+
filter.filter(exchange, ex -> Mono.empty()).block();
56+
57+
ILoggingEvent event = onlyEvent();
58+
assertThat(event.getLevel()).isEqualTo(Level.INFO);
59+
assertThat(event.getFormattedMessage())
60+
.contains("ip=203.0.113.7")
61+
.contains("method=GET")
62+
.contains("path=/api/v1/articles")
63+
.contains("status=200")
64+
.contains("traceId=t-abc")
65+
.contains("cost=");
66+
// query string 刻意不入日志(防一次性令牌/邮箱等敏感参数落盘)
67+
assertThat(event.getFormattedMessage()).doesNotContain("keyword=spring");
68+
}
69+
70+
@Test
71+
void prefersRealIpOverForwardedFor() {
72+
// 与限流共用 ClientIpResolver:nginx 覆写的 X-Real-IP 优先于客户端可伪造的 XFF
73+
ServerWebExchange exchange = MockServerWebExchange.from(
74+
MockServerHttpRequest.get("/api/v1/x")
75+
.header("X-Forwarded-For", "1.2.3.4")
76+
.header("X-Real-IP", "9.9.9.9")
77+
.build());
78+
79+
filter.filter(exchange, ex -> Mono.empty()).block();
80+
81+
assertThat(onlyEvent().getFormattedMessage()).contains("ip=9.9.9.9");
82+
}
83+
84+
@Test
85+
void logsFailureAsWarningWhenChainErrors() {
86+
ServerWebExchange exchange = MockServerWebExchange.from(
87+
MockServerHttpRequest.get("/api/v1/x").header("X-Real-IP", "1.2.3.4").build());
88+
89+
filter.filter(exchange, ex -> Mono.error(new IllegalStateException("boom")))
90+
.onErrorResume(e -> Mono.empty())
91+
.block();
92+
93+
ILoggingEvent event = onlyEvent();
94+
assertThat(event.getLevel()).isEqualTo(Level.WARN);
95+
assertThat(event.getFormattedMessage())
96+
.contains("gateway access failed")
97+
.contains("ip=1.2.3.4")
98+
.contains("boom");
99+
}
100+
101+
@Test
102+
void logsCancelledRequestAtInfo() {
103+
ServerWebExchange exchange = MockServerWebExchange.from(
104+
MockServerHttpRequest.get("/api/v1/x").header("X-Real-IP", "1.2.3.4").build());
105+
106+
// 模拟客户端提前断开:订阅后立即 dispose,链路以 ON_CANCEL 终止
107+
Disposable subscription = filter.filter(exchange, ex -> Mono.never()).subscribe();
108+
subscription.dispose();
109+
110+
ILoggingEvent event = onlyEvent();
111+
// 取消属日常流量(前端切路由/关页面),记 INFO 以免刷屏淹没真正的失败
112+
assertThat(event.getLevel()).isEqualTo(Level.INFO);
113+
assertThat(event.getFormattedMessage())
114+
.contains("gateway access cancelled")
115+
.contains("ip=1.2.3.4");
116+
}
117+
118+
@Test
119+
void stillLogsWhenForwardedForIsCommaOnly() {
120+
// 回归防线:",".split(",") 得到长度 0 的数组,老写法取 [0] 会抛 AIOOBE;
121+
// 该异常在 doFinally 里被 Reactor 吞掉,请求方用畸形头就让自己的访问日志整条消失。
122+
ServerWebExchange exchange = MockServerWebExchange.from(
123+
MockServerHttpRequest.get("/api/v1/x").header("X-Forwarded-For", ",").build());
124+
125+
filter.filter(exchange, ex -> Mono.empty()).block();
126+
127+
assertThat(onlyEvent().getFormattedMessage()).contains("ip=");
128+
}
129+
130+
@Test
131+
void trimsWhitespaceSoRateLimitBucketsCannotBeRotated() {
132+
// 带空格的头若原样入 key,"1.2.3.4 " 与 "1.2.3.4" 会落进不同限流桶
133+
ServerWebExchange exchange = MockServerWebExchange.from(
134+
MockServerHttpRequest.get("/api/v1/x").header("X-Real-IP", " 1.2.3.4 ").build());
135+
136+
filter.filter(exchange, ex -> Mono.empty()).block();
137+
138+
// 断言到下一个字段的边界,才能证明首尾空格真的被去掉了
139+
assertThat(onlyEvent().getFormattedMessage()).contains("ip=1.2.3.4 method=");
140+
}
141+
142+
@Test
143+
void forwardsWithoutLoggingWhenDisabled() {
144+
props.getAccessLog().setEnabled(false);
145+
ServerWebExchange exchange = MockServerWebExchange.from(
146+
MockServerHttpRequest.get("/api/v1/x").build());
147+
AtomicBoolean forwarded = new AtomicBoolean(false);
148+
149+
filter.filter(exchange, ex -> {
150+
forwarded.set(true);
151+
return Mono.empty();
152+
}).block();
153+
154+
assertThat(forwarded).isTrue();
155+
assertThat(appender.list).isEmpty();
156+
}
157+
158+
@Test
159+
void runsOutsideTraceIdFilterSoTraceIdIsAvailable() {
160+
// 顺序是契约的一部分:必须在 TraceIdFilter 外层,否则记录时 traceId 还没写入 exchange
161+
assertThat(filter.getOrder()).isLessThan(new TraceIdFilter().getOrder());
162+
}
163+
164+
private ILoggingEvent onlyEvent() {
165+
assertThat(appender.list).hasSize(1);
166+
return appender.list.get(0);
167+
}
168+
}

0 commit comments

Comments
 (0)