Auto-configure security for Spring GraphQL

This commit configures security features for Spring GraphQL.
In the case of both MVC and WebFlux, this contributes
`DataFetcherExceptionResolver` instances to resolve security exceptions
and expose them as proper errors in the GraphQL response.

For MVC only, this also configures a
`SecurityContextThreadLocalAccessor`. This component ensures that the
security context is propagated between `ThreadLocal` and the Reactor
asynchronous execution.

See gh-29140
This commit is contained in:
Brian Clozel
2021-12-21 08:33:50 +01:00
parent 9954b4c63e
commit 6dbcd0e95e
6 changed files with 479 additions and 0 deletions

View File

@@ -0,0 +1,54 @@
/*
* 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.boot.autoconfigure.graphql.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.boot.autoconfigure.graphql.reactive.GraphQlWebFluxAutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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 2.7.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();
}
}

View File

@@ -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.boot.autoconfigure.graphql.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.boot.autoconfigure.graphql.servlet.GraphQlWebMvcAutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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 2.7.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();
}
}

View File

@@ -0,0 +1,20 @@
/*
* 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 for Security support in Spring GraphQL.
*/
package org.springframework.boot.autoconfigure.graphql.security;

View File

@@ -75,6 +75,8 @@ org.springframework.boot.autoconfigure.graphql.data.GraphQlQueryByExampleAutoCon
org.springframework.boot.autoconfigure.graphql.data.GraphQlQuerydslAutoConfiguration,\
org.springframework.boot.autoconfigure.graphql.reactive.GraphQlWebFluxAutoConfiguration,\
org.springframework.boot.autoconfigure.graphql.servlet.GraphQlWebMvcAutoConfiguration,\
org.springframework.boot.autoconfigure.graphql.security.GraphQlWebFluxSecurityAutoConfiguration,\
org.springframework.boot.autoconfigure.graphql.security.GraphQlWebMvcSecurityAutoConfiguration,\
org.springframework.boot.autoconfigure.groovy.template.GroovyTemplateAutoConfiguration,\
org.springframework.boot.autoconfigure.gson.GsonAutoConfiguration,\
org.springframework.boot.autoconfigure.h2.H2ConsoleAutoConfiguration,\

View File

@@ -0,0 +1,163 @@
/*
* 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.boot.autoconfigure.graphql.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.graphql.Book;
import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration;
import org.springframework.boot.autoconfigure.graphql.GraphQlTestDataFetchers;
import org.springframework.boot.autoconfigure.graphql.reactive.GraphQlWebFluxAutoConfiguration;
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.execution.ErrorType;
import org.springframework.graphql.execution.RuntimeWiringConfigurer;
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,
GraphQlWebFluxAutoConfiguration.class, GraphQlWebFluxSecurityAutoConfiguration.class,
ReactiveSecurityAutoConfiguration.class))
.withUserConfiguration(DataFetchersConfiguration.class, SecurityConfig.class)
.withPropertyValues("spring.main.web-application-type=reactive");
@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<WebTestClient> 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
RuntimeWiringConfigurer bookDataFetcher(BookService bookService) {
return (builder) -> builder.type(TypeRuntimeWiring.newTypeWiring("Query").dataFetcher("bookById",
(env) -> bookService.getBookdById(env.getArgument("id"))));
}
@Bean
BookService bookService() {
return new BookService();
}
}
static class BookService {
@PreAuthorize("hasRole('USER')")
@Nullable
Mono<Book> getBookdById(String id) {
return Mono.justOrEmpty(GraphQlTestDataFetchers.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")
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);
}
}
}

View File

@@ -0,0 +1,179 @@
/*
* 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.boot.autoconfigure.graphql.security;
import graphql.schema.idl.TypeRuntimeWiring;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.graphql.Book;
import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration;
import org.springframework.boot.autoconfigure.graphql.GraphQlTestDataFetchers;
import org.springframework.boot.autoconfigure.graphql.servlet.GraphQlWebMvcAutoConfiguration;
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.execution.ErrorType;
import org.springframework.graphql.execution.RuntimeWiringConfigurer;
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, GraphQlWebMvcAutoConfiguration.class,
GraphQlWebMvcSecurityAutoConfiguration.class, SecurityAutoConfiguration.class))
.withUserConfiguration(DataFetchersConfiguration.class, SecurityConfig.class)
.withPropertyValues("spring.main.web-application-type=servlet");
@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
RuntimeWiringConfigurer bookDataFetcher(BookService bookService) {
return (builder) -> builder.type(TypeRuntimeWiring.newTypeWiring("Query").dataFetcher("bookById",
(env) -> bookService.getBookdById(env.getArgument("id"))));
}
@Bean
BookService bookService() {
return new BookService();
}
}
static class BookService {
@PreAuthorize("hasRole('USER')")
@Nullable
Book getBookdById(String id) {
return GraphQlTestDataFetchers.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
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);
}
}
}