From 1cb8d02ed7d61308ec88925de9701474662aa2fa Mon Sep 17 00:00:00 2001 From: Chris Bono Date: Sun, 15 Mar 2026 20:05:29 -0700 Subject: [PATCH] Add Spring gRPC server and client security support Add auto-configuration to integrate gRPC server applications with Spring Security. This commit provides both standard Spring Security support as well as OAuth support. Closes gh-49047 Co-authored-by: Phillip Webb --- module/spring-boot-grpc-server/build.gradle | 2 + .../GrpcDisableCsrfHttpConfigurer.java | 86 +++++++ ...OAuth2ResourceServerAutoConfiguration.java | 76 ++++++ .../GrpcServerSecurityAutoConfiguration.java | 127 ++++++++++ .../autoconfigure/security/package-info.java | 23 ++ .../security/web/reactive/GrpcRequest.java | 148 ++++++++++++ .../security/web/reactive/package-info.java | 23 ++ .../security/web/servlet/GrpcRequest.java | 148 ++++++++++++ .../security/web/servlet/package-info.java | 23 ++ ...ot.autoconfigure.AutoConfiguration.imports | 2 + .../GrpcDisableCsrfHttpConfigurerTests.java | 167 +++++++++++++ ...2ResourceServerAutoConfigurationTests.java | 170 ++++++++++++++ ...cServerSecurityAutoConfigurationTests.java | 152 ++++++++++++ .../web/reactive/GrpcRequestTests.java | 114 +++++++++ .../web/servlet/GrpcRequestTests.java | 93 ++++++++ settings.gradle | 2 + .../build.gradle | 68 ++++++ .../grpcserveroauth/HelloWorldService.java | 60 +++++ .../SampleGrpcServerOAuthApplication.java | 29 +++ .../SecurityConfiguration.java | 61 +++++ .../grpcserveroauth/package-info.java | 20 ++ .../src/main/proto/hello.proto | 18 ++ .../src/main/resources/application.yaml | 21 ++ ...SampleGrpcServerOAuthApplicationTests.java | 220 ++++++++++++++++++ .../build.gradle | 66 ++++++ .../grpcserversecure/HelloWorldService.java | 68 ++++++ .../SampleGrpcServerSecureApplication.java | 29 +++ .../SecurityConfiguration.java | 64 +++++ .../grpcserversecure/package-info.java | 20 ++ .../src/main/proto/hello.proto | 19 ++ ...ampleGrpcServerSecureApplicationTests.java | 192 +++++++++++++++ 31 files changed, 2311 insertions(+) create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcDisableCsrfHttpConfigurer.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcServerOAuth2ResourceServerAutoConfiguration.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcServerSecurityAutoConfiguration.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/package-info.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/web/reactive/GrpcRequest.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/web/reactive/package-info.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/web/servlet/GrpcRequest.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/web/servlet/package-info.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcDisableCsrfHttpConfigurerTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcServerOAuth2ResourceServerAutoConfigurationTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcServerSecurityAutoConfigurationTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/web/reactive/GrpcRequestTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/web/servlet/GrpcRequestTests.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-oauth/build.gradle create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/java/smoketest/grpcserveroauth/HelloWorldService.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/java/smoketest/grpcserveroauth/SampleGrpcServerOAuthApplication.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/java/smoketest/grpcserveroauth/SecurityConfiguration.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/java/smoketest/grpcserveroauth/package-info.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/proto/hello.proto create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/resources/application.yaml create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/test/java/smoketest/grpcserveroauth/SampleGrpcServerOAuthApplicationTests.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-secure/build.gradle create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-secure/src/main/java/smoketest/grpcserversecure/HelloWorldService.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-secure/src/main/java/smoketest/grpcserversecure/SampleGrpcServerSecureApplication.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-secure/src/main/java/smoketest/grpcserversecure/SecurityConfiguration.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-secure/src/main/java/smoketest/grpcserversecure/package-info.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-secure/src/main/proto/hello.proto create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-secure/src/test/java/smoketest/grpcserversecure/SampleGrpcServerSecureApplicationTests.java diff --git a/module/spring-boot-grpc-server/build.gradle b/module/spring-boot-grpc-server/build.gradle index 581e145e640..28f6200c30e 100644 --- a/module/spring-boot-grpc-server/build.gradle +++ b/module/spring-boot-grpc-server/build.gradle @@ -31,6 +31,8 @@ dependencies { optional(project(":core:spring-boot-autoconfigure")) optional(project(":module:spring-boot-health")) optional(project(":module:spring-boot-micrometer-observation")) + optional(project(":module:spring-boot-security")) + optional(project(":module:spring-boot-security-oauth2-resource-server")) optional("com.fasterxml.jackson.core:jackson-annotations") optional("io.projectreactor:reactor-core") optional("io.grpc:grpc-servlet-jakarta") diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcDisableCsrfHttpConfigurer.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcDisableCsrfHttpConfigurer.java new file mode 100644 index 00000000000..fa407365734 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcDisableCsrfHttpConfigurer.java @@ -0,0 +1,86 @@ +/* + * Copyright 2012-present 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.boot.grpc.server.autoconfigure.security; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.beans.factory.BeanFactoryUtils; +import org.springframework.boot.grpc.server.GrpcServletRegistration; +import org.springframework.boot.grpc.server.autoconfigure.security.web.servlet.GrpcRequest; +import org.springframework.context.ApplicationContext; +import org.springframework.grpc.server.service.GrpcServiceDiscoverer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer; +import org.springframework.security.web.csrf.CsrfFilter; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.ObjectUtils; + +/** + * A custom {@link AbstractHttpConfigurer} that disables CSRF protection for gRPC + * requests. + *

+ * This configurer checks the application context to determine if CSRF protection should + * be disabled for gRPC requests based on the property + * {@code spring.grpc.server.security.csrf.enabled}. By default, CSRF protection is + * disabled unless explicitly enabled in the application properties. + *

+ * + * @author Dave Syer + * @see AbstractHttpConfigurer + * @see HttpSecurity + */ +class GrpcDisableCsrfHttpConfigurer extends AbstractHttpConfigurer { + + @Override + public void init(HttpSecurity http) { + ApplicationContext context = http.getSharedObject(ApplicationContext.class); + if (context != null && isCsrfConfigurerPresent(http) && hasBean(context, GrpcServiceDiscoverer.class) + && hasBean(context, GrpcServletRegistration.class) && isCsrfEnabled(context)) { + http.csrf(this::disable); + } + } + + @SuppressWarnings("unchecked") + private boolean isCsrfConfigurerPresent(HttpSecurity http) { + return http.getConfigurer(CsrfConfigurer.class) != null; + } + + private boolean hasBean(ApplicationContext context, Class type) { + return !ObjectUtils.isEmpty(BeanFactoryUtils.beanNamesForTypeIncludingAncestors(context, type)); + } + + private boolean isCsrfEnabled(ApplicationContext context) { + return context.getEnvironment().getProperty("spring.grpc.server.security.csrf.enabled", Boolean.class, true); + } + + private void disable(CsrfConfigurer csrf) { + csrf.requireCsrfProtectionMatcher(GrpcCsrfRequestMatcher.INSTANCE); + } + + static class GrpcCsrfRequestMatcher implements RequestMatcher { + + static GrpcCsrfRequestMatcher INSTANCE = new GrpcCsrfRequestMatcher(); + + @Override + public boolean matches(HttpServletRequest request) { + return CsrfFilter.DEFAULT_CSRF_MATCHER.matches(request) && !GrpcRequest.toAnyService().matches(request); + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcServerOAuth2ResourceServerAutoConfiguration.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcServerOAuth2ResourceServerAutoConfiguration.java new file mode 100644 index 00000000000..26350670885 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcServerOAuth2ResourceServerAutoConfiguration.java @@ -0,0 +1,76 @@ +/* + * Copyright 2012-present 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.boot.grpc.server.autoconfigure.security; + +import io.grpc.BindableService; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.grpc.server.autoconfigure.GrpcServerAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.grpc.server.GlobalServerInterceptor; +import org.springframework.grpc.server.GrpcServerFactory; +import org.springframework.grpc.server.security.AuthenticationProcessInterceptor; +import org.springframework.grpc.server.security.GrpcSecurity; +import org.springframework.security.config.ObjectPostProcessor; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector; + +import static org.springframework.security.config.Customizer.withDefaults; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for gRPC OAuth2 resource server. + * + * @author Dave Syer + * @author Andrey Litvitski + * @author Phillip Webb + * @since 4.1.0 + */ +@AutoConfiguration(beforeName = "org.springframework.boot.security.autoconfigure.UserDetailsServiceAutoConfiguration", + afterName = { "org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration", + "org.springframework.boot.security.oauth2.server.resource.autoconfigure.OAuth2ResourceServerAutoConfiguration" }, + after = { GrpcServerSecurityAutoConfiguration.class, GrpcServerAutoConfiguration.class }) +@ConditionalOnBooleanProperty(name = "spring.grpc.server.enabled", matchIfMissing = true) +@ConditionalOnClass({ BindableService.class, GrpcServerFactory.class, ObjectPostProcessor.class }) +@ConditionalOnMissingBean(AuthenticationProcessInterceptor.class) +@ConditionalOnBean(GrpcSecurity.class) +public final class GrpcServerOAuth2ResourceServerAutoConfiguration { + + @Bean + @ConditionalOnBean(OpaqueTokenIntrospector.class) + @GlobalServerInterceptor + AuthenticationProcessInterceptor opaqueTokenAuthenticationProcessInterceptor(GrpcSecurity grpcSecurity) + throws Exception { + grpcSecurity.authorizeRequests((requests) -> requests.allRequests().authenticated()); + grpcSecurity.oauth2ResourceServer((resourceServer) -> resourceServer.opaqueToken(withDefaults())); + return grpcSecurity.build(); + } + + @Bean + @ConditionalOnBean(JwtDecoder.class) + @GlobalServerInterceptor + AuthenticationProcessInterceptor jwtAuthenticationProcessInterceptor(GrpcSecurity grpcSecurity) throws Exception { + grpcSecurity.authorizeRequests((requests) -> requests.allRequests().authenticated()); + grpcSecurity.oauth2ResourceServer((resourceServer) -> resourceServer.jwt(withDefaults())); + return grpcSecurity.build(); + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcServerSecurityAutoConfiguration.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcServerSecurityAutoConfiguration.java new file mode 100644 index 00000000000..07ef0e7afc0 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcServerSecurityAutoConfiguration.java @@ -0,0 +1,127 @@ +/* + * Copyright 2012-present 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.boot.grpc.server.autoconfigure.security; + +import io.grpc.BindableService; +import io.grpc.internal.GrpcUtil; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.grpc.server.GrpcServletRegistration; +import org.springframework.boot.grpc.server.autoconfigure.GrpcServerAutoConfiguration; +import org.springframework.boot.grpc.server.autoconfigure.GrpcServerExecutorProvider; +import org.springframework.boot.grpc.server.autoconfigure.security.GrpcServerSecurityAutoConfiguration.ExceptionHandlerConfiguration; +import org.springframework.boot.grpc.server.autoconfigure.security.GrpcServerSecurityAutoConfiguration.GrpcNativeSecurityConfigurerConfiguration; +import org.springframework.boot.grpc.server.autoconfigure.security.GrpcServerSecurityAutoConfiguration.GrpcServletSecurityConfigurerConfiguration; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.grpc.server.GlobalServerInterceptor; +import org.springframework.grpc.server.GrpcServerFactory; +import org.springframework.grpc.server.security.CoroutineSecurityContextInterceptor; +import org.springframework.grpc.server.security.GrpcSecurity; +import org.springframework.grpc.server.security.SecurityContextServerInterceptor; +import org.springframework.grpc.server.security.SecurityGrpcExceptionHandler; +import org.springframework.security.concurrent.DelegatingSecurityContextExecutor; +import org.springframework.security.config.ObjectPostProcessor; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.authentication.configuration.EnableGlobalAuthentication; +import org.springframework.security.web.SecurityFilterChain; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for gRPC server-side security. + * + * @author Dave Syer + * @author Chris Bono + * @author Andrey Litvitski + * @author Phillip Webb + * @since 4.1.0 + */ +@AutoConfiguration(after = GrpcServerAutoConfiguration.class, + afterName = "org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration") +@ConditionalOnBooleanProperty(name = "spring.grpc.server.enabled", matchIfMissing = true) +@ConditionalOnClass({ BindableService.class, GrpcServerFactory.class, ObjectPostProcessor.class }) +@Import({ ExceptionHandlerConfiguration.class, GrpcNativeSecurityConfigurerConfiguration.class, + GrpcServletSecurityConfigurerConfiguration.class }) +public final class GrpcServerSecurityAutoConfiguration { + + @Configuration(proxyBeanMethods = false) + @Import(AuthenticationConfiguration.class) + static class ExceptionHandlerConfiguration { + + @Bean + SecurityGrpcExceptionHandler accessExceptionHandler() { + return new SecurityGrpcExceptionHandler(); + } + + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnBean(GrpcServerFactory.class) + @EnableGlobalAuthentication + static class GrpcNativeSecurityConfigurerConfiguration { + + @Bean + GrpcSecurity grpcSecurity(ApplicationContext context, ObjectPostProcessor objectPostProcessor, + AuthenticationConfiguration authenticationConfiguration) { + AuthenticationManagerBuilder authenticationManagerBuilder = authenticationConfiguration + .authenticationManagerBuilder(objectPostProcessor, context); + authenticationManagerBuilder + .parentAuthenticationManager(authenticationConfiguration.getAuthenticationManager()); + return new GrpcSecurity(objectPostProcessor, authenticationManagerBuilder, context); + } + + } + + @ConditionalOnBean({ GrpcServletRegistration.class, SecurityFilterChain.class }) + @Configuration(proxyBeanMethods = false) + static class GrpcServletSecurityConfigurerConfiguration { + + @Bean + @GlobalServerInterceptor + SecurityContextServerInterceptor securityContextInterceptor() { + return new SecurityContextServerInterceptor(); + } + + @Bean + @ConditionalOnMissingBean + GrpcServerExecutorProvider grpcServerExecutorProvider() { + return () -> new DelegatingSecurityContextExecutor(GrpcUtil.SHARED_CHANNEL_EXECUTOR.create()); + } + + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(name = "io.grpc.kotlin.CoroutineContextServerInterceptor") + static class GrpcClientCoroutineStubConfiguration { + + @Bean + @GlobalServerInterceptor + @ConditionalOnMissingBean + CoroutineSecurityContextInterceptor coroutineSecurityContextInterceptor() { + return new CoroutineSecurityContextInterceptor(); + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/package-info.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/package-info.java new file mode 100644 index 00000000000..264c92d6e9a --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/package-info.java @@ -0,0 +1,23 @@ +/* + * Copyright 2012-present 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. + */ + +/** + * Auto-configuration for gRPC server security. + */ +@NullMarked +package org.springframework.boot.grpc.server.autoconfigure.security; + +import org.jspecify.annotations.NullMarked; diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/web/reactive/GrpcRequest.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/web/reactive/GrpcRequest.java new file mode 100644 index 00000000000..c298429d767 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/web/reactive/GrpcRequest.java @@ -0,0 +1,148 @@ +/* + * Copyright 2012-present 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.boot.grpc.server.autoconfigure.security.web.reactive; + +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Supplier; +import java.util.stream.Stream; + +import org.jspecify.annotations.Nullable; +import reactor.core.publisher.Mono; + +import org.springframework.boot.grpc.server.autoconfigure.security.web.servlet.GrpcRequest.GrpcServletRequestMatcher; +import org.springframework.boot.security.web.reactive.ApplicationContextServerWebExchangeMatcher; +import org.springframework.grpc.server.service.GrpcServiceDiscoverer; +import org.springframework.security.web.server.util.matcher.OrServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.PathPatternParserServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.web.server.ServerWebExchange; + +/** + * Factory that can be used to create a {@link ServerWebExchangeMatcher} to match against + * gRPC service locations. + * + * @author Dave Syer + * @author Phillip Webb + * @since 4.1.0 + */ +public final class GrpcRequest { + + private GrpcRequest() { + } + + /** + * Returns a matcher that includes all gRPC services. The + * {@link GrpcReactiveRequestMatcher#excluding(String...) excluding} method can be + * used to remove specific services by name if required. For example: + * + *
+	 * GrpcReactiveRequest.toAnyService().excluding("my-service")
+	 * 
+ * @return the configured {@link ServerWebExchangeMatcher} + */ + public static GrpcReactiveRequestMatcher toAnyService() { + return new GrpcReactiveRequestMatcher(Collections.emptySet()); + } + + /** + * The matcher used to match against service locations. + */ + public static final class GrpcReactiveRequestMatcher + extends ApplicationContextServerWebExchangeMatcher { + + private static final ServerWebExchangeMatcher EMPTY_MATCHER = (exchange) -> MatchResult.notMatch(); + + private final Set excludes; + + private volatile @Nullable ServerWebExchangeMatcher delegate; + + private GrpcReactiveRequestMatcher(Set excludes) { + super(GrpcServiceDiscoverer.class); + this.excludes = excludes; + } + + /** + * Return a new {@link GrpcServletRequestMatcher} based on this one but excluding + * the specified services. + * @param services additional services to exclude + * @return a new {@link GrpcServletRequestMatcher} + */ + public GrpcReactiveRequestMatcher excluding(String... services) { + return excluding(Set.of(services)); + } + + /** + * Return a new {@link GrpcServletRequestMatcher} based on this one but excluding + * the specified services. + * @param services additional service names to exclude + * @return a new {@link GrpcServletRequestMatcher} + */ + public GrpcReactiveRequestMatcher excluding(Collection services) { + Assert.notNull(services, "'services' must not be null"); + Set excludes = new LinkedHashSet<>(this.excludes); + excludes.addAll(services); + return new GrpcReactiveRequestMatcher(excludes); + } + + @Override + protected void initialized(Supplier context) { + this.delegate = createDelegate(context.get()); + + } + + private ServerWebExchangeMatcher createDelegate(GrpcServiceDiscoverer serviceDiscoverer) { + List delegateMatchers = getDelegateMatchers(serviceDiscoverer); + return (!CollectionUtils.isEmpty(delegateMatchers)) ? new OrServerWebExchangeMatcher(delegateMatchers) + : EMPTY_MATCHER; + } + + private List getDelegateMatchers(GrpcServiceDiscoverer serviceDiscoverer) { + return getPatterns(serviceDiscoverer).map(this::getDelegateMatcher).toList(); + } + + private Stream getPatterns(GrpcServiceDiscoverer serviceDiscoverer) { + return serviceDiscoverer.listServiceNames().stream().filter(this::isExcluded).map(this::getPath); + } + + private boolean isExcluded(String service) { + return !this.excludes.stream().anyMatch((candidate) -> candidate.equals(service)); + } + + private String getPath(String service) { + return "/" + service + "/**"; + } + + private ServerWebExchangeMatcher getDelegateMatcher(String path) { + Assert.hasText(path, "'path' must not be empty"); + return new PathPatternParserServerWebExchangeMatcher(path); + } + + @Override + protected Mono matches(ServerWebExchange exchange, Supplier context) { + Assert.state(this.delegate != null, "'delegate' must not be null"); + return this.delegate.matches(exchange); + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/web/reactive/package-info.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/web/reactive/package-info.java new file mode 100644 index 00000000000..51f413b9a25 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/web/reactive/package-info.java @@ -0,0 +1,23 @@ +/* + * Copyright 2012-present 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. + */ + +/** + * Auto-configuration for gRPC web security when using a servlet stack. + */ +@NullMarked +package org.springframework.boot.grpc.server.autoconfigure.security.web.reactive; + +import org.jspecify.annotations.NullMarked; diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/web/servlet/GrpcRequest.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/web/servlet/GrpcRequest.java new file mode 100644 index 00000000000..f2eb644214f --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/web/servlet/GrpcRequest.java @@ -0,0 +1,148 @@ +/* + * Copyright 2012-present 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.boot.grpc.server.autoconfigure.security.web.servlet; + +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Supplier; +import java.util.stream.Stream; + +import jakarta.servlet.http.HttpServletRequest; +import org.jspecify.annotations.Nullable; + +import org.springframework.boot.security.web.servlet.ApplicationContextRequestMatcher; +import org.springframework.grpc.server.service.GrpcServiceDiscoverer; +import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; +import org.springframework.security.web.util.matcher.OrRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; + +/** + * Factory that can be used to create a {@link RequestMatcher} to match against gRPC + * service locations. + * + * @author Dave Syer + * @author Phillip Webb + * @since 4.1.0 + */ +public final class GrpcRequest { + + private static final RequestMatcher EMPTY_MATCHER = (request) -> false; + + private static final GrpcServletRequestMatcher TO_ANY_SERVICE = new GrpcServletRequestMatcher( + Collections.emptySet()); + + private GrpcRequest() { + } + + /** + * Returns a matcher that includes all gRPC services. The + * {@link GrpcServletRequestMatcher#excluding(String...) excluding} method can be used + * to remove specific services by name if required. For example: + * + *
+	 * GrpcServletRequest.toAnyService().excluding("my-service")
+	 * 
+ * @return the configured {@link RequestMatcher} + */ + public static GrpcServletRequestMatcher toAnyService() { + return TO_ANY_SERVICE; + } + + /** + * The matcher used to match against service locations. + */ + public static final class GrpcServletRequestMatcher + extends ApplicationContextRequestMatcher { + + private final Set excludes; + + private volatile @Nullable RequestMatcher delegate; + + private GrpcServletRequestMatcher(Set exclusions) { + super(GrpcServiceDiscoverer.class); + this.excludes = exclusions; + } + + /** + * Return a new {@link GrpcServletRequestMatcher} based on this one but excluding + * the specified services. + * @param services additional services to exclude + * @return a new {@link GrpcServletRequestMatcher} + */ + public GrpcServletRequestMatcher excluding(String... services) { + return excluding(Set.of(services)); + } + + /** + * Return a new {@link GrpcServletRequestMatcher} based on this one but excluding + * the specified services. + * @param services additional service names to exclude + * @return a new {@link GrpcServletRequestMatcher} + */ + public GrpcServletRequestMatcher excluding(Collection services) { + Assert.notNull(services, "'services' must not be null"); + Set excludes = new LinkedHashSet<>(this.excludes); + excludes.addAll(services); + return new GrpcServletRequestMatcher(excludes); + } + + @Override + protected void initialized(Supplier context) { + this.delegate = createDelegate(context.get()); + } + + private @Nullable RequestMatcher createDelegate(GrpcServiceDiscoverer grpcServiceDiscoverer) { + List delegateMatchers = getDelegateMatchers(grpcServiceDiscoverer); + return (!CollectionUtils.isEmpty(delegateMatchers)) ? new OrRequestMatcher(delegateMatchers) + : EMPTY_MATCHER; + } + + private List getDelegateMatchers(GrpcServiceDiscoverer serviceDiscoverer) { + return getPatterns(serviceDiscoverer).map(this::getDelegateMatcher).toList(); + } + + private Stream getPatterns(GrpcServiceDiscoverer serviceDiscoverer) { + return serviceDiscoverer.listServiceNames().stream().filter(this::isExcluded).map(this::getPath); + } + + private boolean isExcluded(String service) { + return !this.excludes.stream().anyMatch((candidate) -> candidate.equals(service)); + } + + private String getPath(String service) { + return "/" + service + "/**"; + } + + private RequestMatcher getDelegateMatcher(String path) { + Assert.hasText(path, "'path' must not be empty"); + return PathPatternRequestMatcher.withDefaults().matcher(path); + } + + @Override + protected boolean matches(HttpServletRequest request, Supplier context) { + Assert.state(this.delegate != null, "'delegate' must not be null"); + return this.delegate.matches(request); + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/web/servlet/package-info.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/web/servlet/package-info.java new file mode 100644 index 00000000000..441fff7c423 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/web/servlet/package-info.java @@ -0,0 +1,23 @@ +/* + * Copyright 2012-present 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. + */ + +/** + * Auto-configuration for gRPC web security when using a reactive stack. + */ +@NullMarked +package org.springframework.boot.grpc.server.autoconfigure.security.web.servlet; + +import org.jspecify.annotations.NullMarked; diff --git a/module/spring-boot-grpc-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/module/spring-boot-grpc-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 00f402214f4..1b4dd838523 100644 --- a/module/spring-boot-grpc-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/module/spring-boot-grpc-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -3,3 +3,5 @@ org.springframework.boot.grpc.server.autoconfigure.GrpcServerObservationAutoConf org.springframework.boot.grpc.server.autoconfigure.GrpcServerServicesAutoConfiguration org.springframework.boot.grpc.server.autoconfigure.health.GrpcServerHealthAutoConfiguration org.springframework.boot.grpc.server.autoconfigure.health.GrpcServerHealthSchedulerAutoConfiguration +org.springframework.boot.grpc.server.autoconfigure.security.GrpcServerOAuth2ResourceServerAutoConfiguration +org.springframework.boot.grpc.server.autoconfigure.security.GrpcServerSecurityAutoConfiguration diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcDisableCsrfHttpConfigurerTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcDisableCsrfHttpConfigurerTests.java new file mode 100644 index 00000000000..418b257a765 --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcDisableCsrfHttpConfigurerTests.java @@ -0,0 +1,167 @@ +/* + * Copyright 2012-present 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.boot.grpc.server.autoconfigure.security; + +import java.util.HashMap; + +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import org.springframework.boot.grpc.server.GrpcServletRegistration; +import org.springframework.boot.grpc.server.autoconfigure.security.GrpcDisableCsrfHttpConfigurer.GrpcCsrfRequestMatcher; +import org.springframework.boot.test.util.TestPropertyValues; +import org.springframework.context.ApplicationContext; +import org.springframework.context.support.StaticApplicationContext; +import org.springframework.grpc.server.service.GrpcServiceDiscoverer; +import org.springframework.security.config.ObjectPostProcessor; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer; +import org.springframework.security.web.util.matcher.RequestMatcher; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +/** + * Tests for {@link GrpcDisableCsrfHttpConfigurer}. + * + * @author Phillip Webb + */ +class GrpcDisableCsrfHttpConfigurerTests { + + private GrpcDisableCsrfHttpConfigurer configurer = new GrpcDisableCsrfHttpConfigurer(); + + @Test + void initDisablesCsrf() { + ObjectPostProcessor objectPostProcessor = ObjectPostProcessor.identity(); + AuthenticationManagerBuilder authenticationBuilder = new AuthenticationManagerBuilder(objectPostProcessor); + HttpSecurity http = new HttpSecurity(objectPostProcessor, authenticationBuilder, new HashMap<>()); + StaticApplicationContext applicationContext = addApplicationContext(http); + addServiceDiscover(applicationContext); + addGrpcServletRegistration(applicationContext); + CsrfConfigurer csrf = addCsrf(http); + this.configurer.init(http); + ArgumentCaptor matcher = ArgumentCaptor.captor(); + then(csrf).should().requireCsrfProtectionMatcher(matcher.capture()); + assertThat(matcher.getValue()).isSameAs(GrpcCsrfRequestMatcher.INSTANCE); + } + + @Test + void initWhenNoApplicationContextDoesNothing() { + ObjectPostProcessor objectPostProcessor = ObjectPostProcessor.identity(); + AuthenticationManagerBuilder authenticationBuilder = new AuthenticationManagerBuilder(objectPostProcessor); + HttpSecurity http = new HttpSecurity(objectPostProcessor, authenticationBuilder, new HashMap<>()); + CsrfConfigurer csrf = addCsrf(http); + this.configurer.init(http); + then(csrf).should(never()).requireCsrfProtectionMatcher(any()); + } + + @Test + @SuppressWarnings("unchecked") + void initWhenNoCsrfConfigurerDoesNothing() { + ObjectPostProcessor objectPostProcessor = ObjectPostProcessor.identity(); + AuthenticationManagerBuilder authenticationBuilder = new AuthenticationManagerBuilder(objectPostProcessor); + HttpSecurity http = new HttpSecurity(objectPostProcessor, authenticationBuilder, new HashMap<>()); + StaticApplicationContext applicationContext = addApplicationContext(http); + addServiceDiscover(applicationContext); + addGrpcServletRegistration(applicationContext); + this.configurer.init(http); + CsrfConfigurer csrfConfigurer = http.getConfigurer(CsrfConfigurer.class); + assertThat(csrfConfigurer).isNull(); + } + + @Test + void initWhenNoGrpcServiceDiscovererBeanDoesNothing() { + ObjectPostProcessor objectPostProcessor = ObjectPostProcessor.identity(); + AuthenticationManagerBuilder authenticationBuilder = new AuthenticationManagerBuilder(objectPostProcessor); + HttpSecurity http = new HttpSecurity(objectPostProcessor, authenticationBuilder, new HashMap<>()); + StaticApplicationContext applicationContext = addApplicationContext(http); + addGrpcServletRegistration(applicationContext); + CsrfConfigurer csrf = addCsrf(http); + this.configurer.init(http); + then(csrf).should(never()).requireCsrfProtectionMatcher(any()); + } + + @Test + void initWhenNoGrpcServletRegistrationBeanDoesNothing() { + ObjectPostProcessor objectPostProcessor = ObjectPostProcessor.identity(); + AuthenticationManagerBuilder authenticationBuilder = new AuthenticationManagerBuilder(objectPostProcessor); + HttpSecurity http = new HttpSecurity(objectPostProcessor, authenticationBuilder, new HashMap<>()); + StaticApplicationContext applicationContext = addApplicationContext(http); + addServiceDiscover(applicationContext); + CsrfConfigurer csrf = addCsrf(http); + this.configurer.init(http); + then(csrf).should(never()).requireCsrfProtectionMatcher(any()); + } + + @Test + void initWhenEnabledPropertyFalseDoesNothing() { + ObjectPostProcessor objectPostProcessor = ObjectPostProcessor.identity(); + AuthenticationManagerBuilder authenticationBuilder = new AuthenticationManagerBuilder(objectPostProcessor); + HttpSecurity http = new HttpSecurity(objectPostProcessor, authenticationBuilder, new HashMap<>()); + StaticApplicationContext applicationContext = addApplicationContext(http); + TestPropertyValues.of("spring.grpc.server.security.csrf.enabled=false").applyTo(applicationContext); + addServiceDiscover(applicationContext); + addGrpcServletRegistration(applicationContext); + CsrfConfigurer csrf = addCsrf(http); + this.configurer.init(http); + then(csrf).should(never()).requireCsrfProtectionMatcher(any()); + } + + @Test + void initWhenEnabledPropertyTrueDisablesCsrf() { + ObjectPostProcessor objectPostProcessor = ObjectPostProcessor.identity(); + AuthenticationManagerBuilder authenticationBuilder = new AuthenticationManagerBuilder(objectPostProcessor); + HttpSecurity http = new HttpSecurity(objectPostProcessor, authenticationBuilder, new HashMap<>()); + StaticApplicationContext applicationContext = addApplicationContext(http); + TestPropertyValues.of("spring.grpc.server.security.csrf.enabled=true").applyTo(applicationContext); + addServiceDiscover(applicationContext); + addGrpcServletRegistration(applicationContext); + CsrfConfigurer csrf = addCsrf(http); + this.configurer.init(http); + ArgumentCaptor matcher = ArgumentCaptor.captor(); + then(csrf).should().requireCsrfProtectionMatcher(matcher.capture()); + assertThat(matcher.getValue()).isSameAs(GrpcCsrfRequestMatcher.INSTANCE); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + private CsrfConfigurer addCsrf(HttpSecurity http) { + CsrfConfigurer csrf = mock(); + http.with((CsrfConfigurer) csrf); + return csrf; + } + + private StaticApplicationContext addApplicationContext(HttpSecurity http) { + StaticApplicationContext applicationContext = new StaticApplicationContext(); + http.setSharedObject(ApplicationContext.class, applicationContext); + return applicationContext; + } + + private void addServiceDiscover(StaticApplicationContext applicationContext) { + GrpcServiceDiscoverer serviceDiscoverer = mock(); + applicationContext.registerBean(GrpcServiceDiscoverer.class, serviceDiscoverer); + } + + private void addGrpcServletRegistration(StaticApplicationContext applicationContext) { + GrpcServletRegistration servletRegistration = mock(); + applicationContext.registerBean(GrpcServletRegistration.class, servletRegistration); + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcServerOAuth2ResourceServerAutoConfigurationTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcServerOAuth2ResourceServerAutoConfigurationTests.java new file mode 100644 index 00000000000..f20be2bf4ed --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcServerOAuth2ResourceServerAutoConfigurationTests.java @@ -0,0 +1,170 @@ +/* + * Copyright 2012-present 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.boot.grpc.server.autoconfigure.security; + +import io.grpc.BindableService; +import io.grpc.ServerServiceDefinition; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.logging.ConditionEvaluationReportLoggingListener; +import org.springframework.boot.context.annotation.UserConfigurations; +import org.springframework.boot.context.event.ApplicationFailedEvent; +import org.springframework.boot.logging.LogLevel; +import org.springframework.boot.security.autoconfigure.web.servlet.ServletWebSecurityAutoConfiguration; +import org.springframework.boot.security.oauth2.server.resource.autoconfigure.OAuth2ResourceServerAutoConfiguration; +import org.springframework.boot.test.context.assertj.ApplicationContextAssertProvider; +import org.springframework.boot.test.context.runner.AbstractApplicationContextRunner; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.boot.web.context.servlet.AnnotationConfigServletWebApplicationContext; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.grpc.server.GlobalServerInterceptor; +import org.springframework.grpc.server.lifecycle.GrpcServerLifecycle; +import org.springframework.grpc.server.security.AuthenticationProcessInterceptor; +import org.springframework.grpc.server.security.GrpcSecurity; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.ObjectPostProcessor; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link GrpcServerOAuth2ResourceServerAutoConfiguration}. + * + * @author Chris Bono + */ +class GrpcServerOAuth2ResourceServerAutoConfigurationTests { + + private static final AutoConfigurations autoConfigurations = AutoConfigurations + .of(OAuth2ResourceServerAutoConfiguration.class, GrpcServerOAuth2ResourceServerAutoConfiguration.class); + + private ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(autoConfigurations) + .withUserConfiguration(GrpcSecurityConfiguration.class) + .with(this::serviceBean) + .withBean("noopServerLifecycle", GrpcServerLifecycle.class, Mockito::mock); + + @Test + void jwtConfiguredWhenIssuerIsProvided() { + this.contextRunner + .withPropertyValues("spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:9000") + .run((context) -> assertThat(context).hasSingleBean(AuthenticationProcessInterceptor.class)); + } + + @Test + void jwtConfiguredWhenJwkSetIsProvided() { + this.contextRunner + .withPropertyValues("spring.security.oauth2.resourceserver.jwt.jwk-set-uri=http://localhost:9000") + .run((context) -> assertThat(context).hasSingleBean(AuthenticationProcessInterceptor.class)); + } + + @Test + void customInterceptorWhenJwkSetIsProvided() { + this.contextRunner.withInitializer(ConditionEvaluationReportLoggingListener.forLogLevel(LogLevel.INFO)) + .withConfiguration(UserConfigurations.of(CustomInterceptorConfiguration.class)) + .withPropertyValues("spring.security.oauth2.resourceserver.jwt.jwk-set-uri=http://localhost:9000") + .run((context) -> assertThat(context).hasSingleBean(AuthenticationProcessInterceptor.class)); + } + + @Test + void notConfiguredWhenIssuerNotProvided() { + this.contextRunner + .run((context) -> assertThat(context).doesNotHaveBean(AuthenticationProcessInterceptor.class)); + } + + @Test + void notConfiguredInWebApplication() { + new WebApplicationContextRunner().withConfiguration(autoConfigurations) + .withConfiguration(AutoConfigurations.of(ServletWebSecurityAutoConfiguration.class, + OAuth2ResourceServerAutoConfiguration.class)) + .with(this::serviceBean) + .withPropertyValues("spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:9000") + .run((context) -> assertThat(context).doesNotHaveBean(AuthenticationProcessInterceptor.class)); + } + + @Test + void notConfiguredInWebApplicationWithNoBindableService() { + new WebApplicationContextRunner().withConfiguration(autoConfigurations) + .withConfiguration(AutoConfigurations.of(ServletWebSecurityAutoConfiguration.class, + OAuth2ResourceServerAutoConfiguration.class)) + .withPropertyValues("spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:9000") + .run((context) -> assertThat(context).doesNotHaveBean(AuthenticationProcessInterceptor.class)); + } + + private , C extends ConfigurableApplicationContext, A extends ApplicationContextAssertProvider> R serviceBean( + R contextRunner) { + BindableService service = mock(); + ServerServiceDefinition serviceDefinition = ServerServiceDefinition.builder("my-service").build(); + given(service.bindService()).willReturn(serviceDefinition); + return contextRunner.withBean(BindableService.class, () -> service); + } + + static class FailingApplicationFailedEventContext extends AnnotationConfigServletWebApplicationContext { + + @Override + public void refresh() { + try { + super.refresh(); + } + catch (Throwable ex) { + publishEvent(new ApplicationFailedEvent(new SpringApplication(this), new String[0], this, ex)); + throw ex; + } + } + + } + + @Configuration(proxyBeanMethods = false) + static class CustomInterceptorConfiguration { + + @Bean + @GlobalServerInterceptor + AuthenticationProcessInterceptor jwtSecurityFilterChain(GrpcSecurity grpc) throws Exception { + return grpc.authorizeRequests((requests) -> requests.allRequests().authenticated()) + .oauth2ResourceServer((resourceServer) -> resourceServer.jwt(Customizer.withDefaults())) + .build(); + } + + } + + @Configuration(proxyBeanMethods = false) + @EnableWebSecurity + static class GrpcSecurityConfiguration { + + @Bean + GrpcSecurity grpcSecurity(ApplicationContext context, ObjectPostProcessor objectPostProcessor, + AuthenticationConfiguration authenticationConfiguration) { + AuthenticationManagerBuilder authenticationManagerBuilder = authenticationConfiguration + .authenticationManagerBuilder(objectPostProcessor, context); + authenticationManagerBuilder + .parentAuthenticationManager(authenticationConfiguration.getAuthenticationManager()); + return new GrpcSecurity(objectPostProcessor, authenticationManagerBuilder, context); + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcServerSecurityAutoConfigurationTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcServerSecurityAutoConfigurationTests.java new file mode 100644 index 00000000000..f3085d633a2 --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcServerSecurityAutoConfigurationTests.java @@ -0,0 +1,152 @@ +/* + * Copyright 2012-present 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.boot.grpc.server.autoconfigure.security; + +import io.grpc.BindableService; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.logging.ConditionEvaluationReportLoggingListener; +import org.springframework.boot.grpc.server.GrpcServletRegistration; +import org.springframework.boot.grpc.server.autoconfigure.GrpcServerExecutorProvider; +import org.springframework.boot.logging.LogLevel; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.grpc.server.GrpcServerFactory; +import org.springframework.grpc.server.exception.GrpcExceptionHandler; +import org.springframework.grpc.server.lifecycle.GrpcServerLifecycle; +import org.springframework.grpc.server.security.AuthenticationProcessInterceptor; +import org.springframework.grpc.server.security.GrpcSecurity; +import org.springframework.grpc.server.security.SecurityContextServerInterceptor; +import org.springframework.grpc.server.security.SecurityGrpcExceptionHandler; +import org.springframework.security.config.ObjectPostProcessor; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.web.SecurityFilterChain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link GrpcServerSecurityAutoConfiguration}. + * + * @author Chris Bono + */ +class GrpcServerSecurityAutoConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GrpcServerSecurityAutoConfiguration.class)) + .withBean("noopServerLifecycle", GrpcServerLifecycle.class, Mockito::mock); + + @Test + void whenSpringSecurityNotOnClasspathAutoConfigurationIsSkipped() { + this.contextRunner.withClassLoader(new FilteredClassLoader(ObjectPostProcessor.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerSecurityAutoConfiguration.class)); + } + + @Test + void whenGrpcNotOnClasspathAutoConfigurationIsSkipped() { + this.contextRunner.withClassLoader(new FilteredClassLoader(BindableService.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerSecurityAutoConfiguration.class)); + } + + @Test + void whenSpringGrpcNotOnClasspathAutoConfigurationIsSkipped() { + this.contextRunner.withClassLoader(new FilteredClassLoader(GrpcServerFactory.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerSecurityAutoConfiguration.class)); + } + + @Test + void whenSpringGrpcAndSpringSecurityPresentAndUsingGrpcServletCreatesGrpcSecurity() { + new WebApplicationContextRunner() + .withInitializer(ConditionEvaluationReportLoggingListener.forLogLevel(LogLevel.INFO)) + .withConfiguration(AutoConfigurations.of(GrpcServerSecurityAutoConfiguration.class)) + .withUserConfiguration(ServletConfiguration.class) + .run((context) -> { + assertThat(context).hasSingleBean(SecurityContextServerInterceptor.class); + assertThat(context).hasSingleBean(GrpcServerExecutorProvider.class); + }); + } + + @Test + void whenSpringGrpcAndSpringSecurityPresentAndUsingGrpcNativeCreatesGrpcSecurity() { + new ApplicationContextRunner() + .withInitializer(ConditionEvaluationReportLoggingListener.forLogLevel(LogLevel.INFO)) + .withConfiguration(AutoConfigurations.of(GrpcServerSecurityAutoConfiguration.class)) + .withUserConfiguration(NativeConfiguration.class) + .run((context) -> assertThat(context).hasSingleBean(GrpcSecurity.class)); + } + + @Test + void whenServerEnabledPropertySetFalseThenAutoConfigurationIsSkipped() { + this.contextRunner.withPropertyValues("spring.grpc.server.enabled=false") + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerSecurityAutoConfiguration.class)); + } + + @Test + void whenServerEnabledPropertyNotSetThenAutoConfigurationIsNotSkipped() { + this.contextRunner + .run((context) -> assertThat(context).hasSingleBean(GrpcServerSecurityAutoConfiguration.class)); + } + + @Test + void whenServerEnabledPropertySetTrueThenAutoConfigurationIsNotSkipped() { + this.contextRunner.withPropertyValues("spring.grpc.server.enabled=true") + .run((context) -> assertThat(context).hasSingleBean(GrpcServerSecurityAutoConfiguration.class)); + } + + @Test + void grpcSecurityAutoConfiguredAsExpected() { + this.contextRunner.run((context) -> { + assertThat(context).getBean(GrpcExceptionHandler.class).isInstanceOf(SecurityGrpcExceptionHandler.class); + assertThat(context).getBean(AuthenticationProcessInterceptor.class).isNull(); + }); + } + + @Configuration(proxyBeanMethods = false) + @EnableWebSecurity + static class ServletConfiguration { + + @Bean + GrpcServletRegistration grpcServletRegistration() { + return new GrpcServletRegistration(mock(), mock()); + } + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) { + return http.authorizeHttpRequests((requests) -> requests.anyRequest().permitAll()).build(); + } + + } + + @Configuration(proxyBeanMethods = false) + @EnableMethodSecurity + static class NativeConfiguration { + + @Bean + GrpcServerFactory grpcServerFactory() { + return mock(); + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/web/reactive/GrpcRequestTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/web/reactive/GrpcRequestTests.java new file mode 100644 index 00000000000..860e086745c --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/web/reactive/GrpcRequestTests.java @@ -0,0 +1,114 @@ +/* + * Copyright 2012-present 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.boot.grpc.server.autoconfigure.security.web.reactive; + +import io.grpc.BindableService; +import io.grpc.ServerServiceDefinition; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.grpc.server.autoconfigure.security.web.reactive.GrpcRequest.GrpcReactiveRequestMatcher; +import org.springframework.boot.web.context.reactive.GenericReactiveWebApplicationContext; +import org.springframework.context.ApplicationContext; +import org.springframework.grpc.server.service.DefaultGrpcServiceDiscoverer; +import org.springframework.grpc.server.service.GrpcServiceDiscoverer; +import org.springframework.http.codec.ServerCodecConfigurer; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.http.server.reactive.MockServerHttpResponse; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher.MatchResult; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.i18n.AcceptHeaderLocaleContextResolver; +import org.springframework.web.server.session.DefaultWebSessionManager; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link GrpcRequest}. + * + * @author Dave Syer + * @author Phillip Webb + */ +class GrpcRequestTests { + + private GenericReactiveWebApplicationContext context = new GenericReactiveWebApplicationContext(); + + @BeforeEach + void setup() { + MockService service1 = mock(); + given(service1.bindService()).willReturn(ServerServiceDefinition.builder("my-service").build()); + MockService service2 = mock(); + given(service2.bindService()).willReturn(ServerServiceDefinition.builder("my-other-service").build()); + this.context.registerBean("s1", BindableService.class, () -> service1); + this.context.registerBean("s2", BindableService.class, () -> service2); + this.context.registerBean(GrpcServiceDiscoverer.class, () -> new DefaultGrpcServiceDiscoverer(this.context)); + this.context.refresh(); + } + + @Test + void whenToAnyService() { + GrpcReactiveRequestMatcher matcher = GrpcRequest.toAnyService(); + assertThat(isMatch(matcher, "/my-service/Method")).isTrue(); + assertThat(isMatch(matcher, "/my-service/Other")).isTrue(); + assertThat(isMatch(matcher, "/my-other-service/Other")).isTrue(); + assertThat(isMatch(matcher, "/notaservice")).isFalse(); + } + + @Test + void whenToAnyServiceWithExclude() { + GrpcReactiveRequestMatcher matcher = GrpcRequest.toAnyService().excluding("my-other-service"); + assertThat(isMatch(matcher, "/my-service/Method")).isTrue(); + assertThat(isMatch(matcher, "/my-service/Other")).isTrue(); + assertThat(isMatch(matcher, "/my-other-service/Other")).isFalse(); + assertThat(isMatch(matcher, "/notaservice")).isFalse(); + } + + private boolean isMatch(GrpcReactiveRequestMatcher matcher, String path) { + MockExchange request = mockRequest(path); + MatchResult result = matcher.matches(request).block(); + return (result != null) && result.isMatch(); + } + + private MockExchange mockRequest(String path) { + MockServerHttpRequest servletContext = MockServerHttpRequest.get(path).build(); + MockExchange request = new MockExchange(servletContext, this.context); + return request; + } + + interface MockService extends BindableService { + + } + + static class MockExchange extends DefaultServerWebExchange { + + private ApplicationContext context; + + MockExchange(MockServerHttpRequest request, ApplicationContext context) { + super(request, new MockServerHttpResponse(), new DefaultWebSessionManager(), ServerCodecConfigurer.create(), + new AcceptHeaderLocaleContextResolver()); + this.context = context; + } + + @Override + public ApplicationContext getApplicationContext() { + return this.context; + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/web/servlet/GrpcRequestTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/web/servlet/GrpcRequestTests.java new file mode 100644 index 00000000000..87763de2a49 --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/web/servlet/GrpcRequestTests.java @@ -0,0 +1,93 @@ +/* + * Copyright 2012-present 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.boot.grpc.server.autoconfigure.security.web.servlet; + +import io.grpc.BindableService; +import io.grpc.ServerServiceDefinition; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.grpc.server.autoconfigure.security.web.servlet.GrpcRequest.GrpcServletRequestMatcher; +import org.springframework.grpc.server.service.DefaultGrpcServiceDiscoverer; +import org.springframework.grpc.server.service.GrpcServiceDiscoverer; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockServletContext; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.context.support.GenericWebApplicationContext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Test for {@link GrpcRequest}. + * + * @author Dave Syer + * @author Phillip Webb + */ +class GrpcRequestTests { + + private GenericWebApplicationContext context = new GenericWebApplicationContext(); + + @BeforeEach + void setup() { + MockService service1 = mock(); + given(service1.bindService()).willReturn(ServerServiceDefinition.builder("my-service").build()); + MockService service2 = mock(); + given(service2.bindService()).willReturn(ServerServiceDefinition.builder("my-other-service").build()); + this.context.registerBean("s1", BindableService.class, () -> service1); + this.context.registerBean("s2", BindableService.class, () -> service2); + this.context.registerBean(GrpcServiceDiscoverer.class, () -> new DefaultGrpcServiceDiscoverer(this.context)); + this.context.refresh(); + } + + @Test + void whenToAnyService() { + GrpcServletRequestMatcher matcher = GrpcRequest.toAnyService(); + assertThat(isMatch(matcher, "/my-service/Method")).isTrue(); + assertThat(isMatch(matcher, "/my-service/Other")).isTrue(); + assertThat(isMatch(matcher, "/my-other-service/Other")).isTrue(); + assertThat(isMatch(matcher, "/notaservice")).isFalse(); + } + + @Test + void whenToAnyServiceWithExclude() { + GrpcServletRequestMatcher matcher = GrpcRequest.toAnyService().excluding("my-other-service"); + assertThat(isMatch(matcher, "/my-service/Method")).isTrue(); + assertThat(isMatch(matcher, "/my-service/Other")).isTrue(); + assertThat(isMatch(matcher, "/my-other-service/Other")).isFalse(); + assertThat(isMatch(matcher, "/notaservice")).isFalse(); + } + + private boolean isMatch(GrpcServletRequestMatcher matcher, String path) { + MockHttpServletRequest request = mockRequest(path); + return matcher.matches(request); + } + + private MockHttpServletRequest mockRequest(String path) { + MockServletContext servletContext = new MockServletContext(); + servletContext.setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, this.context); + MockHttpServletRequest request = new MockHttpServletRequest(servletContext); + request.setRequestURI(path); + return request; + } + + interface MockService extends BindableService { + + } + +} diff --git a/settings.gradle b/settings.gradle index 28dffcc3a6a..94d968336d5 100644 --- a/settings.gradle +++ b/settings.gradle @@ -431,7 +431,9 @@ include ":smoke-test:spring-boot-smoke-test-grpc-client" include ":smoke-test:spring-boot-smoke-test-grpc-client-test" include ":smoke-test:spring-boot-smoke-test-grpc-server" include ":smoke-test:spring-boot-smoke-test-grpc-server-netty-shaded" +include ":smoke-test:spring-boot-smoke-test-grpc-server-oauth" include ":smoke-test:spring-boot-smoke-test-grpc-server-servlet" +include ":smoke-test:spring-boot-smoke-test-grpc-server-secure" include ":smoke-test:spring-boot-smoke-test-grpc-server-test" include ":smoke-test:spring-boot-smoke-test-hateoas" include ":smoke-test:spring-boot-smoke-test-hibernate" diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-oauth/build.gradle b/smoke-test/spring-boot-smoke-test-grpc-server-oauth/build.gradle new file mode 100644 index 00000000000..758cc967819 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-oauth/build.gradle @@ -0,0 +1,68 @@ +/* + * Copyright 2012-present 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. + */ + +plugins { + id "java" + id "com.google.protobuf" version "${protobufGradlePluginVersion}" + id "org.springframework.boot.docker-test" +} + +description = "Spring Boot gRPC server oauth smoke test" + +dependencies { + implementation(project(":starter:spring-boot-starter-oauth2-authorization-server")) + implementation(project(":starter:spring-boot-starter-oauth2-resource-server")) + implementation(project(":starter:spring-boot-starter-grpc-server")) + + testImplementation(project(":starter:spring-boot-starter-grpc-client")) + testImplementation(project(":starter:spring-boot-starter-grpc-test")) + testImplementation(project(":starter:spring-boot-starter-oauth2-client")) + testImplementation(project(":starter:spring-boot-starter-test")) +} + +def dependenciesBom = project(":platform:spring-boot-dependencies").extensions.getByName("bom") +def grpcJava = dependenciesBom.getLibrary("Grpc Java") +def protobufJava = dependenciesBom.getLibrary("Protobuf Java") + +tasks.named("compileTestJava") { + options.nullability.checking = "tests" +} + +nullability { + requireExplicitNullMarking = false +} + +configurations.named { it.startsWith("protobufToolsLocator_") || it.toLowerCase().endsWith("protopath") }.all { + extendsFrom(configurations.dependencyManagement) +} + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:${protobufJava.version}" + } + plugins { + grpc { + artifact = "io.grpc:protoc-gen-grpc-java:${grpcJava.version}" + } + } + generateProtoTasks { + all()*.plugins { + grpc { + option '@generated=omit' + } + } + } +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/java/smoketest/grpcserveroauth/HelloWorldService.java b/smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/java/smoketest/grpcserveroauth/HelloWorldService.java new file mode 100644 index 00000000000..e7f8ca8ab90 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/java/smoketest/grpcserveroauth/HelloWorldService.java @@ -0,0 +1,60 @@ +/* + * Copyright 2012-present 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 smoketest.grpcserveroauth; + +import io.grpc.stub.StreamObserver; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import smoketest.grpcserveroauth.proto.HelloReply; +import smoketest.grpcserveroauth.proto.HelloRequest; +import smoketest.grpcserveroauth.proto.HelloWorldGrpc; + +import org.springframework.stereotype.Service; +import org.springframework.util.Assert; + +@Service +public class HelloWorldService extends HelloWorldGrpc.HelloWorldImplBase { + + private static Log logger = LogFactory.getLog(HelloWorldService.class); + + @Override + public void sayHelloProfileScope(HelloRequest request, StreamObserver responseObserver) { + sayHello("sayHelloProfileScope", request, responseObserver); + } + + @Override + public void sayHelloEmailScope(HelloRequest request, StreamObserver responseObserver) { + sayHello("sayHelloEmailScope", request, responseObserver); + } + + @Override + public void sayHelloAuthenticated(HelloRequest request, StreamObserver responseObserver) { + sayHello("sayHelloAuthenticated", request, responseObserver); + } + + public void sayHello(String methodName, HelloRequest request, StreamObserver responseObserver) { + String name = request.getName(); + logger.info(methodName + " " + name); + Assert.isTrue(!name.startsWith("error"), () -> "Bad name: " + name); + Assert.state(!name.startsWith("internal"), "Internal error"); + String message = "%s '%s'".formatted(methodName, name); + HelloReply reply = HelloReply.newBuilder().setMessage(message).build(); + responseObserver.onNext(reply); + responseObserver.onCompleted(); + } + +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/java/smoketest/grpcserveroauth/SampleGrpcServerOAuthApplication.java b/smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/java/smoketest/grpcserveroauth/SampleGrpcServerOAuthApplication.java new file mode 100644 index 00000000000..66cce5e1310 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/java/smoketest/grpcserveroauth/SampleGrpcServerOAuthApplication.java @@ -0,0 +1,29 @@ +/* + * Copyright 2012-present 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 smoketest.grpcserveroauth; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SampleGrpcServerOAuthApplication { + + public static void main(String[] args) { + SpringApplication.run(SampleGrpcServerOAuthApplication.class, args); + } + +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/java/smoketest/grpcserveroauth/SecurityConfiguration.java b/smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/java/smoketest/grpcserveroauth/SecurityConfiguration.java new file mode 100644 index 00000000000..6f7405ed52f --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/java/smoketest/grpcserveroauth/SecurityConfiguration.java @@ -0,0 +1,61 @@ +/* + * Copyright 2012-present 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 smoketest.grpcserveroauth; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.grpc.server.GlobalServerInterceptor; +import org.springframework.grpc.server.security.AuthenticationProcessInterceptor; +import org.springframework.grpc.server.security.GrpcSecurity; +import org.springframework.grpc.server.security.RequestMapperConfigurer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.SecurityFilterChain; + +import static org.springframework.security.config.Customizer.withDefaults; + +@Configuration(proxyBeanMethods = false) +public class SecurityConfiguration { + + @Bean + @GlobalServerInterceptor + AuthenticationProcessInterceptor securityInterceptor(GrpcSecurity grpcSecurity) throws Exception { + return grpcSecurity.authorizeRequests(this::authorizeGrpcRequests) + .oauth2ResourceServer((resourceServer) -> resourceServer.jwt(withDefaults())) + .build(); + } + + private void authorizeGrpcRequests(RequestMapperConfigurer requests) { + requests.methods("HelloWorld/SayHelloProfileScope").hasAuthority("SCOPE_profile"); + requests.methods("HelloWorld/SayHelloEmailScope").hasAuthority("SCOPE_email"); + requests.methods("HelloWorld/SayHelloAuthenticated").authenticated(); + requests.methods("grpc.*/*").permitAll(); + requests.allRequests().denyAll(); + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) { + http.oauth2AuthorizationServer((authorizationServer) -> { + http.securityMatcher(authorizationServer.getEndpointsMatcher()); + authorizationServer.oidc(withDefaults()); + }); + http.oauth2ResourceServer((resourceServer) -> resourceServer.jwt(withDefaults())); + http.authorizeHttpRequests((requests) -> requests.anyRequest().permitAll()); + http.csrf((csrf) -> csrf.disable()); + return http.build(); + } + +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/java/smoketest/grpcserveroauth/package-info.java b/smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/java/smoketest/grpcserveroauth/package-info.java new file mode 100644 index 00000000000..ab81b93206f --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/java/smoketest/grpcserveroauth/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-present 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. + */ + +@NullMarked +package smoketest.grpcserveroauth; + +import org.jspecify.annotations.NullMarked; diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/proto/hello.proto b/smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/proto/hello.proto new file mode 100644 index 00000000000..e443b360171 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/proto/hello.proto @@ -0,0 +1,18 @@ +syntax = "proto3"; + +option java_package = "smoketest.grpcserveroauth.proto"; +option java_multiple_files = true; + +service HelloWorld { + rpc SayHelloProfileScope (HelloRequest) returns (HelloReply) {} + rpc SayHelloEmailScope (HelloRequest) returns (HelloReply) {} + rpc SayHelloAuthenticated (HelloRequest) returns (HelloReply) {} +} + +message HelloRequest { + string name = 1; +} + +message HelloReply { + string message = 1; +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/resources/application.yaml b/smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/resources/application.yaml new file mode 100644 index 00000000000..c9257e42c1e --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/resources/application.yaml @@ -0,0 +1,21 @@ +logging.level.org.springframework.security: TRACE +spring: + security: + oauth2: + authorizationserver: + client: + oidc-client: + registration: + client-id: "spring" + client-secret: "{noop}secret" + client-authentication-methods: + - "client_secret_basic" + authorization-grant-types: + - "client_credentials" + - "refresh_token" + scopes: + - "openid" + - "profile" + resourceserver: + jwt: + jwk-set-uri: http://localhost:8080/oauth2/jwks diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/test/java/smoketest/grpcserveroauth/SampleGrpcServerOAuthApplicationTests.java b/smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/test/java/smoketest/grpcserveroauth/SampleGrpcServerOAuthApplicationTests.java new file mode 100644 index 00000000000..9bfeb8bab90 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/test/java/smoketest/grpcserveroauth/SampleGrpcServerOAuthApplicationTests.java @@ -0,0 +1,220 @@ +/* + * Copyright 2012-present 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 smoketest.grpcserveroauth; + +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; + +import io.grpc.ManagedChannelBuilder; +import io.grpc.Status.Code; +import io.grpc.StatusRuntimeException; +import io.grpc.reflection.v1.ServerReflectionGrpc.ServerReflectionStub; +import io.grpc.reflection.v1.ServerReflectionRequest; +import io.grpc.reflection.v1.ServerReflectionResponse; +import io.grpc.stub.StreamObserver; +import org.awaitility.Awaitility; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; +import smoketest.grpcserveroauth.proto.HelloReply; +import smoketest.grpcserveroauth.proto.HelloRequest; +import smoketest.grpcserveroauth.proto.HelloWorldGrpc.HelloWorldBlockingStub; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.core.env.Environment; +import org.springframework.grpc.client.GrpcChannelBuilderCustomizer; +import org.springframework.grpc.client.ImportGrpcClients; +import org.springframework.grpc.client.interceptor.security.BearerTokenAuthenticationInterceptor; +import org.springframework.security.oauth2.client.endpoint.OAuth2ClientCredentialsGrantRequest; +import org.springframework.security.oauth2.client.endpoint.RestClientClientCredentialsTokenResponseClient; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; +import org.springframework.security.oauth2.client.registration.SupplierClientRegistrationRepository; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder.JwkSetUriJwtDecoderBuilder; +import org.springframework.security.oauth2.jwt.SupplierJwtDecoder; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.util.function.SingletonSupplier; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, + properties = { "spring.grpc.server.port=0", + "spring.grpc.client.channel.default.target=static://localhost:${local.grpc.server.port}", + "spring.grpc.client.channel.oauth.target=static://localhost:${local.grpc.server.port}" }) +@DirtiesContext +class SampleGrpcServerOAuthApplicationTests { + + @Autowired + @Qualifier("unauthenticatedHelloWorldBlockingStub") + private HelloWorldBlockingStub unuathenticated; + + @Autowired + @Qualifier("oauthHelloWorldBlockingStub") + private HelloWorldBlockingStub oauth; + + @Autowired + private ServerReflectionStub reflection; + + @Test + void whenUnauthenticatedStub() { + HelloWorldBlockingStub stub = this.unuathenticated; + assertThatExceptionOfType(StatusRuntimeException.class).isThrownBy(() -> invoke(stub::sayHelloProfileScope)) + .satisfies(statusCode(Code.UNAUTHENTICATED)); + assertThatExceptionOfType(StatusRuntimeException.class).isThrownBy(() -> invoke(stub::sayHelloEmailScope)) + .satisfies(statusCode(Code.UNAUTHENTICATED)); + assertThatExceptionOfType(StatusRuntimeException.class).isThrownBy(() -> invoke(stub::sayHelloAuthenticated)) + .satisfies(statusCode(Code.UNAUTHENTICATED)); + assertCanInvokeReflection(); + } + + @Test + void whenOAuth() { + HelloWorldBlockingStub stub = this.oauth; + assertThat(invoke(stub::sayHelloProfileScope)).isEqualTo("sayHelloProfileScope 'Spring'"); + assertThatExceptionOfType(StatusRuntimeException.class).isThrownBy(() -> invoke(stub::sayHelloEmailScope)) + .satisfies(statusCode(Code.PERMISSION_DENIED)); + assertThat(invoke(stub::sayHelloAuthenticated)).isEqualTo("sayHelloAuthenticated 'Spring'"); + assertCanInvokeReflection(); + } + + private String invoke(Function method) { + HelloRequest request = HelloRequest.newBuilder().setName("Spring").build(); + return method.apply(request).getMessage(); + } + + private void assertCanInvokeReflection() { + ObservedResponse response = invokeReflection(); + assertThat(response.getValue()).isNotNull(); + assertThat(response.getError()).isNull(); + } + + private ObservedResponse invokeReflection() { + ObservedResponse response = new ObservedResponse<>(); + StreamObserver request = this.reflection.serverReflectionInfo(response); + request.onNext(ServerReflectionRequest.newBuilder().setListServices("").build()); + request.onCompleted(); + response.await(); + return response; + } + + private Consumer statusCode(Code expected) { + return (ex) -> assertThat(ex).extracting("status.code").isEqualTo(expected); + } + + @TestConfiguration(proxyBeanMethods = false) + @ImportGrpcClients(types = ServerReflectionStub.class) + @ImportGrpcClients(prefix = "unauthenticated", types = HelloWorldBlockingStub.class) + @ImportGrpcClients(target = "oauth", prefix = "oauth", types = HelloWorldBlockingStub.class) + static class GrpcClientTestConfiguration { + + @Bean + > GrpcChannelBuilderCustomizer channelSecurityCustomizer( + ObjectProvider clientRegistrationRepository) { + return GrpcChannelBuilderCustomizer.matching("oauth", (builder) -> { + Supplier tokenSupplier = SingletonSupplier + .of(() -> token(clientRegistrationRepository.getObject())); + builder.intercept(new BearerTokenAuthenticationInterceptor(tokenSupplier)); + }); + } + + private String token(ClientRegistrationRepository clientRegistrationRepository) { + RestClientClientCredentialsTokenResponseClient client = new RestClientClientCredentialsTokenResponseClient(); + ClientRegistration registration = clientRegistrationRepository.findByRegistrationId("spring"); + OAuth2ClientCredentialsGrantRequest request = new OAuth2ClientCredentialsGrantRequest(registration); + return client.getTokenResponse(request).getAccessToken().getTokenValue(); + } + + @Bean + ClientRegistrationRepository lazyClientRegistrationRepository(Environment environment) { + return new SupplierClientRegistrationRepository(() -> getClientRegistrationRepository(environment)); + } + + private InMemoryClientRegistrationRepository getClientRegistrationRepository(Environment environment) { + return new InMemoryClientRegistrationRepository(ClientRegistration.withRegistrationId("spring") + .clientId("spring") + .clientSecret("secret") + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .scope("profile") + .tokenUri(environment.resolvePlaceholders("http://localhost:${local.server.port}/oauth2/token")) + .build()); + } + + @Bean + SupplierJwtDecoder lazyJwtDecoder(Environment environment) { + return new SupplierJwtDecoder(() -> getJwtDecoder(environment)); + } + + private JwtDecoder getJwtDecoder(Environment environment) { + JwkSetUriJwtDecoderBuilder builder = NimbusJwtDecoder + .withJwkSetUri(environment.resolvePlaceholders("http://localhost:${local.server.port}/oauth2/jwks")); + builder.jwsAlgorithms((algorithms) -> algorithms.add(SignatureAlgorithm.from("RS256"))); + return builder.build(); + } + + } + + static class ObservedResponse implements StreamObserver { + + private volatile @Nullable T value; + + private volatile @Nullable Throwable error; + + @Override + public synchronized void onNext(T value) { + this.value = value; + } + + @Override + public synchronized void onError(Throwable error) { + this.error = error; + } + + @Override + public void onCompleted() { + } + + void await() { + Awaitility.await().until(this::hasResponse); + } + + private synchronized boolean hasResponse() { + return this.value != null || this.error != null; + } + + @Nullable T getValue() { + return this.value; + } + + @Nullable Throwable getError() { + return this.error; + } + + } + +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-secure/build.gradle b/smoke-test/spring-boot-smoke-test-grpc-server-secure/build.gradle new file mode 100644 index 00000000000..a4ec289d2f2 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-secure/build.gradle @@ -0,0 +1,66 @@ +/* + * Copyright 2012-present 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. + */ + +plugins { + id "java" + id "com.google.protobuf" version "${protobufGradlePluginVersion}" + id "org.springframework.boot.docker-test" +} + +description = "Spring Boot gRPC server secure smoke test" + +dependencies { + implementation(project(":starter:spring-boot-starter-grpc-server")) + implementation(project(":starter:spring-boot-starter-security")) + + testImplementation(project(":starter:spring-boot-starter-grpc-client")) + testImplementation(project(":starter:spring-boot-starter-grpc-test")) + testImplementation(project(":starter:spring-boot-starter-test")) +} + +def dependenciesBom = project(":platform:spring-boot-dependencies").extensions.getByName("bom") +def grpcJava = dependenciesBom.getLibrary("Grpc Java") +def protobufJava = dependenciesBom.getLibrary("Protobuf Java") + +tasks.named("compileTestJava") { + options.nullability.checking = "tests" +} + +nullability { + requireExplicitNullMarking = false +} + +configurations.named { it.startsWith("protobufToolsLocator_") || it.toLowerCase().endsWith("protopath") }.all { + extendsFrom(configurations.dependencyManagement) +} + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:${protobufJava.version}" + } + plugins { + grpc { + artifact = "io.grpc:protoc-gen-grpc-java:${grpcJava.version}" + } + } + generateProtoTasks { + all()*.plugins { + grpc { + option '@generated=omit' + } + } + } +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-secure/src/main/java/smoketest/grpcserversecure/HelloWorldService.java b/smoke-test/spring-boot-smoke-test-grpc-server-secure/src/main/java/smoketest/grpcserversecure/HelloWorldService.java new file mode 100644 index 00000000000..3330d1a1555 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-secure/src/main/java/smoketest/grpcserversecure/HelloWorldService.java @@ -0,0 +1,68 @@ +/* + * Copyright 2012-present 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 smoketest.grpcserversecure; + +import io.grpc.stub.StreamObserver; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import smoketest.grpcserversecure.proto.HelloReply; +import smoketest.grpcserversecure.proto.HelloRequest; +import smoketest.grpcserversecure.proto.HelloWorldGrpc; + +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Service; +import org.springframework.util.Assert; + +@Service +public class HelloWorldService extends HelloWorldGrpc.HelloWorldImplBase { + + private static Log logger = LogFactory.getLog(HelloWorldService.class); + + @Override + public void sayHelloAdmin(HelloRequest request, StreamObserver responseObserver) { + sayHello("sayHelloAdmin", request, responseObserver); + } + + @Override + @PreAuthorize("hasAuthority('ROLE_ADMIN')") + public void sayHelloAdminAnnotated(HelloRequest request, StreamObserver responseObserver) { + sayHello("sayHelloAdminAnnotated", request, responseObserver); + } + + @Override + public void sayHelloUser(HelloRequest request, StreamObserver responseObserver) { + sayHello("sayHelloUser", request, responseObserver); + } + + @Override + @PreAuthorize("hasAuthority('ROLE_USER')") + public void sayHelloUserAnnotated(HelloRequest request, StreamObserver responseObserver) { + sayHello("sayHelloUserAnnotated", request, responseObserver); + } + + public void sayHello(String methodName, HelloRequest request, StreamObserver responseObserver) { + String name = request.getName(); + logger.info(methodName + " " + name); + Assert.isTrue(!name.startsWith("error"), () -> "Bad name: " + name); + Assert.state(!name.startsWith("internal"), "Internal error"); + String message = "%s '%s'".formatted(methodName, name); + HelloReply reply = HelloReply.newBuilder().setMessage(message).build(); + responseObserver.onNext(reply); + responseObserver.onCompleted(); + } + +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-secure/src/main/java/smoketest/grpcserversecure/SampleGrpcServerSecureApplication.java b/smoke-test/spring-boot-smoke-test-grpc-server-secure/src/main/java/smoketest/grpcserversecure/SampleGrpcServerSecureApplication.java new file mode 100644 index 00000000000..9f92bc74883 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-secure/src/main/java/smoketest/grpcserversecure/SampleGrpcServerSecureApplication.java @@ -0,0 +1,29 @@ +/* + * Copyright 2012-present 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 smoketest.grpcserversecure; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SampleGrpcServerSecureApplication { + + public static void main(String[] args) { + SpringApplication.run(SampleGrpcServerSecureApplication.class, args); + } + +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-secure/src/main/java/smoketest/grpcserversecure/SecurityConfiguration.java b/smoke-test/spring-boot-smoke-test-grpc-server-secure/src/main/java/smoketest/grpcserversecure/SecurityConfiguration.java new file mode 100644 index 00000000000..7beaa3c3dd8 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-secure/src/main/java/smoketest/grpcserversecure/SecurityConfiguration.java @@ -0,0 +1,64 @@ +/* + * Copyright 2012-present 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 smoketest.grpcserversecure; + +import io.grpc.ServerInterceptor; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.grpc.server.GlobalServerInterceptor; +import org.springframework.grpc.server.security.GrpcSecurity; +import org.springframework.grpc.server.security.RequestMapperConfigurer; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; + +import static org.springframework.security.config.Customizer.withDefaults; + +@Configuration(proxyBeanMethods = false) +public class SecurityConfiguration { + + @Bean + InMemoryUserDetailsManager inMemoryUserDetailsManager() { + UserDetails user = user("user", "{noop}userpassword", "ROLE_USER"); + UserDetails admin = user("admin", "{noop}adminpassword", "ROLE_ADMIN"); + return new InMemoryUserDetailsManager(user, admin); + } + + private UserDetails user(String username, String password, String authority) { + return User.withUsername(username).password(password).authorities(authority).build(); + } + + @Bean + @GlobalServerInterceptor + ServerInterceptor securityInterceptor(GrpcSecurity security) throws Exception { + return security.authorizeRequests(this::authorizeRequests) + .httpBasic(withDefaults()) + .preauth(withDefaults()) + .build(); + } + + private void authorizeRequests(RequestMapperConfigurer requests) { + requests.methods("HelloWorld/SayHelloAdmin").hasAuthority("ROLE_ADMIN"); + requests.methods("HelloWorld/SayHelloAdminAnnotated").hasAuthority("ROLE_ADMIN"); + requests.methods("HelloWorld/SayHelloUser").hasAuthority("ROLE_USER"); + requests.methods("HelloWorld/SayHelloUserAnnotated").hasAuthority("ROLE_USER"); + requests.methods("grpc.*/*").permitAll(); + requests.allRequests().denyAll(); + } + +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-secure/src/main/java/smoketest/grpcserversecure/package-info.java b/smoke-test/spring-boot-smoke-test-grpc-server-secure/src/main/java/smoketest/grpcserversecure/package-info.java new file mode 100644 index 00000000000..7f019548742 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-secure/src/main/java/smoketest/grpcserversecure/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-present 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. + */ + +@NullMarked +package smoketest.grpcserversecure; + +import org.jspecify.annotations.NullMarked; diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-secure/src/main/proto/hello.proto b/smoke-test/spring-boot-smoke-test-grpc-server-secure/src/main/proto/hello.proto new file mode 100644 index 00000000000..700ee4ac930 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-secure/src/main/proto/hello.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; + +option java_package = "smoketest.grpcserversecure.proto"; +option java_multiple_files = true; + +service HelloWorld { + rpc SayHelloAdmin (HelloRequest) returns (HelloReply) {} + rpc SayHelloAdminAnnotated (HelloRequest) returns (HelloReply) {} + rpc SayHelloUser (HelloRequest) returns (HelloReply) {} + rpc SayHelloUserAnnotated (HelloRequest) returns (HelloReply) {} +} + +message HelloRequest { + string name = 1; +} + +message HelloReply { + string message = 1; +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-secure/src/test/java/smoketest/grpcserversecure/SampleGrpcServerSecureApplicationTests.java b/smoke-test/spring-boot-smoke-test-grpc-server-secure/src/test/java/smoketest/grpcserversecure/SampleGrpcServerSecureApplicationTests.java new file mode 100644 index 00000000000..4c7dab0e33d --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-secure/src/test/java/smoketest/grpcserversecure/SampleGrpcServerSecureApplicationTests.java @@ -0,0 +1,192 @@ +/* + * Copyright 2012-present 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 smoketest.grpcserversecure; + +import java.util.function.Consumer; +import java.util.function.Function; + +import io.grpc.ManagedChannelBuilder; +import io.grpc.Status.Code; +import io.grpc.StatusRuntimeException; +import io.grpc.reflection.v1.ServerReflectionGrpc.ServerReflectionStub; +import io.grpc.reflection.v1.ServerReflectionRequest; +import io.grpc.reflection.v1.ServerReflectionResponse; +import io.grpc.stub.StreamObserver; +import org.awaitility.Awaitility; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; +import smoketest.grpcserversecure.proto.HelloReply; +import smoketest.grpcserversecure.proto.HelloRequest; +import smoketest.grpcserversecure.proto.HelloWorldGrpc.HelloWorldBlockingStub; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.grpc.client.GrpcChannelBuilderCustomizer; +import org.springframework.grpc.client.ImportGrpcClients; +import org.springframework.grpc.client.interceptor.security.BasicAuthenticationInterceptor; +import org.springframework.test.annotation.DirtiesContext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +@SpringBootTest(properties = { "spring.grpc.server.port=0", + "spring.grpc.client.channel.default.target=static://localhost:${local.grpc.server.port}", + "spring.grpc.client.channel.user.target=static://localhost:${local.grpc.server.port}", + "spring.grpc.client.channel.admin.target=static://localhost:${local.grpc.server.port}" }) +@DirtiesContext +class SampleGrpcServerSecureApplicationTests { + + @Autowired + @Qualifier("unauthenticatedHelloWorldBlockingStub") + private HelloWorldBlockingStub unuathenticated; + + @Autowired + @Qualifier("userAuthenticatedHelloWorldBlockingStub") + private HelloWorldBlockingStub userAuthenticated; + + @Autowired + @Qualifier("adminAuthenticatedHelloWorldBlockingStub") + private HelloWorldBlockingStub adminAuthenticated; + + @Autowired + private ServerReflectionStub reflection; + + @Test + void whenUnauthenticatedStub() { + HelloWorldBlockingStub stub = this.unuathenticated; + assertThatExceptionOfType(StatusRuntimeException.class).isThrownBy(() -> invoke(stub::sayHelloUser)) + .satisfies(statusCode(Code.UNAUTHENTICATED)); + assertThatExceptionOfType(StatusRuntimeException.class).isThrownBy(() -> invoke(stub::sayHelloUserAnnotated)) + .satisfies(statusCode(Code.UNAUTHENTICATED)); + assertThatExceptionOfType(StatusRuntimeException.class).isThrownBy(() -> invoke(stub::sayHelloAdmin)) + .satisfies(statusCode(Code.UNAUTHENTICATED)); + assertThatExceptionOfType(StatusRuntimeException.class).isThrownBy(() -> invoke(stub::sayHelloAdminAnnotated)) + .satisfies(statusCode(Code.UNAUTHENTICATED)); + assertCanInvokeReflection(); + } + + @Test + void whenUserAuthenticatedStub() { + HelloWorldBlockingStub stub = this.userAuthenticated; + assertThat(invoke(stub::sayHelloUser)).isEqualTo("sayHelloUser 'Spring'"); + assertThat(invoke(stub::sayHelloUserAnnotated)).isEqualTo("sayHelloUserAnnotated 'Spring'"); + assertThatExceptionOfType(StatusRuntimeException.class).isThrownBy(() -> invoke(stub::sayHelloAdmin)) + .satisfies(statusCode(Code.PERMISSION_DENIED)); + assertThatExceptionOfType(StatusRuntimeException.class).isThrownBy(() -> invoke(stub::sayHelloAdminAnnotated)) + .satisfies(statusCode(Code.PERMISSION_DENIED)); + assertCanInvokeReflection(); + } + + @Test + void whenAdminAuthenticatedStub() { + HelloWorldBlockingStub stub = this.adminAuthenticated; + assertThatExceptionOfType(StatusRuntimeException.class).isThrownBy(() -> invoke(stub::sayHelloUser)) + .satisfies(statusCode(Code.PERMISSION_DENIED)); + assertThatExceptionOfType(StatusRuntimeException.class).isThrownBy(() -> invoke(stub::sayHelloUserAnnotated)) + .satisfies(statusCode(Code.PERMISSION_DENIED)); + assertThat(invoke(stub::sayHelloAdmin)).isEqualTo("sayHelloAdmin 'Spring'"); + assertThat(invoke(stub::sayHelloAdminAnnotated)).isEqualTo("sayHelloAdminAnnotated 'Spring'"); + assertCanInvokeReflection(); + } + + private String invoke(Function method) { + HelloRequest request = HelloRequest.newBuilder().setName("Spring").build(); + return method.apply(request).getMessage(); + } + + private void assertCanInvokeReflection() { + ObservedResponse response = invokeReflection(); + assertThat(response.getValue()).isNotNull(); + assertThat(response.getError()).isNull(); + } + + private ObservedResponse invokeReflection() { + ObservedResponse response = new ObservedResponse<>(); + StreamObserver request = this.reflection.serverReflectionInfo(response); + request.onNext(ServerReflectionRequest.newBuilder().setListServices("").build()); + request.onCompleted(); + response.await(); + return response; + } + + private Consumer statusCode(Code expected) { + return (ex) -> assertThat(ex).extracting("status.code").isEqualTo(expected); + } + + @TestConfiguration(proxyBeanMethods = false) + @ImportGrpcClients(types = ServerReflectionStub.class) + @ImportGrpcClients(prefix = "unauthenticated", types = HelloWorldBlockingStub.class) + @ImportGrpcClients(target = "user", prefix = "userAuthenticated", types = HelloWorldBlockingStub.class) + @ImportGrpcClients(target = "admin", prefix = "adminAuthenticated", types = HelloWorldBlockingStub.class) + static class GrpcClientTestConfiguration { + + @Bean + > GrpcChannelBuilderCustomizer channelSecurityCustomizer() { + return (target, builder) -> { + if ("user".equals(target)) { + builder.intercept(new BasicAuthenticationInterceptor("user", "userpassword")); + } + if ("admin".equals(target)) { + builder.intercept(new BasicAuthenticationInterceptor("admin", "adminpassword")); + } + }; + } + + } + + static class ObservedResponse implements StreamObserver { + + private volatile @Nullable T value; + + private volatile @Nullable Throwable error; + + @Override + public synchronized void onNext(T value) { + this.value = value; + } + + @Override + public synchronized void onError(Throwable error) { + this.error = error; + } + + @Override + public void onCompleted() { + } + + void await() { + Awaitility.await().until(this::hasResponse); + } + + private synchronized boolean hasResponse() { + return this.value != null || this.error != null; + } + + @Nullable T getValue() { + return this.value; + } + + @Nullable Throwable getError() { + return this.error; + } + + } + +}