diff --git a/config b/config index a4e8564c9..a7f37585e 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit a4e8564c95186fef00ff00abc8e11845945b1ed4 +Subproject commit a7f37585efbf66faf57403fe04f3e157005f6a08 diff --git a/src/apps/infrastructure/gateway/pom.xml b/src/apps/infrastructure/gateway/pom.xml index dbae7cdc9..4d59629dd 100644 --- a/src/apps/infrastructure/gateway/pom.xml +++ b/src/apps/infrastructure/gateway/pom.xml @@ -50,6 +50,11 @@ org.projectlombok lombok + + org.wiremock + wiremock-standalone + test + 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. - * - *

- * - *

- */ - 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 {