diff --git a/docker-compose.yml b/docker-compose.yml index a8f4b112..de478726 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,14 +36,14 @@ services: - 3891:389 gateway: - image: georchestra/gateway:latest + image: georchestra/gateway:22.1-SNAPSHOT depends_on: - ldap - database volumes: - datadir:/etc/georchestra environment: - - JAVA_TOOL_OPTIONS=-Dgeorchestra.datadir=/etc/georchestra -Dspring.profiles.active=docker -Xmx512M -Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=0.0.0.0:5005 + - JAVA_TOOL_OPTIONS=-Dgeorchestra.datadir=/etc/georchestra -Dspring.profiles.active=docker -Xmx512M -Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=0.0.0.0:5005 -Dreactor.netty.http.server.accessLogEnabled=true restart: always ports: - 8080:8080 @@ -90,4 +90,3 @@ services: restart: always ports: - 10007:8080 - diff --git a/gateway/pom.xml b/gateway/pom.xml index 53777875..df958ce6 100644 --- a/gateway/pom.xml +++ b/gateway/pom.xml @@ -32,6 +32,23 @@ org.springframework.boot spring-boot-starter-validation + + + spring-boot-starter-logging + + org.springframework.boot + + + + + io.micrometer + context-propagation + 1.0.4 + + + org.eclipse.jetty + jetty-reactive-httpclient org.springframework.boot @@ -76,7 +93,7 @@ - org.springframework.boot spring-boot-configuration-processor diff --git a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/app/FiltersAutoConfiguration.java b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/app/FiltersAutoConfiguration.java index 4c2d19b4..05528a7a 100644 --- a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/app/FiltersAutoConfiguration.java +++ b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/app/FiltersAutoConfiguration.java @@ -18,6 +18,9 @@ */ package org.georchestra.gateway.autoconfigure.app; +import org.georchestra.gateway.filter.global.AccessLogFilter; +import org.georchestra.gateway.filter.global.AccessLogFilterConfig; +import org.georchestra.gateway.filter.global.RequestIdGlobalFilter; import org.georchestra.gateway.filter.global.ResolveTargetGlobalFilter; import org.georchestra.gateway.filter.headers.HeaderFiltersConfiguration; import org.georchestra.gateway.model.GatewayConfigProperties; @@ -36,7 +39,7 @@ @AutoConfiguration @AutoConfigureBefore(GatewayAutoConfiguration.class) @Import(HeaderFiltersConfiguration.class) -@EnableConfigurationProperties(GatewayConfigProperties.class) +@EnableConfigurationProperties({ GatewayConfigProperties.class, AccessLogFilterConfig.class }) public class FiltersAutoConfiguration { /** @@ -64,4 +67,14 @@ public class FiltersAutoConfiguration { public @Bean StripBasePathGatewayFilterFactory stripBasePathGatewayFilterFactory() { return new StripBasePathGatewayFilterFactory(); } + + @Bean + RequestIdGlobalFilter requestIdGlobalFilter() { + return new RequestIdGlobalFilter(); + } + + @Bean + AccessLogFilter accessLogGlobalFilter(AccessLogFilterConfig config) { + return new AccessLogFilter(config); + } } diff --git a/gateway/src/main/java/org/georchestra/gateway/filter/global/AccessLogFilter.java b/gateway/src/main/java/org/georchestra/gateway/filter/global/AccessLogFilter.java new file mode 100644 index 00000000..bc336ede --- /dev/null +++ b/gateway/src/main/java/org/georchestra/gateway/filter/global/AccessLogFilter.java @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2022 by the geOrchestra PSC + * + * This file is part of geOrchestra. + * + * geOrchestra is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * + * geOrchestra is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * geOrchestra. If not, see . + */ + +package org.georchestra.gateway.filter.global; + +import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR; +import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR; + +import java.net.InetSocketAddress; +import java.net.URI; +import java.security.Principal; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.georchestra.gateway.model.GeorchestraUsers; +import org.georchestra.security.model.GeorchestraUser; +import org.slf4j.MDC; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.cloud.gateway.route.Route; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.web.server.ServerWebExchange; + +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import reactor.core.publisher.Mono; + +@RequiredArgsConstructor +@Slf4j(topic = "org.georchestra.gateway.accesslog") +public class AccessLogFilter implements GlobalFilter { + + private final @NonNull AccessLogFilterConfig config; + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + if (config.matches(exchange.getRequest().getURI())) { + exchange.getResponse().beforeCommit(() -> { + return log(exchange); + }); + } + + return chain.filter(exchange); + } + + private static final AnonymousAuthenticationToken ANNON = new AnonymousAuthenticationToken("anonymous", "anonymous", + List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))); + + /** + * @param exchange + */ + private Mono log(ServerWebExchange exchange) { + if (!log.isInfoEnabled()) + return Mono.empty(); + + return exchange.getPrincipal().switchIfEmpty(Mono.just(ANNON)).doOnNext(p -> { + doLog(p, exchange); + }).then(); + } + + private void doLog(Principal principal, ServerWebExchange exchange) { + ServerHttpRequest request = exchange.getRequest(); + ServerHttpResponse response = exchange.getResponse(); + URI uri = request.getURI(); + + Route route = exchange.getAttribute(GATEWAY_ROUTE_ATTR); + URI routeUri = exchange.getAttribute(GATEWAY_REQUEST_URL_ATTR); + + String requestId = request.getHeaders().getFirst(RequestIdGlobalFilter.REQUEST_ID_HEADER); + + InetSocketAddress addr = request.getRemoteAddress(); + String remoteAddress = addr == null ? "unknown" : addr.toString(); + + mdcPut("route-id", route.getId()); + mdcPut("route-uri", String.valueOf(routeUri)); + mdcPut(RequestIdGlobalFilter.REQUEST_ID_HEADER, requestId); + mdcPut("remoteAddress", remoteAddress); + + Optional user = GeorchestraUsers.resolve(exchange); + user.ifPresentOrElse(gsu -> { + mdcPut("auth-user", gsu.getUsername()); + mdcPut("auth-roles", gsu.getRoles().stream().collect(Collectors.joining(", "))); + }, () -> MDC.put("auth-user", "anonymous")); + + if (principal instanceof Authentication && principal != ANNON) { + String roles = ((Authentication) principal).getAuthorities().stream().map(GrantedAuthority::getAuthority) + .collect(Collectors.joining(", ")); + + mdcPut("principal-name", principal.getName()); + mdcPut("principal-authorities", roles); + } + + log.info("{} {} {} ", request.getMethodValue(), response.getRawStatusCode(), uri); + mdcClear(); + } + + void mdcPut(String key, String value) { + MDC.put(key, value); + } + + void mdcClear() { + MDC.clear(); + } +} diff --git a/gateway/src/main/java/org/georchestra/gateway/filter/global/AccessLogFilterConfig.java b/gateway/src/main/java/org/georchestra/gateway/filter/global/AccessLogFilterConfig.java new file mode 100644 index 00000000..1839e7b6 --- /dev/null +++ b/gateway/src/main/java/org/georchestra/gateway/filter/global/AccessLogFilterConfig.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2022 by the geOrchestra PSC + * + * This file is part of geOrchestra. + * + * geOrchestra is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * + * geOrchestra is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * geOrchestra. If not, see . + */ + +package org.georchestra.gateway.filter.global; + +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import lombok.Data; + +/** + * Configuration to set white/black list over the request URL to determine if + * the access log filter will log an entry for it. + */ +@Data +@ConfigurationProperties(prefix = "georchestra.gateway.accesslog") +public class AccessLogFilterConfig { + + /** + * Enable/disable the access log filter + */ + private boolean enabled = true; + + /** + * A list of java regular expressions applied to the request URL to include them + * from logging. + */ + List include = new ArrayList<>(); + + /** + * A list of java regular expressions applied to the request URL to exclude them + * from logging. A request URL must pass all the include filters before being + * tested for exclusion. Useful to avoid flooding the logs with frequent + * non-important requests such as static resources (i.e. static images, etc). + */ + List exclude = new ArrayList<>(); + + /** + * @param uri the origin URL (e.g. https://my.domain.com/geoserver/web/) + * @return {@code true} if disabled or an access log entry shall be logged for + * this request + */ + public boolean matches(URI uri) { + if (!enabled || (include.isEmpty() && exclude.isEmpty())) + return true; + + String url = uri.toString(); + return matches(url, include, true) && !matches(url, exclude, false); + } + + private boolean matches(String url, List patterns, boolean fallbackIfEmpty) { + return (patterns == null || patterns.isEmpty()) ? fallbackIfEmpty + : patterns.stream().anyMatch(pattern -> pattern.matcher(url).matches()); + } +} diff --git a/gateway/src/main/java/org/georchestra/gateway/filter/global/RequestIdGlobalFilter.java b/gateway/src/main/java/org/georchestra/gateway/filter/global/RequestIdGlobalFilter.java new file mode 100644 index 00000000..0ce186b8 --- /dev/null +++ b/gateway/src/main/java/org/georchestra/gateway/filter/global/RequestIdGlobalFilter.java @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2022 by the geOrchestra PSC + * + * This file is part of geOrchestra. + * + * geOrchestra is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * + * geOrchestra is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * geOrchestra. If not, see . + */ +package org.georchestra.gateway.filter.global; + +import org.apache.commons.lang3.RandomStringUtils; +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.http.server.reactive.ServerHttpResponse; +import org.springframework.web.server.ServerWebExchange; + +import reactor.core.publisher.Mono; + +/** + * Makes sure both the request and response have the same + * {@literal X-Request-ID} header. + *

+ * A new value is created for the header if not provided by the client. + */ +public class RequestIdGlobalFilter implements GlobalFilter, Ordered { + + static final String REQUEST_ID_HEADER = "X-Request-ID"; + + /** + * @return {@link Ordered#HIGHEST_PRECEDENCE} + */ + public @Override int getOrder() { + return Ordered.HIGHEST_PRECEDENCE; + } + + /** + * Makes sure both the request and response have the same + * {@literal X-Request-ID} header. + *

+ * A new value is created for the header if not provided by the client. + */ + public @Override Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + + final String requestId; + final ServerHttpRequest request; + String providedRequestId = exchange.getRequest().getHeaders().getFirst(REQUEST_ID_HEADER); + if (null == providedRequestId) { + requestId = RandomStringUtils.randomNumeric(16); + request = exchange.getRequest().mutate().header(REQUEST_ID_HEADER, requestId).build(); + exchange = exchange.mutate().request(request).build(); + } else { + requestId = providedRequestId; + request = exchange.getRequest(); + } + + ServerHttpResponse response = exchange.getResponse(); + response.beforeCommit(() -> { + response.getHeaders().set(REQUEST_ID_HEADER, requestId); + return Mono.empty(); + }); + + return chain.filter(exchange); + } + +} \ No newline at end of file diff --git a/gateway/src/main/resources/application.yml b/gateway/src/main/resources/application.yml index ead19e59..eeb5251e 100644 --- a/gateway/src/main/resources/application.yml +++ b/gateway/src/main/resources/application.yml @@ -148,12 +148,15 @@ management: logging: level: root: warn + '[reactor.netty.http ]': warn + '[reactor.netty.http.server.logging.AccessLog]': warn + '[reactor.netty.http.client]': warn '[org.springframework]': info '[org.springframework.cloud.gateway]': info '[org.springframework.security]': info '[org.springframework.security.oauth2]': debug - '[reactor.netty.http ]': debug '[org.georchestra.gateway]': info + '[org.georchestra.gateway.accesslog]': info '[org.georchestra.gateway.filter.headers]': debug '[org.georchestra.gateway.config.security]': debug '[org.georchestra.gateway.config.security.accessrules]': debug diff --git a/gateway/src/main/resources/log4j2-spring.xml b/gateway/src/main/resources/log4j2-spring.xml new file mode 100644 index 00000000..7cab4223 --- /dev/null +++ b/gateway/src/main/resources/log4j2-spring.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/gateway/src/test/java/org/georchestra/gateway/filter/global/AccessLogFilterConfigTest.java b/gateway/src/test/java/org/georchestra/gateway/filter/global/AccessLogFilterConfigTest.java new file mode 100644 index 00000000..060fc169 --- /dev/null +++ b/gateway/src/test/java/org/georchestra/gateway/filter/global/AccessLogFilterConfigTest.java @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2022 by the geOrchestra PSC + * + * This file is part of geOrchestra. + * + * geOrchestra is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * + * geOrchestra is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * geOrchestra. If not, see . + */ + +package org.georchestra.gateway.filter.global; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.URI; +import java.util.regex.Pattern; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + */ +class AccessLogFilterConfigTest { + + private AccessLogFilterConfig config; + + @BeforeEach + void setUp() { + config = new AccessLogFilterConfig(); + } + + @Test + void testDisabled() { + config.setEnabled(false); + assertThat(config.matches(URI.create("https://my.host"))).isTrue(); + + config.getExclude().add(Pattern.compile(".*")); + assertThat(config.matches(URI.create("https://my.host"))).isTrue(); + } + + @Test + void testIncludes() { + config.getInclude().add(Pattern.compile(".*/ows/.*GetFeature.*")); + config.getInclude().add(Pattern.compile(".*/ows/.*GetMap.*")); + + assertThat(config.matches(URI.create("https://my.host/ows/?request=GetFeature&typeName=test"))).isTrue(); + assertThat(config.matches(URI.create("https://my.host/ows/?request=GetMap&typeName=test"))).isTrue(); + assertThat(config.matches(URI.create("https://my.host/some/path/img1.svg"))).isFalse(); + } + + @Test + void testExcludes() { + config.getExclude().add(Pattern.compile(".*\\.png")); + config.getExclude().add(Pattern.compile(".*\\.jpeg")); + + assertThat(config.matches(URI.create("https://my.host/some/path/img1.png"))).isFalse(); + assertThat(config.matches(URI.create("https://my.host/some/path/img1.svg"))).isTrue(); + + assertThat(config.matches(URI.create("https://my.host/some/path/img2.jpeg"))).isFalse(); + assertThat(config.matches(URI.create("https://my.host/some/path/img2.svg"))).isTrue(); + } + + @Test + void testIncludesAndExcludes() { + config.getInclude().add(Pattern.compile(".*/ows/.*")); + config.getExclude().add(Pattern.compile(".*/ows/.*GetMap.*")); + + assertThat(config.matches(URI.create("https://my.host/ows/?request=GetFeature&typeName=test"))).isTrue(); + assertThat(config.matches(URI.create("https://my.host/ows/?request=GetMap&typeName=test"))).isFalse(); + } +}