From 837db44dfa5719e99ea3309865b6c548171eff98 Mon Sep 17 00:00:00 2001 From: Spencer Gibb Date: Thu, 8 Oct 2020 16:17:25 -0400 Subject: [PATCH] Migrates token relay from spring-cloud-security. (#1976) * Migrates token relay from spring-cloud-security. Adds refresh token support. Fixes https://github.com/spring-cloud/spring-cloud-security/issues/175 Fixes gh-1975 See https://github.com/spring-cloud/spring-cloud-security/issues/231 --- pom.xml | 1 + spring-cloud-gateway-dependencies/pom.xml | 5 + spring-cloud-gateway-server-security/pom.xml | 81 +++++++++++ .../security/TokenRelayAutoConfiguration.java | 65 +++++++++ .../TokenRelayGatewayFilterFactory.java | 74 ++++++++++ .../main/resources/META-INF/spring.factories | 3 + .../TokenRelayAutoConfigurationTests.java | 59 ++++++++ .../TokenRelayGatewayFilterFactoryTests.java | 128 ++++++++++++++++++ 8 files changed, 416 insertions(+) create mode 100644 spring-cloud-gateway-server-security/pom.xml create mode 100644 spring-cloud-gateway-server-security/src/main/java/org/springframework/cloud/gateway/security/TokenRelayAutoConfiguration.java create mode 100644 spring-cloud-gateway-server-security/src/main/java/org/springframework/cloud/gateway/security/TokenRelayGatewayFilterFactory.java create mode 100644 spring-cloud-gateway-server-security/src/main/resources/META-INF/spring.factories create mode 100644 spring-cloud-gateway-server-security/src/test/java/org/springframework/cloud/gateway/security/TokenRelayAutoConfigurationTests.java create mode 100644 spring-cloud-gateway-server-security/src/test/java/org/springframework/cloud/gateway/security/TokenRelayGatewayFilterFactoryTests.java diff --git a/pom.xml b/pom.xml index 6dfe6c80..ebc4d204 100644 --- a/pom.xml +++ b/pom.xml @@ -123,6 +123,7 @@ spring-cloud-gateway-mvc spring-cloud-gateway-webflux spring-cloud-gateway-server + spring-cloud-gateway-server-security spring-cloud-starter-gateway spring-cloud-gateway-sample docs diff --git a/spring-cloud-gateway-dependencies/pom.xml b/spring-cloud-gateway-dependencies/pom.xml index 20997002..ca534eee 100644 --- a/spring-cloud-gateway-dependencies/pom.xml +++ b/spring-cloud-gateway-dependencies/pom.xml @@ -37,6 +37,11 @@ spring-cloud-gateway-server ${project.version} + + org.springframework.cloud + spring-cloud-gateway-server-security + ${project.version} + org.springframework.cloud spring-cloud-starter-gateway diff --git a/spring-cloud-gateway-server-security/pom.xml b/spring-cloud-gateway-server-security/pom.xml new file mode 100644 index 00000000..abb51fb4 --- /dev/null +++ b/spring-cloud-gateway-server-security/pom.xml @@ -0,0 +1,81 @@ + + + 4.0.0 + + + org.springframework.cloud + spring-cloud-gateway + 3.0.0-SNAPSHOT + .. + + spring-cloud-gateway-server-security + jar + Spring Cloud Gateway Server Security + Spring Cloud Gateway Server Security + + ${basedir}/.. + + + + + org.springframework.cloud + spring-cloud-gateway-server + + + org.springframework.boot + spring-boot-starter-oauth2-client + + + org.springframework.boot + spring-boot-configuration-processor + true + + + org.springframework.boot + spring-boot-devtools + true + + + org.springframework.boot + spring-boot-autoconfigure-processor + true + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-webflux + test + + + org.junit.vintage + junit-vintage-engine + test + + + org.junit-pioneer + junit-pioneer + test + + + org.springframework.cloud + spring-cloud-test-support + test + + + io.projectreactor + reactor-test + test + + + org.assertj + assertj-core + test + + + diff --git a/spring-cloud-gateway-server-security/src/main/java/org/springframework/cloud/gateway/security/TokenRelayAutoConfiguration.java b/spring-cloud-gateway-server-security/src/main/java/org/springframework/cloud/gateway/security/TokenRelayAutoConfiguration.java new file mode 100644 index 00000000..91379363 --- /dev/null +++ b/spring-cloud-gateway-server-security/src/main/java/org/springframework/cloud/gateway/security/TokenRelayAutoConfiguration.java @@ -0,0 +1,65 @@ +/* + * Copyright 2013-2014 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.gateway.security; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type; +import org.springframework.boot.autoconfigure.security.SecurityProperties; +import org.springframework.cloud.gateway.filter.GatewayFilter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientProvider; +import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientProviderBuilder; +import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.DefaultReactiveOAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.web.server.ServerOAuth2AuthorizedClientRepository; +import org.springframework.security.web.server.SecurityWebFilterChain; + +/** + * @author Dave Syer + * + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnProperty(name = "spring.cloud.gateway.enabled", matchIfMissing = true) +@ConditionalOnClass({ GatewayFilter.class, OAuth2AuthorizedClient.class, SecurityWebFilterChain.class, + SecurityProperties.class }) +@ConditionalOnWebApplication(type = Type.REACTIVE) +public class TokenRelayAutoConfiguration { + + @Bean + public TokenRelayGatewayFilterFactory tokenRelayGatewayFilterFactory( + ReactiveOAuth2AuthorizedClientManager clientManager) { + return new TokenRelayGatewayFilterFactory(clientManager); + } + + @Bean + public ReactiveOAuth2AuthorizedClientManager gatewayReactiveOAuth2AuthorizedClientManager( + ReactiveClientRegistrationRepository clientRegistrationRepository, + ServerOAuth2AuthorizedClientRepository authorizedClientRepository) { + ReactiveOAuth2AuthorizedClientProvider authorizedClientProvider = ReactiveOAuth2AuthorizedClientProviderBuilder + .builder().authorizationCode().refreshToken().build(); + DefaultReactiveOAuth2AuthorizedClientManager authorizedClientManager = new DefaultReactiveOAuth2AuthorizedClientManager( + clientRegistrationRepository, authorizedClientRepository); + authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider); + return authorizedClientManager; + } + +} diff --git a/spring-cloud-gateway-server-security/src/main/java/org/springframework/cloud/gateway/security/TokenRelayGatewayFilterFactory.java b/spring-cloud-gateway-server-security/src/main/java/org/springframework/cloud/gateway/security/TokenRelayGatewayFilterFactory.java new file mode 100644 index 00000000..e3cdf1a8 --- /dev/null +++ b/spring-cloud-gateway-server-security/src/main/java/org/springframework/cloud/gateway/security/TokenRelayGatewayFilterFactory.java @@ -0,0 +1,74 @@ +/* + * Copyright 2002-2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.gateway.security; + +import reactor.core.publisher.Mono; + +import org.springframework.cloud.gateway.filter.GatewayFilter; +import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory; +import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; + +/** + * @author Joe Grandja + */ +@Component +public class TokenRelayGatewayFilterFactory extends AbstractGatewayFilterFactory { + + private final ReactiveOAuth2AuthorizedClientManager clientManager; + + public TokenRelayGatewayFilterFactory(ReactiveOAuth2AuthorizedClientManager clientManager) { + super(Object.class); + this.clientManager = clientManager; + } + + public GatewayFilter apply() { + return apply((Object) null); + } + + @Override + public GatewayFilter apply(Object config) { + return (exchange, chain) -> exchange.getPrincipal() + // .log("token-relay-filter") + .filter(principal -> principal instanceof OAuth2AuthenticationToken) + .cast(OAuth2AuthenticationToken.class) + .flatMap(authentication -> authorizedClient(exchange, authentication)) + .map(OAuth2AuthorizedClient::getAccessToken).map(token -> withBearerAuth(exchange, token)) + // TODO: adjustable behavior if empty + .defaultIfEmpty(exchange).flatMap(chain::filter); + } + + private Mono authorizedClient(ServerWebExchange exchange, + OAuth2AuthenticationToken oauth2Authentication) { + String clientRegistrationId = oauth2Authentication.getAuthorizedClientRegistrationId(); + OAuth2AuthorizeRequest request = OAuth2AuthorizeRequest.withClientRegistrationId(clientRegistrationId) + .principal(oauth2Authentication).build(); + // TODO: use Mono.defer() for request above? + return clientManager.authorize(request); + } + + private ServerWebExchange withBearerAuth(ServerWebExchange exchange, OAuth2AccessToken accessToken) { + return exchange.mutate().request(r -> r.headers(headers -> headers.setBearerAuth(accessToken.getTokenValue()))) + .build(); + } + +} diff --git a/spring-cloud-gateway-server-security/src/main/resources/META-INF/spring.factories b/spring-cloud-gateway-server-security/src/main/resources/META-INF/spring.factories new file mode 100644 index 00000000..74a8fbe1 --- /dev/null +++ b/spring-cloud-gateway-server-security/src/main/resources/META-INF/spring.factories @@ -0,0 +1,3 @@ +# Auto Configure +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ +org.springframework.cloud.gateway.security.TokenRelayAutoConfiguration diff --git a/spring-cloud-gateway-server-security/src/test/java/org/springframework/cloud/gateway/security/TokenRelayAutoConfigurationTests.java b/spring-cloud-gateway-server-security/src/test/java/org/springframework/cloud/gateway/security/TokenRelayAutoConfigurationTests.java new file mode 100644 index 00000000..05e7c0b1 --- /dev/null +++ b/spring-cloud-gateway-server-security/src/test/java/org/springframework/cloud/gateway/security/TokenRelayAutoConfigurationTests.java @@ -0,0 +1,59 @@ +/* + * Copyright 2014-2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.gateway.security; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.security.oauth2.client.reactive.ReactiveOAuth2ClientAutoConfiguration; +import org.springframework.boot.autoconfigure.security.reactive.ReactiveSecurityAutoConfiguration; +import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientManager; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Spencer Gibb + * + */ +public class TokenRelayAutoConfigurationTests { + + @Test + public void beansAreCreated() { + new ReactiveWebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(ReactiveSecurityAutoConfiguration.class, + ReactiveOAuth2ClientAutoConfiguration.class, TokenRelayAutoConfiguration.class)) + .withPropertyValues( + "spring.security.oauth2.client.provider[testprovider].authorization-uri=http://localhost", + "spring.security.oauth2.client.provider[testprovider].token-uri=http://localhost/token", + "spring.security.oauth2.client.registration[test].provider=testprovider", + "spring.security.oauth2.client.registration[test].authorization-grant-type=authorization_code", + "spring.security.oauth2.client.registration[test].redirect-uri=http://localhost/redirect", + "spring.security.oauth2.client.registration[test].client-id=login-client") + .withUserConfiguration(TestConfig.class).withPropertyValues("debug=true").run(context -> { + assertThat(context).hasSingleBean(ReactiveOAuth2AuthorizedClientManager.class); + assertThat(context).hasSingleBean(TokenRelayGatewayFilterFactory.class); + }); + } + + @Configuration + protected static class TestConfig { + + } + +} diff --git a/spring-cloud-gateway-server-security/src/test/java/org/springframework/cloud/gateway/security/TokenRelayGatewayFilterFactoryTests.java b/spring-cloud-gateway-server-security/src/test/java/org/springframework/cloud/gateway/security/TokenRelayGatewayFilterFactoryTests.java new file mode 100644 index 00000000..75acfcb1 --- /dev/null +++ b/spring-cloud-gateway-server-security/src/test/java/org/springframework/cloud/gateway/security/TokenRelayGatewayFilterFactoryTests.java @@ -0,0 +1,128 @@ +/* + * Copyright 2014-2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.gateway.security; + +import java.time.Duration; +import java.util.Collections; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import reactor.core.publisher.Mono; + +import org.springframework.cloud.gateway.filter.GatewayFilter; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.http.HttpHeaders; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.context.SecurityContextImpl; +import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.web.server.context.SecurityContextServerWebExchange; +import org.springframework.web.server.ServerWebExchange; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * @author Spencer Gibb + * + */ +public class TokenRelayGatewayFilterFactoryTests { + + private static final Duration TIMEOUT = Duration.ofSeconds(30); + + private ReactiveOAuth2AuthorizedClientManager authorizedClientManager; + + private MockServerHttpRequest request; + + private MockServerWebExchange mockExchange; + + private GatewayFilterChain filterChain; + + private GatewayFilter filter; + + public TokenRelayGatewayFilterFactoryTests() { + } + + @Before + public void init() { + request = MockServerHttpRequest.get("/hello").build(); + mockExchange = MockServerWebExchange.from(request); + filterChain = mock(GatewayFilterChain.class); + when(filterChain.filter(any(ServerWebExchange.class))).thenReturn(Mono.empty()); + + authorizedClientManager = mock(ReactiveOAuth2AuthorizedClientManager.class); + filter = new TokenRelayGatewayFilterFactory(authorizedClientManager).apply(); + } + + @After + public void after() { + } + + @Test + public void emptyPrincipal() { + filter.filter(mockExchange, filterChain).block(TIMEOUT); + assertThat(request.getHeaders()).doesNotContainKeys(HttpHeaders.AUTHORIZATION); + } + + @Test + public void whenPrincipalExistsAuthorizationHeaderAdded() { + OAuth2AccessToken accessToken = mock(OAuth2AccessToken.class); + when(accessToken.getTokenValue()).thenReturn("mytoken"); + + ClientRegistration clientRegistration = ClientRegistration.withRegistrationId("myregistrationid") + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS).clientId("myclientid") + .tokenUri("mytokenuri").build(); + OAuth2AuthorizedClient authorizedClient = new OAuth2AuthorizedClient(clientRegistration, "joe", accessToken); + + when(authorizedClientManager.authorize(any(OAuth2AuthorizeRequest.class))) + .thenReturn(Mono.just(authorizedClient)); + + OAuth2AuthenticationToken authenticationToken = new OAuth2AuthenticationToken(mock(OAuth2User.class), + Collections.emptyList(), "myId"); + SecurityContextImpl securityContext = new SecurityContextImpl(authenticationToken); + SecurityContextServerWebExchange exchange = new SecurityContextServerWebExchange(mockExchange, + Mono.just(securityContext)); + + filter.filter(exchange, filterChain).block(TIMEOUT); + + assertThat(request.getHeaders()).containsEntry(HttpHeaders.AUTHORIZATION, + Collections.singletonList("Bearer mytoken")); + } + + @Test + public void principalIsNotOAuth2AuthenticationToken() { + SecurityContextImpl securityContext = new SecurityContextImpl(new TestingAuthenticationToken("my", null)); + SecurityContextServerWebExchange exchange = new SecurityContextServerWebExchange(mockExchange, + Mono.just(securityContext)); + + filter.filter(exchange, filterChain).block(TIMEOUT); + + assertThat(request.getHeaders()).doesNotContainKeys(HttpHeaders.AUTHORIZATION); + } + +}