From f1bc239b573563e7637c5b819b2d2622d89e1671 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 6 Jul 2021 13:29:06 +0200 Subject: [PATCH] Add Spring Security auto-configuration This commit adds two new auto-configuration classes that provide the relevant Spring Security infrastructure for GraphQL applications: * a `ThreadLocalAccessor` that propagates the security context in Spring MVC applications * `DataFetcherExceptionResolver` implementations that resolve security exception from data fetchers, for both MVC and WebFlux Closes gh-81 --- build.gradle | 2 +- graphql-spring-boot-starter/build.gradle | 6 + ...aphQlWebFluxSecurityAutoConfiguration.java | 53 +++++ ...raphQlWebMvcSecurityAutoConfiguration.java | 61 ++++++ .../graphql/boot/security/package-info.java | 27 +++ .../main/resources/META-INF/spring.factories | 6 +- .../graphql/boot/GraphQlDataFetchers.java | 19 +- ...WebFluxSecurityAutoConfigurationTests.java | 194 +++++++++++++++++ ...lWebMvcSecurityAutoConfigurationTests.java | 200 ++++++++++++++++++ 9 files changed, 560 insertions(+), 8 deletions(-) create mode 100644 graphql-spring-boot-starter/src/main/java/org/springframework/graphql/boot/security/GraphQlWebFluxSecurityAutoConfiguration.java create mode 100644 graphql-spring-boot-starter/src/main/java/org/springframework/graphql/boot/security/GraphQlWebMvcSecurityAutoConfiguration.java create mode 100644 graphql-spring-boot-starter/src/main/java/org/springframework/graphql/boot/security/package-info.java create mode 100644 graphql-spring-boot-starter/src/test/java/org/springframework/graphql/boot/security/GraphQlWebFluxSecurityAutoConfigurationTests.java create mode 100644 graphql-spring-boot-starter/src/test/java/org/springframework/graphql/boot/security/GraphQlWebMvcSecurityAutoConfigurationTests.java diff --git a/build.gradle b/build.gradle index aac7d0e0..19096b3f 100644 --- a/build.gradle +++ b/build.gradle @@ -38,7 +38,7 @@ configure(moduleProjects) { mavenBom "io.projectreactor:reactor-bom:2020.0.7" mavenBom "org.springframework:spring-framework-bom:5.3.7" mavenBom "org.springframework.data:spring-data-bom:2021.0.1" - mavenBom "org.springframework.security:spring-security-bom:5.5.0" + mavenBom "org.springframework.security:spring-security-bom:5.5.1" mavenBom "org.junit:junit-bom:5.7.2" } dependencies { diff --git a/graphql-spring-boot-starter/build.gradle b/graphql-spring-boot-starter/build.gradle index 6bfde176..b8248e5e 100644 --- a/graphql-spring-boot-starter/build.gradle +++ b/graphql-spring-boot-starter/build.gradle @@ -38,6 +38,9 @@ dependencies { compileOnly 'io.micrometer:micrometer-core' compileOnly 'org.springframework.boot:spring-boot-actuator-autoconfigure' + compileOnly 'org.springframework.security:spring-security-config' + compileOnly 'org.springframework.security:spring-security-web' + compileOnly project(':spring-graphql-test') compileOnly 'org.springframework.boot:spring-boot-test' @@ -57,6 +60,9 @@ dependencies { testImplementation 'org.apache.tomcat.embed:tomcat-embed-websocket' testImplementation 'org.springframework.boot:spring-boot-actuator-autoconfigure' testImplementation 'io.micrometer:micrometer-core' + testImplementation 'org.springframework.security:spring-security-config' + testImplementation 'org.springframework.security:spring-security-web' + testImplementation 'org.springframework.security:spring-security-test' testImplementation 'org.springframework.boot:spring-boot-starter-test' } diff --git a/graphql-spring-boot-starter/src/main/java/org/springframework/graphql/boot/security/GraphQlWebFluxSecurityAutoConfiguration.java b/graphql-spring-boot-starter/src/main/java/org/springframework/graphql/boot/security/GraphQlWebFluxSecurityAutoConfiguration.java new file mode 100644 index 00000000..387dc7f2 --- /dev/null +++ b/graphql-spring-boot-starter/src/main/java/org/springframework/graphql/boot/security/GraphQlWebFluxSecurityAutoConfiguration.java @@ -0,0 +1,53 @@ +/* + * Copyright 2020-2021 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.graphql.boot.security; + +import graphql.GraphQL; + +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.graphql.boot.GraphQlWebFluxAutoConfiguration; +import org.springframework.graphql.security.ReactiveSecurityDataFetcherExceptionResolver; +import org.springframework.graphql.web.webflux.GraphQlHttpHandler; +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for enabling Security support + * for Spring GraphQL with WebFlux. + * + * @author Brian Clozel + * @since 1.0.0 + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) +@ConditionalOnClass({GraphQL.class, GraphQlHttpHandler.class, EnableWebFluxSecurity.class}) +@ConditionalOnBean(GraphQlHttpHandler.class) +@AutoConfigureAfter(GraphQlWebFluxAutoConfiguration.class) +public class GraphQlWebFluxSecurityAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public ReactiveSecurityDataFetcherExceptionResolver reactiveSecurityDataFetcherExceptionResolver() { + return new ReactiveSecurityDataFetcherExceptionResolver(); + } +} diff --git a/graphql-spring-boot-starter/src/main/java/org/springframework/graphql/boot/security/GraphQlWebMvcSecurityAutoConfiguration.java b/graphql-spring-boot-starter/src/main/java/org/springframework/graphql/boot/security/GraphQlWebMvcSecurityAutoConfiguration.java new file mode 100644 index 00000000..34753bf0 --- /dev/null +++ b/graphql-spring-boot-starter/src/main/java/org/springframework/graphql/boot/security/GraphQlWebMvcSecurityAutoConfiguration.java @@ -0,0 +1,61 @@ +/* + * Copyright 2020-2021 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.graphql.boot.security; + +import graphql.GraphQL; + +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.graphql.boot.GraphQlWebMvcAutoConfiguration; +import org.springframework.graphql.security.SecurityContextThreadLocalAccessor; +import org.springframework.graphql.security.SecurityDataFetcherExceptionResolver; +import org.springframework.graphql.web.webmvc.GraphQlHttpHandler; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for enabling Security support + * for Spring GraphQL with MVC. + * + * @author Brian Clozel + * @since 1.0.0 + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +@ConditionalOnClass({GraphQL.class, GraphQlHttpHandler.class, EnableWebSecurity.class}) +@ConditionalOnBean(GraphQlHttpHandler.class) +@AutoConfigureAfter(GraphQlWebMvcAutoConfiguration.class) +public class GraphQlWebMvcSecurityAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public SecurityDataFetcherExceptionResolver securityDataFetcherExceptionResolver() { + return new SecurityDataFetcherExceptionResolver(); + } + + @Bean + @ConditionalOnMissingBean + public SecurityContextThreadLocalAccessor securityContextThreadLocalAccessor() { + return new SecurityContextThreadLocalAccessor(); + } + +} diff --git a/graphql-spring-boot-starter/src/main/java/org/springframework/graphql/boot/security/package-info.java b/graphql-spring-boot-starter/src/main/java/org/springframework/graphql/boot/security/package-info.java new file mode 100644 index 00000000..359fbcc6 --- /dev/null +++ b/graphql-spring-boot-starter/src/main/java/org/springframework/graphql/boot/security/package-info.java @@ -0,0 +1,27 @@ +/* + * Copyright 2020-2021 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 classes to configure + * {@link org.springframework.graphql.security.SecurityDataFetcherExceptionResolver}, + * {@link org.springframework.graphql.security.SecurityContextThreadLocalAccessor} for Security support. + */ +@NonNullApi +@NonNullFields +package org.springframework.graphql.boot.security; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/graphql-spring-boot-starter/src/main/resources/META-INF/spring.factories b/graphql-spring-boot-starter/src/main/resources/META-INF/spring.factories index dd0ca602..394c4cf5 100644 --- a/graphql-spring-boot-starter/src/main/resources/META-INF/spring.factories +++ b/graphql-spring-boot-starter/src/main/resources/META-INF/spring.factories @@ -1,9 +1,11 @@ org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ -org.springframework.graphql.boot.actuate.metrics.GraphQlMetricsAutoConfiguration,\ org.springframework.graphql.boot.GraphQlAutoConfiguration,\ org.springframework.graphql.boot.GraphQlServiceAutoConfiguration,\ org.springframework.graphql.boot.GraphQlWebFluxAutoConfiguration,\ -org.springframework.graphql.boot.GraphQlWebMvcAutoConfiguration +org.springframework.graphql.boot.GraphQlWebMvcAutoConfiguration,\ +org.springframework.graphql.boot.actuate.metrics.GraphQlMetricsAutoConfiguration,\ +org.springframework.graphql.boot.security.GraphQlWebFluxSecurityAutoConfiguration,\ +org.springframework.graphql.boot.security.GraphQlWebMvcSecurityAutoConfiguration # Spring Test @AutoConfigureGraphQlTester org.springframework.graphql.boot.test.tester.AutoConfigureGraphQlTester=\ diff --git a/graphql-spring-boot-starter/src/test/java/org/springframework/graphql/boot/GraphQlDataFetchers.java b/graphql-spring-boot-starter/src/test/java/org/springframework/graphql/boot/GraphQlDataFetchers.java index 8adad0ec..24baf729 100644 --- a/graphql-spring-boot-starter/src/test/java/org/springframework/graphql/boot/GraphQlDataFetchers.java +++ b/graphql-spring-boot-starter/src/test/java/org/springframework/graphql/boot/GraphQlDataFetchers.java @@ -22,6 +22,8 @@ import java.util.List; import graphql.schema.DataFetcher; import reactor.core.publisher.Flux; +import org.springframework.lang.Nullable; + public final class GraphQlDataFetchers { private static List books = Arrays.asList(new Book("book-1", "GraphQL for beginners", 100, "John GraphQL"), @@ -33,13 +35,20 @@ public final class GraphQlDataFetchers { } public static DataFetcher getBookByIdDataFetcher() { - return (environment) -> books.stream().filter((book) -> book.getId().equals(environment.getArgument("id"))) - .findFirst().orElse(null); + return (environment) -> getBookById(environment.getArgument("id")); } - public static DataFetcher getBooksOnSale() { - return (environment) -> Flux.fromIterable(books) - .filter((book) -> book.getPageCount() >= (int) environment.getArgument("minPages")); + public static DataFetcher getBooksOnSaleDataFetcher() { + return (environment) -> getBooksOnSale(environment.getArgument("minPages")); + } + + @Nullable + public static Book getBookById(String id) { + return books.stream().filter((book) -> book.getId().equals(id)).findFirst().orElse(null); + } + + public static Flux getBooksOnSale(int minPages) { + return Flux.fromIterable(books).filter((book) -> book.getPageCount() >= minPages); } } diff --git a/graphql-spring-boot-starter/src/test/java/org/springframework/graphql/boot/security/GraphQlWebFluxSecurityAutoConfigurationTests.java b/graphql-spring-boot-starter/src/test/java/org/springframework/graphql/boot/security/GraphQlWebFluxSecurityAutoConfigurationTests.java new file mode 100644 index 00000000..f61a8b85 --- /dev/null +++ b/graphql-spring-boot-starter/src/test/java/org/springframework/graphql/boot/security/GraphQlWebFluxSecurityAutoConfigurationTests.java @@ -0,0 +1,194 @@ +/* + * Copyright 2020-2021 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.graphql.boot.security; + +import java.util.Collections; +import java.util.function.Consumer; + +import graphql.schema.idl.TypeRuntimeWiring; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.http.codec.CodecsAutoConfiguration; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.boot.autoconfigure.security.reactive.ReactiveSecurityAutoConfiguration; +import org.springframework.boot.autoconfigure.web.reactive.HttpHandlerAutoConfiguration; +import org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfiguration; +import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.graphql.boot.Book; +import org.springframework.graphql.boot.GraphQlAutoConfiguration; +import org.springframework.graphql.boot.GraphQlDataFetchers; +import org.springframework.graphql.boot.GraphQlServiceAutoConfiguration; +import org.springframework.graphql.boot.GraphQlWebFluxAutoConfiguration; +import org.springframework.graphql.boot.RuntimeWiringBuilderCustomizer; +import org.springframework.graphql.execution.ErrorType; +import org.springframework.graphql.security.ReactiveSecurityDataFetcherExceptionResolver; +import org.springframework.http.MediaType; +import org.springframework.lang.Nullable; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity; +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.core.userdetails.MapReactiveUserDetailsService; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.server.SecurityWebFilterChain; +import org.springframework.test.web.reactive.server.WebTestClient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.config.Customizer.withDefaults; + +/** + * Tests for {@link GraphQlWebFluxSecurityAutoConfiguration}. + * @author Brian Clozel + */ +class GraphQlWebFluxSecurityAutoConfigurationTests { + + private static final String BASE_URL = "https://spring.example.org/graphql"; + + private final ReactiveWebApplicationContextRunner contextRunner = new ReactiveWebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(HttpHandlerAutoConfiguration.class, WebFluxAutoConfiguration.class, + CodecsAutoConfiguration.class, JacksonAutoConfiguration.class, GraphQlAutoConfiguration.class, + GraphQlServiceAutoConfiguration.class, GraphQlWebFluxAutoConfiguration.class, + GraphQlWebFluxSecurityAutoConfiguration.class, ReactiveSecurityAutoConfiguration.class)) + .withUserConfiguration(DataFetchersConfiguration.class, SecurityConfig.class) + .withPropertyValues( + "spring.main.web-application-type=reactive", + "spring.graphql.schema.locations=classpath:books/"); + + @Test + void contributesExceptionResolver() { + this.contextRunner.run((context) -> { + assertThat(context).hasSingleBean(ReactiveSecurityDataFetcherExceptionResolver.class); + }); + } + + @Test + void anonymousUserShouldBeUnauthorized() { + testWithWebClient((client) -> { + String query = "{" + + " bookById(id: \\\"book-1\\\"){ " + + " id" + + " name" + + " pageCount" + + " author" + + " }" + + "}"; + client.post().uri("").bodyValue("{ \"query\": \"" + query + "\"}") + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("data.bookById.name") + .doesNotExist() + .jsonPath("errors[0].extensions.classification").isEqualTo(ErrorType.UNAUTHORIZED.toString()); + }); + } + + @Test + void authenticatedUserShouldGetData() { + testWithWebClient((client) -> { + String query = "{" + + " bookById(id: \\\"book-1\\\"){ " + + " id" + + " name" + + " pageCount" + + " author" + + " }" + + "}"; + client.post().uri("").headers(headers -> headers.setBasicAuth("rob", "rob")) + .bodyValue("{ \"query\": \"" + query + "\"}") + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("data.bookById.name") + .isEqualTo("GraphQL for beginners") + .jsonPath("errors[0].extensions.classification").doesNotExist(); + }); + } + + private void testWithWebClient(Consumer consumer) { + this.contextRunner.run((context) -> { + WebTestClient client = WebTestClient.bindToApplicationContext(context) + .configureClient() + .defaultHeaders((headers) -> { + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); + }) + .baseUrl(BASE_URL) + .build(); + consumer.accept(client); + }); + } + + @Configuration(proxyBeanMethods = false) + static class DataFetchersConfiguration { + + @Bean + RuntimeWiringBuilderCustomizer bookDataFetcher(BookService bookService) { + return (builder) -> builder.type(TypeRuntimeWiring.newTypeWiring("Query") + .dataFetcher("bookById", environment -> bookService.getBookdById(environment.getArgument("id")))); + } + + @Bean + BookService bookService() { + return new BookService(); + } + + } + + static class BookService { + + @PreAuthorize("hasRole('USER')") + @Nullable + public Mono getBookdById(String id) { + return Mono.justOrEmpty(GraphQlDataFetchers.getBookById(id)); + } + + } + + @Configuration(proxyBeanMethods = false) + @EnableWebFluxSecurity + @EnableReactiveMethodSecurity + static class SecurityConfig { + + @Bean + SecurityWebFilterChain springWebFilterChain(ServerHttpSecurity http) throws Exception { + return http + .csrf(spec -> spec.disable()) + // Demonstrate that method security works + // Best practice to use both for defense in depth + .authorizeExchange(requests -> requests.anyExchange().permitAll()) + .httpBasic(withDefaults()) + .build(); + } + + @Bean + @SuppressWarnings("deprecation") + public MapReactiveUserDetailsService userDetailsService() { + User.UserBuilder userBuilder = User.withDefaultPasswordEncoder(); + UserDetails rob = userBuilder.username("rob").password("rob").roles("USER").build(); + UserDetails admin = userBuilder.username("admin").password("admin").roles("USER", "ADMIN").build(); + return new MapReactiveUserDetailsService(rob, admin); + } + + } +} diff --git a/graphql-spring-boot-starter/src/test/java/org/springframework/graphql/boot/security/GraphQlWebMvcSecurityAutoConfigurationTests.java b/graphql-spring-boot-starter/src/test/java/org/springframework/graphql/boot/security/GraphQlWebMvcSecurityAutoConfigurationTests.java new file mode 100644 index 00000000..29043618 --- /dev/null +++ b/graphql-spring-boot-starter/src/test/java/org/springframework/graphql/boot/security/GraphQlWebMvcSecurityAutoConfigurationTests.java @@ -0,0 +1,200 @@ +/* + * Copyright 2020-2021 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.graphql.boot.security; + +import graphql.schema.idl.TypeRuntimeWiring; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; +import org.springframework.boot.autoconfigure.web.servlet.DispatcherServletAutoConfiguration; +import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.graphql.boot.Book; +import org.springframework.graphql.boot.GraphQlAutoConfiguration; +import org.springframework.graphql.boot.GraphQlDataFetchers; +import org.springframework.graphql.boot.GraphQlServiceAutoConfiguration; +import org.springframework.graphql.boot.GraphQlWebMvcAutoConfiguration; +import org.springframework.graphql.boot.RuntimeWiringBuilderCustomizer; +import org.springframework.graphql.execution.ErrorType; +import org.springframework.graphql.security.SecurityContextThreadLocalAccessor; +import org.springframework.graphql.security.SecurityDataFetcherExceptionResolver; +import org.springframework.http.MediaType; +import org.springframework.lang.Nullable; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.DefaultSecurityFilterChain; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.config.Customizer.withDefaults; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests for {@link GraphQlWebMvcSecurityAutoConfiguration}. + * @author Brian Clozel + */ +class GraphQlWebMvcSecurityAutoConfigurationTests { + + private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(DispatcherServletAutoConfiguration.class, + WebMvcAutoConfiguration.class, HttpMessageConvertersAutoConfiguration.class, + JacksonAutoConfiguration.class, GraphQlAutoConfiguration.class, + GraphQlServiceAutoConfiguration.class, GraphQlWebMvcAutoConfiguration.class, + GraphQlWebMvcSecurityAutoConfiguration.class, SecurityAutoConfiguration.class)) + .withUserConfiguration(DataFetchersConfiguration.class, SecurityConfig.class) + .withPropertyValues( + "spring.main.web-application-type=servlet", + "spring.graphql.schema.locations=classpath:books/"); + + @Test + void contributesSecurityComponents() { + this.contextRunner.run((context) -> { + assertThat(context).hasSingleBean(SecurityDataFetcherExceptionResolver.class); + assertThat(context).hasSingleBean(SecurityContextThreadLocalAccessor.class); + }); + } + + @Test + void anonymousUserShouldBeUnauthorized() { + testWith((mockMvc) -> { + String query = "{" + + " bookById(id: \\\"book-1\\\"){ " + + " id" + + " name" + + " pageCount" + + " author" + + " }" + + "}"; + MvcResult result = mockMvc.perform(post("/graphql").content("{\"query\": \"" + query + "\"}")).andReturn(); + mockMvc.perform(asyncDispatch(result)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("data.bookById.name").doesNotExist()) + .andExpect(jsonPath("errors[0].extensions.classification").value(ErrorType.UNAUTHORIZED.toString())); + }); + } + + @Test + void authenticatedUserShouldGetData() { + testWith((mockMvc) -> { + String query = "{" + + " bookById(id: \\\"book-1\\\"){ " + + " id" + + " name" + + " pageCount" + + " author" + + " }" + + "}"; + MvcResult result = mockMvc.perform(post("/graphql").content("{\"query\": \"" + query + "\"}").with(user("rob"))).andReturn(); + mockMvc.perform(asyncDispatch(result)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("data.bookById.name").value("GraphQL for beginners")) + .andExpect(jsonPath("errors").doesNotExist()); + }); + + } + + private void testWith(MockMvcConsumer mockMvcConsumer) { + this.contextRunner.run((context) -> { + MediaType mediaType = MediaType.APPLICATION_JSON; + MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(context) + .defaultRequest(post("/graphql").contentType(mediaType).accept(mediaType)) + .apply(springSecurity()) + .build(); + mockMvcConsumer.accept(mockMvc); + }); + } + + private interface MockMvcConsumer { + + void accept(MockMvc mockMvc) throws Exception; + + } + + @Configuration(proxyBeanMethods = false) + static class DataFetchersConfiguration { + + @Bean + RuntimeWiringBuilderCustomizer bookDataFetcher(BookService bookService) { + return (builder) -> builder.type(TypeRuntimeWiring.newTypeWiring("Query") + .dataFetcher("bookById", environment -> bookService.getBookdById(environment.getArgument("id")))); + } + + @Bean + BookService bookService() { + return new BookService(); + } + + } + + static class BookService { + + @PreAuthorize("hasRole('USER')") + @Nullable + public Book getBookdById(String id) { + return GraphQlDataFetchers.getBookById(id); + } + + } + + @Configuration(proxyBeanMethods = false) + @EnableWebSecurity + @EnableGlobalMethodSecurity(prePostEnabled = true) + static class SecurityConfig { + + @Bean + DefaultSecurityFilterChain springWebFilterChain(HttpSecurity http) throws Exception { + return http + .csrf(c -> c.disable()) + // Demonstrate that method security works + // Best practice to use both for defense in depth + .authorizeRequests(requests -> requests + .anyRequest().permitAll() + ) + .httpBasic(withDefaults()) + .build(); + } + + @Bean + public static InMemoryUserDetailsManager userDetailsService() { + User.UserBuilder userBuilder = User.withDefaultPasswordEncoder(); + UserDetails rob = userBuilder.username("rob").password("rob").roles("USER").build(); + UserDetails admin = userBuilder.username("admin").password("admin").roles("USER", "ADMIN").build(); + return new InMemoryUserDetailsManager(rob, admin); + } + + } +}