diff --git a/src/apps/infrastructure/gateway/src/main/java/org/geoserver/cloud/autoconfigure/gateway/GatewayApplicationAutoconfiguration.java b/src/apps/infrastructure/gateway/src/main/java/org/geoserver/cloud/autoconfigure/gateway/GatewayApplicationAutoconfiguration.java
index 81b9cd7ec..81419c6bb 100644
--- a/src/apps/infrastructure/gateway/src/main/java/org/geoserver/cloud/autoconfigure/gateway/GatewayApplicationAutoconfiguration.java
+++ b/src/apps/infrastructure/gateway/src/main/java/org/geoserver/cloud/autoconfigure/gateway/GatewayApplicationAutoconfiguration.java
@@ -4,19 +4,14 @@
*/
package org.geoserver.cloud.autoconfigure.gateway;
-import lombok.extern.slf4j.Slf4j;
-
-import org.geoserver.cloud.gateway.filter.GatewaySharedAuhenticationGlobalFilter;
import org.geoserver.cloud.gateway.filter.RouteProfileGatewayFilterFactory;
import org.geoserver.cloud.gateway.filter.StripBasePathGatewayFilterFactory;
import org.geoserver.cloud.gateway.predicate.RegExpQueryRoutePredicateFactory;
import org.springframework.boot.autoconfigure.AutoConfiguration;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.core.env.Environment;
@AutoConfiguration
-@Slf4j
public class GatewayApplicationAutoconfiguration {
/**
@@ -66,14 +61,4 @@ RouteProfileGatewayFilterFactory routeProfileGatewayFilterFactory(Environment en
StripBasePathGatewayFilterFactory stripBasePathGatewayFilterFactory() {
return new StripBasePathGatewayFilterFactory();
}
-
- @Bean
- @ConditionalOnProperty(
- name = "geoserver.security.gateway-shared-auth.enabled",
- havingValue = "true",
- matchIfMissing = true)
- GatewaySharedAuhenticationGlobalFilter gatewaySharedAuhenticationGlobalFilter() {
- log.info("gateway-shared-auth is enabled");
- return new GatewaySharedAuhenticationGlobalFilter();
- }
}
diff --git a/src/apps/infrastructure/gateway/src/main/java/org/geoserver/cloud/autoconfigure/gateway/GatewaySharedAuthAutoConfiguration.java b/src/apps/infrastructure/gateway/src/main/java/org/geoserver/cloud/autoconfigure/gateway/GatewaySharedAuthAutoConfiguration.java
new file mode 100644
index 000000000..a9b69dfc7
--- /dev/null
+++ b/src/apps/infrastructure/gateway/src/main/java/org/geoserver/cloud/autoconfigure/gateway/GatewaySharedAuthAutoConfiguration.java
@@ -0,0 +1,39 @@
+/*
+ * (c) 2024 Open Source Geospatial Foundation - all rights reserved This code is licensed under the
+ * GPL 2.0 license, available at the root application directory.
+ */
+package org.geoserver.cloud.autoconfigure.gateway;
+
+import lombok.extern.slf4j.Slf4j;
+
+import org.geoserver.cloud.security.gateway.sharedauth.GatewaySharedAuhenticationPostFilter;
+import org.geoserver.cloud.security.gateway.sharedauth.GatewaySharedAuhenticationPreFilter;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Bean;
+
+import javax.annotation.PostConstruct;
+
+@AutoConfiguration
+@ConditionalOnProperty(
+ name = "geoserver.security.gateway-shared-auth.enabled",
+ havingValue = "true",
+ matchIfMissing = true)
+@Slf4j
+public class GatewaySharedAuthAutoConfiguration {
+
+ @PostConstruct
+ void logEnabled() {
+ log.info("gateway-shared-auth is enabled");
+ }
+
+ @Bean
+ GatewaySharedAuhenticationPreFilter gatewaySharedAuhenticationGlobalPreFilter() {
+ return new GatewaySharedAuhenticationPreFilter();
+ }
+
+ @Bean
+ GatewaySharedAuhenticationPostFilter gatewaySharedAuhenticationGlobalPostFilter() {
+ return new GatewaySharedAuhenticationPostFilter();
+ }
+}
diff --git a/src/apps/infrastructure/gateway/src/main/java/org/geoserver/cloud/gateway/filter/GatewaySharedAuhenticationGlobalFilter.java b/src/apps/infrastructure/gateway/src/main/java/org/geoserver/cloud/gateway/filter/GatewaySharedAuhenticationGlobalFilter.java
deleted file mode 100644
index 0c77e344e..000000000
--- a/src/apps/infrastructure/gateway/src/main/java/org/geoserver/cloud/gateway/filter/GatewaySharedAuhenticationGlobalFilter.java
+++ /dev/null
@@ -1,163 +0,0 @@
-/*
- * (c) 2024 Open Source Geospatial Foundation - all rights reserved This code is licensed under the
- * GPL 2.0 license, available at the root application directory.
- */
-package org.geoserver.cloud.gateway.filter;
-
-import org.springframework.boot.web.servlet.server.Session;
-import org.springframework.cloud.gateway.filter.GatewayFilterChain;
-import org.springframework.cloud.gateway.filter.GlobalFilter;
-import org.springframework.http.HttpHeaders;
-import org.springframework.util.StringUtils;
-import org.springframework.web.server.ServerWebExchange;
-import org.springframework.web.server.WebSession;
-
-import reactor.core.publisher.Mono;
-
-import java.util.List;
-import java.util.Optional;
-
-/**
- * {@link GlobalFilter} to enable sharing the webui form-based authentication object with the other
- * services.
- *
- * When a user is logged in through the regular web ui's authentication form, the {@link
- * Authentication} object is held in the web ui's {@link Session}. Hence, further requests to
- * stateless services, as they're on separate containers, don't share the webui session, and hence
- * are executed as anonymous.
- *
- *
This {@link GlobalFilter} enables a mechanism by which the authenticated user name and roles
- * can be shared with the stateless services through request and response headrers, using the
- * geoserver cloud gateway as the man in the middle.
- *
- *
The webui container will send a couple response headers with the authenticated user name and
- * roles. The gateway will store them in its own session, and forward them to all services as
- * request headers. The stateless services will intercept these request headers and impersonate the
- * authenticated user as a {@link PreAuthenticatedAuthenticationToken}.
- *
- *
At the same time, the gateway will take care of removing the webui response headers from the
- * responses sent to the clients, and from incoming client requests.
- *
- * @since 1.9
- */
-public class GatewaySharedAuhenticationGlobalFilter implements GlobalFilter {
-
- static final String X_GSCLOUD_USERNAME = "x-gsc-username";
- static final String X_GSCLOUD_ROLES = "x-gsc-roles";
-
- @Override
- public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
- // first, remove any incoming header to prevent impersonation
- exchange = removeRequestHeaders(exchange);
-
- return addHeadersFromSession(exchange)
- .flatMap(mutatedExchange -> proceed(mutatedExchange, chain))
- .flatMap(this::saveHeadersInSession)
- .flatMap(this::removeResponseHeaders);
- }
-
- private Mono proceed(ServerWebExchange exchange, GatewayFilterChain chain) {
- return chain.filter(exchange).thenReturn(exchange);
- }
-
- /**
- * After the filter chain's run, if the proxied service replied with the user and roles headers,
- * save them in the session.
- *
- *
- *
- *
- * - A missing request header does not change the session
- *
- An empty username header clears out the values in the session (i.e. it's a logout)
- *
- */
- private Mono saveHeadersInSession(ServerWebExchange exchange) {
-
- HttpHeaders responseHeaders = exchange.getResponse().getHeaders();
- if (responseHeaders.containsKey(X_GSCLOUD_USERNAME)) {
- return exchange.getSession()
- .flatMap(session -> save(responseHeaders, session))
- .thenReturn(exchange);
- }
-
- return Mono.just(exchange);
- }
-
- private Mono save(HttpHeaders responseHeaders, WebSession session) {
- assert responseHeaders.containsKey(X_GSCLOUD_USERNAME);
-
- Optional userame =
- responseHeaders.getOrDefault(X_GSCLOUD_USERNAME, List.of()).stream()
- .filter(StringUtils::hasText)
- .findFirst();
-
- var roles = responseHeaders.getOrDefault(X_GSCLOUD_ROLES, List.of());
-
- return Mono.fromRunnable(
- () ->
- userame.ifPresentOrElse(
- user -> {
- var attributes = session.getAttributes();
- attributes.put(X_GSCLOUD_USERNAME, user);
- attributes.put(X_GSCLOUD_ROLES, roles);
- },
- () -> {
- var attributes = session.getAttributes();
- attributes.remove(X_GSCLOUD_USERNAME);
- attributes.remove(X_GSCLOUD_ROLES);
- }));
- }
-
- /**
- * Before proceeding with the filter chain, if the username and roles are stored in the session,
- * apply the request headers for the proxied service
- */
- private Mono addHeadersFromSession(ServerWebExchange exchange) {
- return exchange.getSession().map(session -> addHeadersFromSession(session, exchange));
- }
-
- private ServerWebExchange addHeadersFromSession(
- WebSession session, ServerWebExchange exchange) {
-
- String username = session.getAttributeOrDefault(X_GSCLOUD_USERNAME, "");
- if (StringUtils.hasText(username)) {
- String[] roles =
- session.getAttributeOrDefault(X_GSCLOUD_ROLES, List.of())
- .toArray(String[]::new);
- var request =
- exchange.getRequest()
- .mutate()
- .header(X_GSCLOUD_USERNAME, username)
- .header(X_GSCLOUD_ROLES, roles)
- .build();
- exchange = exchange.mutate().request(request).build();
- }
- return exchange;
- }
-
- private ServerWebExchange removeRequestHeaders(ServerWebExchange exchange) {
- if (impersonating(exchange)) {
- var request = exchange.getRequest().mutate().headers(this::removeHeaders).build();
- exchange = exchange.mutate().request(request).build();
- }
- return exchange;
- }
-
- private Mono removeResponseHeaders(ServerWebExchange exchange) {
- return Mono.fromRunnable(
- () -> {
- HttpHeaders responseHeaders = exchange.getResponse().getHeaders();
- removeHeaders(responseHeaders);
- });
- }
-
- private void removeHeaders(HttpHeaders httpHeaders) {
- httpHeaders.remove(X_GSCLOUD_USERNAME);
- httpHeaders.remove(X_GSCLOUD_ROLES);
- }
-
- private boolean impersonating(ServerWebExchange exchange) {
- HttpHeaders headers = exchange.getRequest().getHeaders();
- return headers.containsKey(X_GSCLOUD_USERNAME) || headers.containsKey(X_GSCLOUD_ROLES);
- }
-}
diff --git a/src/apps/infrastructure/gateway/src/main/java/org/geoserver/cloud/security/gateway/sharedauth/GatewaySharedAuhenticationPostFilter.java b/src/apps/infrastructure/gateway/src/main/java/org/geoserver/cloud/security/gateway/sharedauth/GatewaySharedAuhenticationPostFilter.java
new file mode 100644
index 000000000..b51bcf3ee
--- /dev/null
+++ b/src/apps/infrastructure/gateway/src/main/java/org/geoserver/cloud/security/gateway/sharedauth/GatewaySharedAuhenticationPostFilter.java
@@ -0,0 +1,186 @@
+/*
+ * (c) 2024 Open Source Geospatial Foundation - all rights reserved This code is licensed under the
+ * GPL 2.0 license, available at the root application directory.
+ */
+package org.geoserver.cloud.security.gateway.sharedauth;
+
+import static org.geoserver.cloud.security.gateway.sharedauth.SharedAuthConfigurationProperties.X_GSCLOUD_ROLES;
+import static org.geoserver.cloud.security.gateway.sharedauth.SharedAuthConfigurationProperties.X_GSCLOUD_USERNAME;
+
+import lombok.extern.slf4j.Slf4j;
+
+import org.springframework.boot.autoconfigure.neo4j.Neo4jProperties.Authentication;
+import org.springframework.cloud.gateway.filter.GatewayFilterChain;
+import org.springframework.cloud.gateway.filter.GlobalFilter;
+import org.springframework.core.Ordered;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.server.reactive.ServerHttpRequest;
+import org.springframework.util.StringUtils;
+import org.springframework.web.server.ServerWebExchange;
+import org.springframework.web.server.WebSession;
+
+import reactor.core.publisher.Mono;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+/**
+ * {@link GlobalFilter} working in tandem with {@link GatewaySharedAuhenticationPreFilter} to enable
+ * sharing the webui form-based authentication object with the other services.
+ *
+ * When a user is logged in through the regular web ui's authentication form, the {@link
+ * Authentication} object is held in the web ui's {@link WebSession}. Hence, further requests to
+ * stateless services, as they're on separate containers, don't share the webui session, and hence
+ * are executed as anonymous.
+ *
+ *
This {@link GlobalFilter} enables a mechanism by which the authenticated user name and roles
+ * can be shared with the stateless services through request and response headrers, using the
+ * geoserver cloud gateway as the man in the middle.
+ *
+ *
The webui container will send a couple response headers with the authenticated user name and
+ * roles. The gateway will store them in its own session, and forward them to all services as
+ * request headers. The stateless services will intercept these request headers and impersonate the
+ * authenticated user as a {@link PreAuthenticatedAuthenticationToken}.
+ *
+ *
At the same time, the gateway will take care of removing the webui response headers from the
+ * responses sent to the clients, and from incoming client requests.
+ *
+ *
This is the post-filter in the above mentioned workflow, and takes care of updating the {@link
+ * WebSession} to either store or remove the {@literal x-gsc-username} and {@literal x-gsc-roles}
+ * webui response headers, or clear them out from the session when the webui responds with an empty
+ * string for {@literal x-gsc-username}.
+ *
+ * @since 1.9
+ * @see GatewaySharedAuhenticationPreFilter
+ */
+@Slf4j(topic = "org.geoserver.cloud.security.gateway.sharedauth.post")
+public class GatewaySharedAuhenticationPostFilter implements GlobalFilter, Ordered {
+
+ /**
+ * @return {@link Ordered#LOWEST_PRECEDENCE}, being a post-filter, means it'll run the first
+ * post-processing once the response is obtained from the downstream service
+ */
+ @Override
+ public int getOrder() {
+ return Ordered.LOWEST_PRECEDENCE;
+ }
+
+ @Override
+ public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
+ return chain.filter(exchange)
+ .then(updateHeadersInSession(exchange))
+ .then(removeResponseHeaders(exchange));
+ }
+
+ /**
+ * After the filter chain's run, if the proxied service replied with the user and roles headers,
+ * save them in the session.
+ *
+ *
+ *
+ *
+ * - A missing request header does not change the session
+ *
- An empty username header clears out the values in the session (i.e. it's a logout)
+ *
+ */
+ private Mono updateHeadersInSession(ServerWebExchange exchange) {
+
+ return exchange.getSession()
+ .flatMap(
+ session ->
+ updateSession(
+ exchange.getRequest(),
+ exchange.getResponse().getHeaders(),
+ session));
+ }
+
+ private Mono updateSession(
+ ServerHttpRequest req, HttpHeaders responseHeaders, WebSession session) {
+ final var responseUser = responseHeaders.getFirst(X_GSCLOUD_USERNAME);
+ if (null == responseUser) {
+ return Mono.empty();
+ }
+
+ final boolean isLogout = !StringUtils.hasText(responseUser);
+ if (isLogout) {
+ return Mono.fromRunnable(() -> loggedOut(session, req));
+ }
+ var roles = responseHeaders.getOrDefault(X_GSCLOUD_ROLES, List.of());
+ return Mono.fromRunnable(() -> loggedIn(session, responseUser, roles, req));
+ }
+
+ private void loggedIn(
+ WebSession session, String user, List roles, ServerHttpRequest req) {
+ var attributes = session.getAttributes();
+ var currUser = attributes.get(X_GSCLOUD_USERNAME);
+ var currRoles = attributes.get(X_GSCLOUD_ROLES);
+ if (Objects.equals(user, currUser) && Objects.equals(roles, currRoles)) {
+ log.trace(
+ "user {} already present in session[{}], ignoring headers from {} {}",
+ user,
+ session.getId(),
+ req.getMethod(),
+ req.getURI().getPath());
+ return;
+ }
+ if (!session.isStarted()) {
+ session.start();
+ }
+ attributes.put(X_GSCLOUD_USERNAME, user);
+ attributes.put(X_GSCLOUD_ROLES, roles);
+ log.debug(
+ "stored shared-auth in session[{}], user '{}', roles '{}', as returned by {} {}",
+ session.getId(),
+ user,
+ roles,
+ req.getMethod(),
+ req.getURI().getPath());
+ }
+
+ private void loggedOut(WebSession session, ServerHttpRequest req) {
+ var attributes = session.getAttributes();
+ if (session.isStarted() && attributes.containsKey(X_GSCLOUD_USERNAME)) {
+ var user = attributes.remove(X_GSCLOUD_USERNAME);
+ var roles = attributes.remove(X_GSCLOUD_ROLES);
+ log.debug(
+ "removed shared-auth user {} roles {} from session[{}] as returned by {} {}",
+ user,
+ roles,
+ session.getId(),
+ req.getMethod(),
+ req.getURI().getPath());
+ }
+ }
+
+ private Mono removeResponseHeaders(ServerWebExchange exchange) {
+ return Mono.fromRunnable(
+ () -> {
+ HttpHeaders responseHeaders = exchange.getResponse().getHeaders();
+ removeResponseHeaders(exchange.getRequest(), responseHeaders);
+ });
+ }
+
+ private void removeResponseHeaders(ServerHttpRequest req, HttpHeaders responseHeaders) {
+ removeResponseHeader(req, responseHeaders, X_GSCLOUD_USERNAME);
+ removeResponseHeader(req, responseHeaders, X_GSCLOUD_ROLES);
+ }
+
+ private void removeResponseHeader(ServerHttpRequest req, HttpHeaders headers, String name) {
+ removeHeader(headers, name)
+ .ifPresent(
+ value ->
+ log.trace(
+ "removed response header {}: '{}'. {} {}",
+ name,
+ value,
+ req.getMethod(),
+ req.getURI().getPath()));
+ }
+
+ private Optional removeHeader(HttpHeaders httpHeaders, String name) {
+ List values = httpHeaders.remove(name);
+ return Optional.ofNullable(values).map(l -> l.stream().collect(Collectors.joining(",")));
+ }
+}
diff --git a/src/apps/infrastructure/gateway/src/main/java/org/geoserver/cloud/security/gateway/sharedauth/GatewaySharedAuhenticationPreFilter.java b/src/apps/infrastructure/gateway/src/main/java/org/geoserver/cloud/security/gateway/sharedauth/GatewaySharedAuhenticationPreFilter.java
new file mode 100644
index 000000000..b4318d315
--- /dev/null
+++ b/src/apps/infrastructure/gateway/src/main/java/org/geoserver/cloud/security/gateway/sharedauth/GatewaySharedAuhenticationPreFilter.java
@@ -0,0 +1,172 @@
+/*
+ * (c) 2024 Open Source Geospatial Foundation - all rights reserved This code is licensed under the
+ * GPL 2.0 license, available at the root application directory.
+ */
+package org.geoserver.cloud.security.gateway.sharedauth;
+
+import static org.geoserver.cloud.security.gateway.sharedauth.SharedAuthConfigurationProperties.X_GSCLOUD_ROLES;
+import static org.geoserver.cloud.security.gateway.sharedauth.SharedAuthConfigurationProperties.X_GSCLOUD_USERNAME;
+
+import lombok.extern.slf4j.Slf4j;
+
+import org.springframework.boot.autoconfigure.neo4j.Neo4jProperties.Authentication;
+import org.springframework.cloud.gateway.filter.GatewayFilterChain;
+import org.springframework.cloud.gateway.filter.GlobalFilter;
+import org.springframework.core.Ordered;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.server.reactive.ServerHttpRequest;
+import org.springframework.util.StringUtils;
+import org.springframework.web.server.ServerWebExchange;
+import org.springframework.web.server.WebSession;
+
+import reactor.core.publisher.Mono;
+
+import java.net.InetSocketAddress;
+import java.net.URI;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+/**
+ * {@link GlobalFilter} working in tandem with {@link GatewaySharedAuhenticationPostFilter} to
+ * enable sharing the webui form-based authentication object with the other services.
+ *
+ * When a user is logged in through the regular web ui's authentication form, the {@link
+ * Authentication} object is held in the web ui's {@link WebSession}. Hence, further requests to
+ * stateless services, as they're on separate containers, don't share the webui session, and hence
+ * are executed as anonymous.
+ *
+ *
This {@link GlobalFilter} enables a mechanism by which the authenticated user name and roles
+ * can be shared with the stateless services through request and response headrers, using the
+ * geoserver cloud gateway as the man in the middle.
+ *
+ *
The webui container will send a couple response headers with the authenticated user name and
+ * roles. The gateway will store them in its own session, and forward them to all services as
+ * request headers. The stateless services will intercept these request headers and impersonate the
+ * authenticated user as a {@link PreAuthenticatedAuthenticationToken}.
+ *
+ *
At the same time, the gateway will take care of removing the webui response headers from the
+ * responses sent to the clients, and from incoming client requests.
+ *
+ *
This is the pre-filter in the above mentioned workflow, taking care of avoiding external
+ * impresonation attempts by removing the {@literal x-gsc-username} and {@literal x-gsc-roles}
+ * headers from incoming requests, and appending them to proxied requests using the values taken
+ * from the {@link WebSession}, if present (as stored by the {@link
+ * GatewaySharedAuhenticationPostFilter post-filter}.
+ *
+ * @since 1.9
+ * @see GatewaySharedAuhenticationPostFilter
+ */
+@Slf4j(topic = "org.geoserver.cloud.security.gateway.sharedauth.pre")
+public class GatewaySharedAuhenticationPreFilter implements GlobalFilter, Ordered {
+
+ /**
+ * @return {@link Ordered#HIGHEST_PRECEDENCE}, being a pre-filter, means it'll run the first for
+ * pre-processing before the request executed
+ */
+ @Override
+ public int getOrder() {
+ return Ordered.HIGHEST_PRECEDENCE;
+ }
+
+ @Override
+ public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
+ // first, remove any incoming header to prevent impersonation
+ exchange = removeRequestHeaders(exchange);
+ return addHeadersFromSession(exchange).flatMap(chain::filter).then();
+ }
+
+ /**
+ * Before proceeding with the filter chain, if the username and roles are stored in the session,
+ * apply the request headers for the proxied service
+ */
+ private Mono addHeadersFromSession(ServerWebExchange exchange) {
+ return exchange.getSession().map(session -> addHeadersFromSession(session, exchange));
+ }
+
+ private ServerWebExchange addHeadersFromSession(
+ WebSession session, ServerWebExchange exchange) {
+ final String username = session.getAttribute(X_GSCLOUD_USERNAME);
+ if (StringUtils.hasText(username)) {
+ final List roles = session.getAttributeOrDefault(X_GSCLOUD_ROLES, List.of());
+ final var origRequest = exchange.getRequest();
+ var request =
+ origRequest
+ .mutate()
+ .headers(
+ headers -> {
+ headers.set(X_GSCLOUD_USERNAME, username);
+ headers.remove(X_GSCLOUD_ROLES);
+ roles.forEach(role -> headers.add(X_GSCLOUD_ROLES, role));
+ log.debug(
+ "appended shared-auth request headers from session[{}] {}: {}, {}: {} to {} {}",
+ session.getId(),
+ X_GSCLOUD_USERNAME,
+ username,
+ X_GSCLOUD_ROLES,
+ roles,
+ origRequest.getMethod(),
+ origRequest.getURI().getPath());
+ })
+ .build();
+
+ exchange = exchange.mutate().request(request).build();
+ } else {
+ log.trace(
+ "{} from session[{}] is '{}', not appending shared-auth headers to {} {}",
+ X_GSCLOUD_USERNAME,
+ session.getId(),
+ username,
+ exchange.getRequest().getMethod(),
+ exchange.getRequest().getURI().getPath());
+ }
+ return exchange;
+ }
+
+ private ServerWebExchange removeRequestHeaders(ServerWebExchange exchange) {
+ if (impersonationAttempt(exchange)) {
+ var origRequest = exchange.getRequest();
+ var request =
+ exchange.getRequest()
+ .mutate()
+ .headers(headers -> removeRequestHeaders(origRequest, headers))
+ .build();
+ exchange = exchange.mutate().request(request).build();
+ }
+ return exchange;
+ }
+
+ private void removeRequestHeaders(ServerHttpRequest origRequest, HttpHeaders requestHeaders) {
+ removeRequestHeader(origRequest, requestHeaders, X_GSCLOUD_USERNAME);
+ removeRequestHeader(origRequest, requestHeaders, X_GSCLOUD_ROLES);
+ }
+
+ private void removeRequestHeader(
+ ServerHttpRequest origRequest, HttpHeaders headers, String name) {
+ removeHeader(headers, name)
+ .ifPresent(
+ value -> {
+ HttpMethod method = origRequest.getMethod();
+ URI uri = origRequest.getURI();
+ InetSocketAddress remoteAddress = origRequest.getRemoteAddress();
+ log.warn(
+ "removed incoming request header {}: {}. Request: [{} {}], from: {}",
+ name,
+ value,
+ method,
+ uri,
+ remoteAddress);
+ });
+ }
+
+ private Optional removeHeader(HttpHeaders httpHeaders, String name) {
+ List values = httpHeaders.remove(name);
+ return Optional.ofNullable(values).map(l -> l.stream().collect(Collectors.joining(",")));
+ }
+
+ private boolean impersonationAttempt(ServerWebExchange exchange) {
+ HttpHeaders headers = exchange.getRequest().getHeaders();
+ return headers.containsKey(X_GSCLOUD_USERNAME) || headers.containsKey(X_GSCLOUD_ROLES);
+ }
+}
diff --git a/src/apps/infrastructure/gateway/src/main/java/org/geoserver/cloud/autoconfigure/gateway/SharedAuthConfigurationProperties.java b/src/apps/infrastructure/gateway/src/main/java/org/geoserver/cloud/security/gateway/sharedauth/SharedAuthConfigurationProperties.java
similarity index 81%
rename from src/apps/infrastructure/gateway/src/main/java/org/geoserver/cloud/autoconfigure/gateway/SharedAuthConfigurationProperties.java
rename to src/apps/infrastructure/gateway/src/main/java/org/geoserver/cloud/security/gateway/sharedauth/SharedAuthConfigurationProperties.java
index 04b39f6d2..0b0cfdc9a 100644
--- a/src/apps/infrastructure/gateway/src/main/java/org/geoserver/cloud/autoconfigure/gateway/SharedAuthConfigurationProperties.java
+++ b/src/apps/infrastructure/gateway/src/main/java/org/geoserver/cloud/security/gateway/sharedauth/SharedAuthConfigurationProperties.java
@@ -2,7 +2,7 @@
* (c) 2024 Open Source Geospatial Foundation - all rights reserved This code is licensed under the
* GPL 2.0 license, available at the root application directory.
*/
-package org.geoserver.cloud.autoconfigure.gateway;
+package org.geoserver.cloud.security.gateway.sharedauth;
import lombok.Data;
@@ -22,4 +22,7 @@ class SharedAuthConfigurationProperties {
* services.
*/
private boolean enabled = true;
+
+ static final String X_GSCLOUD_ROLES = "x-gsc-roles";
+ static final String X_GSCLOUD_USERNAME = "x-gsc-username";
}
diff --git a/src/apps/infrastructure/gateway/src/main/resources/META-INF/spring.factories b/src/apps/infrastructure/gateway/src/main/resources/META-INF/spring.factories
index e8e6264bc..dfc9bdf9d 100644
--- a/src/apps/infrastructure/gateway/src/main/resources/META-INF/spring.factories
+++ b/src/apps/infrastructure/gateway/src/main/resources/META-INF/spring.factories
@@ -1,3 +1,4 @@
# Auto Configure
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
-org.geoserver.cloud.autoconfigure.gateway.GatewayApplicationAutoconfiguration
\ No newline at end of file
+org.geoserver.cloud.autoconfigure.gateway.GatewayApplicationAutoconfiguration,\
+org.geoserver.cloud.autoconfigure.gateway.GatewaySharedAuthAutoConfiguration
\ No newline at end of file
diff --git a/src/apps/infrastructure/gateway/src/test/java/org/geoserver/cloud/autoconfigure/gateway/GatewayApplicationAutoconfigurationTest.java b/src/apps/infrastructure/gateway/src/test/java/org/geoserver/cloud/autoconfigure/gateway/GatewayApplicationAutoconfigurationTest.java
index a9a2c2949..24e0e617d 100644
--- a/src/apps/infrastructure/gateway/src/test/java/org/geoserver/cloud/autoconfigure/gateway/GatewayApplicationAutoconfigurationTest.java
+++ b/src/apps/infrastructure/gateway/src/test/java/org/geoserver/cloud/autoconfigure/gateway/GatewayApplicationAutoconfigurationTest.java
@@ -6,7 +6,6 @@
import static org.assertj.core.api.Assertions.assertThat;
-import org.geoserver.cloud.gateway.filter.GatewaySharedAuhenticationGlobalFilter;
import org.geoserver.cloud.gateway.filter.RouteProfileGatewayFilterFactory;
import org.geoserver.cloud.gateway.filter.StripBasePathGatewayFilterFactory;
import org.geoserver.cloud.gateway.predicate.RegExpQueryRoutePredicateFactory;
@@ -29,18 +28,6 @@ void testDefaultAppContextContributions() {
.hasNotFailed()
.hasSingleBean(RegExpQueryRoutePredicateFactory.class)
.hasSingleBean(RouteProfileGatewayFilterFactory.class)
- .hasSingleBean(StripBasePathGatewayFilterFactory.class)
- .hasSingleBean(GatewaySharedAuhenticationGlobalFilter.class));
- }
-
- @Test
- void disableGatewaySharedAuhenticationGlobalFilter() {
- runner.withPropertyValues("geoserver.security.gateway-shared-auth.enabled: false")
- .run(
- context ->
- assertThat(context)
- .hasNotFailed()
- .doesNotHaveBean(
- GatewaySharedAuhenticationGlobalFilter.class));
+ .hasSingleBean(StripBasePathGatewayFilterFactory.class));
}
}
diff --git a/src/apps/infrastructure/gateway/src/test/java/org/geoserver/cloud/autoconfigure/gateway/GatewaySharedAuthAutoConfigurationTest.java b/src/apps/infrastructure/gateway/src/test/java/org/geoserver/cloud/autoconfigure/gateway/GatewaySharedAuthAutoConfigurationTest.java
new file mode 100644
index 000000000..ef8d67fca
--- /dev/null
+++ b/src/apps/infrastructure/gateway/src/test/java/org/geoserver/cloud/autoconfigure/gateway/GatewaySharedAuthAutoConfigurationTest.java
@@ -0,0 +1,53 @@
+/*
+ * (c) 2020 Open Source Geospatial Foundation - all rights reserved This code is licensed under the
+ * GPL 2.0 license, available at the root application directory.
+ */
+package org.geoserver.cloud.autoconfigure.gateway;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.geoserver.cloud.security.gateway.sharedauth.GatewaySharedAuhenticationPostFilter;
+import org.geoserver.cloud.security.gateway.sharedauth.GatewaySharedAuhenticationPreFilter;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner;
+
+class GatewaySharedAuthAutoConfigurationTest {
+
+ private ReactiveWebApplicationContextRunner runner =
+ new ReactiveWebApplicationContextRunner()
+ .withConfiguration(
+ AutoConfigurations.of(GatewaySharedAuthAutoConfiguration.class));
+
+ @Test
+ void enabledByDefault() {
+ assertEnabled(runner);
+ }
+
+ @Test
+ void enabledByConfig() {
+ assertEnabled(
+ runner.withPropertyValues("geoserver.security.gateway-shared-auth.enabled: true"));
+ }
+
+ private void assertEnabled(ReactiveWebApplicationContextRunner contextRunner) {
+ contextRunner.run(
+ context ->
+ assertThat(context)
+ .hasNotFailed()
+ .hasSingleBean(GatewaySharedAuhenticationPreFilter.class)
+ .hasSingleBean(GatewaySharedAuhenticationPostFilter.class));
+ }
+
+ @Test
+ void disableByConfig() {
+ runner.withPropertyValues("geoserver.security.gateway-shared-auth.enabled: false")
+ .run(
+ context ->
+ assertThat(context)
+ .hasNotFailed()
+ .doesNotHaveBean(GatewaySharedAuhenticationPreFilter.class)
+ .doesNotHaveBean(
+ GatewaySharedAuhenticationPostFilter.class));
+ }
+}
diff --git a/src/apps/infrastructure/gateway/src/test/java/org/geoserver/cloud/security/gateway/sharedauth/GatewaySharedAuhenticationTest.java b/src/apps/infrastructure/gateway/src/test/java/org/geoserver/cloud/security/gateway/sharedauth/GatewaySharedAuhenticationTest.java
new file mode 100644
index 000000000..79799b08a
--- /dev/null
+++ b/src/apps/infrastructure/gateway/src/test/java/org/geoserver/cloud/security/gateway/sharedauth/GatewaySharedAuhenticationTest.java
@@ -0,0 +1,419 @@
+/*
+ * (c) 2024 Open Source Geospatial Foundation - all rights reserved This code is licensed under the
+ * GPL 2.0 license, available at the root application directory.
+ */
+package org.geoserver.cloud.security.gateway.sharedauth;
+
+import static com.github.tomakehurst.wiremock.stubbing.StubMapping.buildFrom;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.github.tomakehurst.wiremock.client.WireMock;
+import com.github.tomakehurst.wiremock.http.HttpHeader;
+import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
+import com.github.tomakehurst.wiremock.junit5.WireMockTest;
+import com.github.tomakehurst.wiremock.stubbing.StubMapping;
+import com.github.tomakehurst.wiremock.verification.LoggedRequest;
+
+import lombok.NonNull;
+
+import org.geoserver.cloud.gateway.GatewayApplication;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.MethodOrderer;
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestMethodOrder;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfiguration.EnableWebFluxConfiguration;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.web.server.WebSession;
+import org.springframework.web.server.session.DefaultWebSessionManager;
+import org.springframework.web.server.session.WebSessionManager;
+import org.springframework.web.server.session.WebSessionStore;
+
+import java.net.URI;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/**
+ * Wiremock integration test for a running gateway with {@link GatewaySharedAuhenticationPreFilter}
+ * and {@link GatewaySharedAuhenticationPostFilter}
+ */
+@SpringBootTest(
+ classes = GatewayApplication.class, //
+ webEnvironment = WebEnvironment.RANDOM_PORT,
+ properties = {"geoserver.security.gateway-shared-auth.enabled=true"})
+@ActiveProfiles("test") // bootstrap-test.yml disables config and discovery
+@WireMockTest
+// @TestMethodOrder is not really needed, just used to run tests in the workflow order, but tests
+// are isolated
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+class GatewaySharedAuhenticationTest {
+ // stub mappings in JSON format, see https://wiremock.org/docs/stubbing/
+
+ /** request stub for the webui returning the logged-in username and roles as response headers */
+ private static final String WEB_LOGIN_SPEC =
+ """
+ {
+ "priority": 1,
+ "request": {
+ "method": "POST",
+ "url": "/geoserver/cloud/j_spring_security_check",
+ "headers": {
+ "Accept": {"contains": "text/html"},
+ "Content-Type": {"equalTo": "application/x-www-form-urlencoded"}
+ }
+ },
+ "response": {
+ "status": 302,
+ "headers": {
+ "Content-Length": "0",
+ "Location": "http://0.0.0.0:9090/geoserver/cloud/web",
+ "Set-Cookie": ["JSESSIONID_web-ui=ABC123; Path=/; HttpOnly"],
+ "x-gsc-username": "testuser",
+ "x-gsc-roles": ["ROLE_USER","ROLE_EDITOR"]
+ }
+ }
+ }
+ """;
+
+ /**
+ * request stub for the webui returning an empty-string on the {@literal x-gsc-username}
+ * response header, meaning to log out (remove the user and roles from the session)
+ */
+ private static final String WEB_LOGOUT_SPEC =
+ """
+ {
+ "priority": 2,
+ "request": {
+ "method": "POST",
+ "url": "/j_spring_security_logout"
+ },
+ "response": {
+ "status": 302,
+ "headers": {
+ "Location": "http://0.0.0.0:9090/geoserver/cloud/web",
+ "Set-Cookie": ["session_id=abc123"],
+ "x-gsc-username": ""
+ }
+ }
+ }
+ """;
+
+ /**
+ * request stub for a non-webui service to check it receives the {@literal x-gsc-username} and
+ * {@literal x-gsc-roles} request headers from the gateway when expected
+ */
+ private static final String WMS_GETCAPS =
+ """
+ {
+ "priority": 3,
+ "request": {
+ "method": "GET",
+ "url": "/wms?request=GetCapabilities"
+ },
+ "response": {
+ "status": 200,
+ "headers": {
+ "Content-Type": "text/xml",
+ "Cache-Control": "no-cache"
+ },
+ "body": ""
+ }
+ }
+ """;
+
+ /** Default response to catch up invalid mappings using the 418 status code */
+ private static final String DEFAULT_RESPONSE =
+ """
+ {
+ "priority": 10,
+ "request": {"method": "ANY","urlPattern": ".*"},
+ "response": {
+ "status": 418,
+ "jsonBody": { "status": "Error", "message": "I'm a teapot" },
+ "headers": {"Content-Type": "application/json"}
+ }
+ }
+ """;
+
+ /** saved in {@link #setUpWireMock}, to be used on {@link #registerRoutes} */
+ private static WireMockRuntimeInfo wmRuntimeInfo;
+
+ /**
+ * Set up stub requests for the wiremock server. WireMock is running on a random port, so this
+ * method saves {@link #wmRuntimeInfo} for {@link #registerRoutes(DynamicPropertyRegistry)}
+ */
+ @BeforeAll
+ static void saveWireMock(WireMockRuntimeInfo runtimeInfo) {
+ GatewaySharedAuhenticationTest.wmRuntimeInfo = runtimeInfo;
+ }
+
+ /** Set up a gateway route that proxies all requests to the wiremock server */
+ @DynamicPropertySource
+ static void registerRoutes(DynamicPropertyRegistry registry) {
+ String targetUrl = wmRuntimeInfo.getHttpBaseUrl();
+ registry.add("spring.cloud.gateway.routes[0].id", () -> "wiremock");
+ registry.add("spring.cloud.gateway.routes[0].uri", () -> targetUrl);
+ registry.add("spring.cloud.gateway.routes[0].predicates[0]", () -> "Path=/**");
+ }
+
+ @Autowired TestRestTemplate testRestTemplate;
+
+ /**
+ * Concrete implementation of {@link WebSessionManager} as created by {@link
+ * EnableWebFluxConfiguration#webSessionManager()} so we can access {@link
+ * DefaultWebSessionManager#getSessionStore()}
+ */
+ @Autowired DefaultWebSessionManager webSessionManager;
+
+ private URI login;
+ private URI logout;
+ private URI getcapabilities;
+
+ @BeforeEach
+ void setUp(WireMockRuntimeInfo runtimeInfo) throws Exception {
+ StubMapping weblogin = buildFrom(WEB_LOGIN_SPEC);
+ StubMapping weblogout = buildFrom(WEB_LOGOUT_SPEC);
+ StubMapping wmscaps = buildFrom(WMS_GETCAPS);
+
+ WireMock wireMock = runtimeInfo.getWireMock();
+ wireMock.register(weblogin);
+ wireMock.register(weblogout);
+ wireMock.register(wmscaps);
+ wireMock.register(buildFrom(DEFAULT_RESPONSE));
+
+ login = gatewayUriOf(runtimeInfo, weblogin);
+ logout = gatewayUriOf(runtimeInfo, weblogout);
+ getcapabilities = gatewayUriOf(runtimeInfo, wmscaps);
+ }
+
+ /**
+ * Make a request where the caller is trying to impersonate a user with request headers {@code
+ * x-gsc-username} and {@code x-gsc-roles}, verify {@link GatewaySharedAuhenticationPreFilter}
+ * removes them from the proxy request
+ */
+ @Test
+ @Order(1)
+ @DisplayName("pre-filter avoids impersonation attempts")
+ void preFilterRemovesIncomingSharedAuthHeaders(WireMockRuntimeInfo runtimeInfo) {
+ ResponseEntity response =
+ getCapabilities(
+ "x-gsc-username", "user", "x-gsc-roles", "ROLE_1", "x-gsc-roles", "ROLE_2");
+ assertThat(response.getBody()).startsWith(" login = login();
+ final String gatewaySessionId = getGatewaySessionId(login.getHeaders());
+ assertUserAndRolesStoredInSession(gatewaySessionId);
+
+ // query the wms service with the gateway session id
+ runtimeInfo.getWireMock().getServeEvents().clear();
+ ResponseEntity getcaps =
+ getCapabilities("Cookie", "SESSION=%s".formatted(gatewaySessionId));
+ assertThat(getcaps.getBody()).startsWith(" login = login();
+ final String gatewaySessionId = getGatewaySessionId(login.getHeaders());
+
+ assertUserAndRolesStoredInSession(gatewaySessionId);
+ }
+
+ @Test
+ @Order(4)
+ @DisplayName("post-filter clears user and roles from session on empty username response header")
+ void postFilterRemovesUserAndRolesFromSessionOnEmptyUserResponseHeader(
+ WireMockRuntimeInfo runtimeInfo) {
+ // preflight, have a session and the user and roles stored
+ ResponseEntity login = login();
+ final String gatewaySessionId = getGatewaySessionId(login.getHeaders());
+ assertUserAndRolesStoredInSession(gatewaySessionId);
+
+ // make a request that returns and empty string on the x-gsc-username response header
+ logout(gatewaySessionId);
+ Map attributes = getSessionAttributes(gatewaySessionId);
+ assertThat(attributes)
+ .as(
+ "GatewaySharedAuhenticationPostFilter did not remove x-gsc-username from the session")
+ .doesNotContainKey("x-gsc-username")
+ .as(
+ "GatewaySharedAuhenticationPostFilter did not remove x-gsc-roles from the session")
+ .doesNotContainKey("x-gsc-roles");
+ }
+
+ /**
+ * Make a call to the web-ui that returns {@code x-gsc-username} and {@code x-gsc-roles}
+ * headers, and verify {@link GatewaySharedAuhenticationPostFilter} does not propagate them to
+ * the response.
+ */
+ @Test
+ @Order(5)
+ @DisplayName("post-filter removes user and roles headers from the final response")
+ void postFilterRemovesOutgoingSharedAuthHeaders(WireMockRuntimeInfo runtimeInfo) {
+ ResponseEntity response = login();
+ HttpHeaders responseHeaders = response.getHeaders();
+ assertThat(responseHeaders)
+ .as(
+ "GatewaySharedAuhenticationGlobalFilter should have removed the x-gsc-username response header")
+ .doesNotContainKey("x-gsc-username")
+ .as(
+ "GatewaySharedAuhenticationGlobalFilter should have removed the x-gsc-roles response header")
+ .doesNotContainKey("x-gsc-roles");
+ }
+
+ private String getGatewaySessionId(HttpHeaders responseHeaders) {
+ List cookies = responseHeaders.get("Set-Cookie");
+ String cookie =
+ cookies.stream().filter(c -> c.startsWith("SESSION=")).findFirst().orElseThrow();
+ String sessionId = cookie.substring("SESSION=".length());
+ sessionId = sessionId.substring(0, sessionId.indexOf(';'));
+ return sessionId;
+ }
+
+ private void assertUserAndRolesStoredInSession(final String gatewaySessionId) {
+ Map attributes = getSessionAttributes(gatewaySessionId);
+ assertThat(attributes)
+ .containsEntry("x-gsc-username", "testuser")
+ .containsEntry("x-gsc-roles", List.of("ROLE_USER", "ROLE_EDITOR"));
+ }
+
+ private Map getSessionAttributes(final String gatewaySessionId) {
+ WebSessionStore sessionStore = webSessionManager.getSessionStore();
+ WebSession session = sessionStore.retrieveSession(gatewaySessionId).block();
+ Map attributes = session.getAttributes();
+ return attributes;
+ }
+
+ private URI gatewayUriOf(WireMockRuntimeInfo runtimeInfo, StubMapping mapping) {
+ return URI.create(mapping.getRequest().getUrl());
+ }
+
+ ResponseEntity login() {
+ HttpEntity> entity =
+ withHeaders( //
+ "Accept", "text/html,application/xhtml+xml", //
+ "Content-Type", "application/x-www-form-urlencoded");
+ ResponseEntity response = testRestTemplate.postForEntity(login, entity, Void.class);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FOUND);
+ HttpHeaders headers = response.getHeaders();
+
+ assertThat(headers)
+ .containsEntry("Location", List.of("http://0.0.0.0:9090/geoserver/cloud/web"));
+
+ return response;
+ }
+
+ ResponseEntity logout(@NonNull String gatewaySessionId) {
+ HttpEntity> entity =
+ withHeaders( //
+ "Accept", "text/html,application/xhtml+xml", //
+ "Content-Type", "application/x-www-form-urlencoded",
+ "Cookie", "SESSION=%s".formatted(gatewaySessionId));
+ ResponseEntity response = testRestTemplate.postForEntity(logout, entity, Void.class);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FOUND);
+ HttpHeaders headers = response.getHeaders();
+
+ assertThat(headers)
+ .containsEntry("Location", List.of("http://0.0.0.0:9090/geoserver/cloud/web"));
+
+ return response;
+ }
+
+ ResponseEntity getCapabilities(String... headersKvp) {
+ HttpEntity> entity = withHeaders(headersKvp);
+ ResponseEntity response =
+ testRestTemplate.exchange(getcapabilities, HttpMethod.GET, entity, String.class);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ HttpHeaders headers = response.getHeaders();
+ assertThat(headers.getContentType()).isEqualTo(MediaType.TEXT_XML);
+
+ return response;
+ }
+
+ private HttpEntity> withHeaders(String... headersKvp) {
+ assertThat(headersKvp.length % 2).as("headers kvp shall come in pairs").isZero();
+ HttpHeaders headers = new HttpHeaders();
+ Iterator it = Stream.of(headersKvp).iterator();
+ while (it.hasNext()) {
+ headers.add(it.next(), it.next());
+ }
+ return new HttpEntity<>(headers);
+ }
+}
diff --git a/src/apps/infrastructure/gateway/src/test/resources/bootstrap-test.yml b/src/apps/infrastructure/gateway/src/test/resources/bootstrap-test.yml
new file mode 100644
index 000000000..8b471da37
--- /dev/null
+++ b/src/apps/infrastructure/gateway/src/test/resources/bootstrap-test.yml
@@ -0,0 +1,12 @@
+spring:
+ main:
+ banner-mode: off
+ allow-bean-definition-overriding: true
+ allow-circular-references: true # false by default since spring-boot 2.6.0, breaks geoserver initialization
+ cloud.config.enabled: false
+ cloud.config.discovery.enabled: false
+ cloud.discovery.enabled: false
+eureka.client.enabled: false
+
+logging.level.org.geoserver.cloud.security.gateway.sharedauth: debug
+
diff --git a/src/pom.xml b/src/pom.xml
index 03f10036f..211437678 100644
--- a/src/pom.xml
+++ b/src/pom.xml
@@ -844,6 +844,12 @@
flyway-database-postgresql
${flyway.version}
+
+ org.wiremock
+ wiremock-standalone
+ 3.8.0
+ test
+
diff --git a/src/starters/security/src/main/java/org/geoserver/cloud/security/gateway/sharedauth/GatewaySharedAuthenticationFilter.java b/src/starters/security/src/main/java/org/geoserver/cloud/security/gateway/sharedauth/GatewaySharedAuthenticationFilter.java
index e4a544655..f94a26226 100644
--- a/src/starters/security/src/main/java/org/geoserver/cloud/security/gateway/sharedauth/GatewaySharedAuthenticationFilter.java
+++ b/src/starters/security/src/main/java/org/geoserver/cloud/security/gateway/sharedauth/GatewaySharedAuthenticationFilter.java
@@ -6,6 +6,8 @@
import static com.google.common.collect.Streams.stream;
+import com.google.common.collect.Streams;
+
import lombok.AccessLevel;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
@@ -23,17 +25,19 @@
import org.geoserver.security.impl.GeoServerRoleConverterImpl;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
-import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import java.io.IOException;
import java.util.Collection;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
+import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@@ -41,7 +45,7 @@
* @since 1.9
*/
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
-@Slf4j
+@Slf4j(topic = "org.geoserver.cloud.security.gateway.sharedauth")
class GatewaySharedAuthenticationFilter extends GeoServerSecurityFilter
implements GeoServerAuthenticationFilter {
@@ -112,6 +116,39 @@ static class ClientFilter extends GeoServerRequestHeaderAuthenticationFilter {
super.setRoleConverterName("");
}
+ @Override
+ public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+ throws IOException, ServletException {
+
+ var pre = SecurityContextHolder.getContext().getAuthentication();
+ try {
+ super.doFilter(request, response, chain);
+ } finally {
+ if (log.isDebugEnabled()) {
+ var post = SecurityContextHolder.getContext().getAuthentication();
+ HttpServletRequest req = (HttpServletRequest) request;
+ String preUsername = pre == null ? null : pre.getName();
+ String postUsername = post == null ? null : post.getName();
+ String reqHeaders = getHeaders(req);
+ String gatewaySessionId = getGatewaySessionId(req);
+ log.debug(
+ "[gateway session: {}] {} {}\n user pre: {}\n user post: {}\n headers: \n{}",
+ gatewaySessionId,
+ req.getMethod(),
+ req.getRequestURI(),
+ preUsername,
+ postUsername,
+ reqHeaders);
+ }
+ }
+ }
+
+ private String getHeaders(HttpServletRequest req) {
+ return Streams.stream(req.getHeaderNames().asIterator())
+ .map(name -> "\t%s: %s".formatted(name, req.getHeader(name)))
+ .collect(Collectors.joining("\n"));
+ }
+
/**
* Override to handle muilti-valued roles header, the superclass assumes a single-valued
* header with a delimiter to handle mutliple values
@@ -141,10 +178,15 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha
// Gateway to act as the middle man and send the user and roles to the other
// services
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
- if (auth == null || auth instanceof AnonymousAuthenticationToken) {
- removeGatewayResponseHeaders((HttpServletResponse) response);
- } else {
- setGatewayResponseHeaders(auth, (HttpServletResponse) response);
+ HttpServletResponse resp = (HttpServletResponse) response;
+ HttpServletRequest req = (HttpServletRequest) request;
+
+ if (auth != null
+ && !(auth instanceof AnonymousAuthenticationToken)
+ && auth.isAuthenticated()) {
+ setGatewayResponseHeaders(auth, req, resp);
+ } else if (auth == null || auth instanceof AnonymousAuthenticationToken) {
+ setEmptyUserResponseHeader(req, resp);
}
chain.doFilter(request, response);
@@ -155,22 +197,56 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha
* requires for it to be explicitly set to clear it out from its session, just removing the
* header wouldn't work.
*/
- private void removeGatewayResponseHeaders(HttpServletResponse response) {
+ private void setEmptyUserResponseHeader(
+ HttpServletRequest req, HttpServletResponse response) {
response.setHeader(X_GSCLOUD_USERNAME, "");
+ if (log.isDebugEnabled()) {
+ String gatewaySessionId = getGatewaySessionId(req);
+ log.debug(
+ "[gateway session: {}] sending empty {} response header for {} {}",
+ gatewaySessionId,
+ X_GSCLOUD_USERNAME,
+ req.getMethod(),
+ req.getRequestURI());
+ }
}
private void setGatewayResponseHeaders(
- Authentication preAuth, HttpServletResponse response) {
- final String name = null == preAuth ? null : preAuth.getName();
- response.setHeader(X_GSCLOUD_USERNAME, name);
- if (null != name) {
- preAuth.getAuthorities().stream()
- .map(GrantedAuthority::getAuthority)
- .forEach(role -> response.addHeader(X_GSCLOUD_ROLES, role));
+ Authentication preAuth, HttpServletRequest req, HttpServletResponse response) {
+ final String username = preAuth.getName();
+ if (null != username) {
+ response.setHeader(X_GSCLOUD_USERNAME, username);
+ preAuth.getAuthorities()
+ .forEach(
+ authority ->
+ response.addHeader(
+ X_GSCLOUD_ROLES, authority.getAuthority()));
+ if (log.isDebugEnabled()) {
+ String gatewaySessionId = getGatewaySessionId(req);
+ log.debug(
+ "[gateway session: {}] appended response headers {}: {}, {}: {} for {} {}",
+ gatewaySessionId,
+ X_GSCLOUD_USERNAME,
+ response.getHeader(X_GSCLOUD_USERNAME),
+ X_GSCLOUD_ROLES,
+ response.getHeaders(X_GSCLOUD_ROLES),
+ req.getMethod(),
+ req.getRequestURI());
+ }
}
}
}
+ static String getGatewaySessionId(HttpServletRequest req) {
+ Cookie[] cookies = req.getCookies();
+ if (null == cookies || cookies.length == 0) return null;
+ return Stream.of(cookies)
+ .filter(c -> "SESSION".equals(c.getName()))
+ .map(Cookie::getValue)
+ .findFirst()
+ .orElse(null);
+ }
+
/** No-op GeoServerSecurityFilter */
static class DisabledFilter extends GeoServerSecurityFilter {