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
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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=\
|
||||
|
||||
@@ -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<Book> 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<Book> getBooksOnSale(int minPages) {
|
||||
return Flux.fromIterable(books).filter((book) -> book.getPageCount() >= minPages);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<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
|
||||
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<Book> 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);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user