diff --git a/samples/webflux-security/src/main/java/io/spring/sample/graphql/SecurityDataFetcherExceptionResolver.java b/samples/webflux-security/src/main/java/io/spring/sample/graphql/SecurityDataFetcherExceptionResolver.java index a77fb36e..5e0c5abf 100644 --- a/samples/webflux-security/src/main/java/io/spring/sample/graphql/SecurityDataFetcherExceptionResolver.java +++ b/samples/webflux-security/src/main/java/io/spring/sample/graphql/SecurityDataFetcherExceptionResolver.java @@ -42,6 +42,7 @@ public class SecurityDataFetcherExceptionResolver implements DataFetcherExceptio private AuthenticationTrustResolver authenticationTrustResolver = new AuthenticationTrustResolverImpl(); + @Override public Mono> resolveException(Throwable exception, DataFetchingEnvironment environment) { if (exception instanceof AuthenticationException) { diff --git a/samples/webmvc-http-security/build.gradle b/samples/webmvc-http-security/build.gradle new file mode 100644 index 00000000..37c1c752 --- /dev/null +++ b/samples/webmvc-http-security/build.gradle @@ -0,0 +1,22 @@ +plugins { + id 'org.springframework.boot' version '2.5.0' + id 'java' +} +group = 'com.example' +version = '0.0.1-SNAPSHOT' +description = "Secure GraphQL over HTTP with Spring MVC Sample" +sourceCompatibility = '1.8' + +dependencies { + implementation project(':graphql-spring-boot-starter') + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + developmentOnly 'org.springframework.boot:spring-boot-devtools' + testImplementation project(':spring-graphql-test') + testImplementation 'org.springframework:spring-webflux' + testImplementation 'org.springframework.boot:spring-boot-starter-test' +} +test { + useJUnitPlatform() +} \ No newline at end of file diff --git a/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/Employee.java b/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/Employee.java new file mode 100644 index 00000000..0737dada --- /dev/null +++ b/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/Employee.java @@ -0,0 +1,30 @@ +package io.spring.sample.graphql; + +public class Employee { + + private String id; + + private String name; + + public Employee(String id, String name) { + this.id = id; + this.name = name; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + +} diff --git a/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/EmployeeService.java b/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/EmployeeService.java new file mode 100644 index 00000000..b131f583 --- /dev/null +++ b/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/EmployeeService.java @@ -0,0 +1,15 @@ +package io.spring.sample.graphql; + +import java.util.Arrays; +import java.util.List; + +import org.springframework.stereotype.Component; + +@Component +public class EmployeeService { + + public List getAllEmployees() { + return Arrays.asList(new Employee("1", "Andi")); + } + +} diff --git a/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/SalaryService.java b/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/SalaryService.java new file mode 100644 index 00000000..76da5e34 --- /dev/null +++ b/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/SalaryService.java @@ -0,0 +1,24 @@ +package io.spring.sample.graphql; + +import java.math.BigDecimal; + +import reactor.core.publisher.Mono; + +import org.springframework.security.access.annotation.Secured; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Component; + +@Component +public class SalaryService { + + + @PreAuthorize("hasRole('ADMIN')") + public BigDecimal getSalaryForEmployee(Employee employee) { + return new BigDecimal("42"); + } + + @Secured({ "ROLE_HR" }) + public void updateSalary(String employeeId, BigDecimal newSalary) { + + } +} diff --git a/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/SampleApplication.java b/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/SampleApplication.java new file mode 100644 index 00000000..19225fa6 --- /dev/null +++ b/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/SampleApplication.java @@ -0,0 +1,28 @@ +/* + * Copyright 2002-2020 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 io.spring.sample.graphql; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SampleApplication { + + public static void main(String[] args) { + SpringApplication.run(SampleApplication.class, args); + } +} diff --git a/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/SampleWiring.java b/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/SampleWiring.java new file mode 100644 index 00000000..e08d2709 --- /dev/null +++ b/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/SampleWiring.java @@ -0,0 +1,48 @@ +package io.spring.sample.graphql; + +import java.math.BigDecimal; +import java.util.Map; + +import graphql.schema.idl.RuntimeWiring; + +import org.springframework.graphql.boot.RuntimeWiringCustomizer; +import org.springframework.stereotype.Component; + +@Component +public class SampleWiring implements RuntimeWiringCustomizer { + + final EmployeeService employeeService; + + final SalaryService salaryService; + + public SampleWiring(EmployeeService employeeService, SalaryService salaryService) { + this.employeeService = employeeService; + this.salaryService = salaryService; + } + + + @Override + public void customize(RuntimeWiring.Builder builder) { + builder.type("Query", wiringBuilder -> + wiringBuilder.dataFetcher("employees", env -> + employeeService.getAllEmployees() + ) + ); + builder.type("Employee", wiringBuilder -> + wiringBuilder.dataFetcher("salary", env -> { + Employee employee = env.getSource(); + return salaryService.getSalaryForEmployee(employee); + }) + ); + builder.type("Mutation", wiringBuilder -> + wiringBuilder.dataFetcher("updateSalary", env -> { + Map input = env.getArgument("input"); + String employeeId = input.get("employeeId"); + BigDecimal newSalary = new BigDecimal(input.get("salary")); + salaryService.updateSalary(employeeId, newSalary); + return null; + }) + ); + } + +} diff --git a/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/SecurityConfig.java b/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/SecurityConfig.java new file mode 100644 index 00000000..7a51dcc2 --- /dev/null +++ b/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/SecurityConfig.java @@ -0,0 +1,41 @@ +package io.spring.sample.graphql; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +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 static org.springframework.security.config.Customizer.withDefaults; + +@Configuration +@EnableWebSecurity +@EnableGlobalMethodSecurity(prePostEnabled = true) +public 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); + } + +} diff --git a/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/SecurityContextThreadLocalAccessor.java b/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/SecurityContextThreadLocalAccessor.java new file mode 100644 index 00000000..fd04aa2d --- /dev/null +++ b/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/SecurityContextThreadLocalAccessor.java @@ -0,0 +1,31 @@ +package io.spring.sample.graphql; + +import org.springframework.graphql.execution.ThreadLocalAccessor; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Component +public class SecurityContextThreadLocalAccessor implements ThreadLocalAccessor { + + private static final String KEY = SecurityContext.class.getName(); + + @Override + public void extractValues(Map container) { + container.put(KEY, SecurityContextHolder.getContext()); + } + + @Override + public void restoreValues(Map values) { + if (values.containsKey(KEY)) { + SecurityContextHolder.setContext((SecurityContext) values.get(KEY)); + } + } + + @Override + public void resetValues(Map values) { + SecurityContextHolder.clearContext(); + } +} diff --git a/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/SecurityDataFetcherExceptionResolver.java b/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/SecurityDataFetcherExceptionResolver.java new file mode 100644 index 00000000..de44818f --- /dev/null +++ b/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/SecurityDataFetcherExceptionResolver.java @@ -0,0 +1,63 @@ +package io.spring.sample.graphql; + +import graphql.GraphQLError; +import graphql.GraphqlErrorBuilder; +import graphql.schema.DataFetchingEnvironment; +import org.springframework.graphql.execution.ErrorType; +import org.springframework.graphql.execution.SyncDataFetcherExceptionResolver; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.AuthenticationTrustResolver; +import org.springframework.security.authentication.AuthenticationTrustResolverImpl; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.util.Assert; + +import java.util.Arrays; +import java.util.List; + +@Component +public class SecurityDataFetcherExceptionResolver implements SyncDataFetcherExceptionResolver { + + private AuthenticationTrustResolver authenticationTrustResolver = new AuthenticationTrustResolverImpl(); + + @Override + public List doResolveException(Throwable exception, DataFetchingEnvironment environment) { + if (exception instanceof AuthenticationException) { + return unauthorized(environment); + } + if (exception instanceof AccessDeniedException) { + SecurityContext context = SecurityContextHolder.getContext(); + Authentication authentication = context.getAuthentication(); + if (this.authenticationTrustResolver.isAnonymous(authentication)) { + return unauthorized(environment); + } + return forbidden(environment); + } + return null; + } + + public void setAuthenticationTrustResolver(AuthenticationTrustResolver authenticationTrustResolver) { + Assert.notNull(authenticationTrustResolver, "authenticationTrustResolver cannot be null"); + this.authenticationTrustResolver = authenticationTrustResolver; + } + + private List unauthorized(DataFetchingEnvironment environment) { + return Arrays.asList( + GraphqlErrorBuilder.newError(environment) + .errorType(ErrorType.UNAUTHORIZED) + .message("Unauthorized") + .build()); + } + + private List forbidden(DataFetchingEnvironment environment) { + return Arrays.asList( + GraphqlErrorBuilder.newError(environment) + .errorType(ErrorType.FORBIDDEN) + .message("Forbidden") + .build()); + } + +} diff --git a/samples/webmvc-http-security/src/main/resources/application.properties b/samples/webmvc-http-security/src/main/resources/application.properties new file mode 100644 index 00000000..28e043a8 --- /dev/null +++ b/samples/webmvc-http-security/src/main/resources/application.properties @@ -0,0 +1,3 @@ +management.endpoints.web.exposure.include=health,metrics,info + +spring.graphql.schema.printer.enabled=true \ No newline at end of file diff --git a/samples/webmvc-http-security/src/main/resources/graphql/schema.graphqls b/samples/webmvc-http-security/src/main/resources/graphql/schema.graphqls new file mode 100644 index 00000000..7687c854 --- /dev/null +++ b/samples/webmvc-http-security/src/main/resources/graphql/schema.graphqls @@ -0,0 +1,22 @@ +type Query { + employees: [Employee] +} +type Mutation { + # restricted + updateSalary(input: UpdateSalaryInput!): UpdateSalaryPayload +} +type Employee { + id: ID! + name: String + # restricted + salary: String +} + +input UpdateSalaryInput { + employeeId: ID! + salary: String! +} +type UpdateSalaryPayload { + success: Boolean! + employee: Employee +} diff --git a/samples/webmvc-http-security/src/test/java/io/spring/sample/graphql/SampleApplicationTests.java b/samples/webmvc-http-security/src/test/java/io/spring/sample/graphql/SampleApplicationTests.java new file mode 100644 index 00000000..c535c7d7 --- /dev/null +++ b/samples/webmvc-http-security/src/test/java/io/spring/sample/graphql/SampleApplicationTests.java @@ -0,0 +1,159 @@ +package io.spring.sample.graphql; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.client.MockMvcWebTestClient; + +// @formatter:off + +@SpringBootTest +@AutoConfigureMockMvc +class SampleApplicationTests { + + private WebTestClient client; + + @BeforeEach + public void setUp(@Autowired MockMvc mockMvc) { + this.client = MockMvcWebTestClient.bindTo(mockMvc) + .baseUrl("/graphql") + .defaultHeaders(headers -> headers.setContentType(MediaType.APPLICATION_JSON)) + .build(); + } + + @Test + void printError() { + String query = "{" + + " employees{ " + + " name" + + " salary" + + " }" + + "}"; + + + client.post().uri("") + .bodyValue("{ \"query\": \"" + query + "\"}") + .exchange() + .expectStatus().isOk() + .expectBody(String.class) + .consumeWith(System.out::println); + + } + + @Test + void anonoymousThenUnauthorized() { + String query = "{" + + " employees{ " + + " name" + + " salary" + + " }" + + "}"; + + client.post().uri("") + .bodyValue("{ \"query\": \"" + query + "\"}") + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("errors[0].extensions.classification").isEqualTo("UNAUTHORIZED"); + + } + + @Test + void userRoleThenForbidden() { + String query = "{" + + " employees{ " + + " name" + + " salary" + + " }" + + "}"; + + client.post().uri("") + .headers(h -> h.setBasicAuth("rob", "rob")) + .bodyValue("{ \"query\": \"" + query + "\"}") + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("errors[0].extensions.classification").isEqualTo("FORBIDDEN"); + } + + @Test + void canQueryName() { + String query = "{" + + " employees{ " + + " name" + + " }" + + "}"; + + + client.post().uri("") + .bodyValue("{ \"query\": \"" + query + "\"}") + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("data.employees[0].name").isEqualTo("Andi"); + + } + + @Test + void canNotQuerySalary() { + String query = "{" + + " employees{ " + + " name" + + " salary" + + " }" + + "}"; + + + client.post().uri("") + .bodyValue("{ \"query\": \"" + query + "\"}") + .exchange() + .expectStatus().isOk() + .expectBody() + .jsonPath("data.employees[0].name").isEqualTo("Andi") + .jsonPath("data.employees[0].salary").doesNotExist(); + + } + + @Test + void canQuerySalaryAsAdmin() { + String query = "{" + + " employees{ " + + " name" + + " salary" + + " }" + + "}"; + + + client.post().uri("") + .headers(h -> h.setBasicAuth("admin", "admin")) + .bodyValue("{ \"query\": \"" + query + "\"}") + .exchange() + .expectStatus().isOk() + .expectBody() + .jsonPath("data.employees[0].name").isEqualTo("Andi") + .jsonPath("data.employees[0].salary").isEqualTo("42"); + + } + + @Test + void invalidCredentials() { + String query = "{" + + " employees{ " + + " name" + + " salary" + + " }" + + "}"; + + + client.post().uri("") + .headers(h -> h.setBasicAuth("admin", "INVALID")) + .bodyValue("{ \"query\": \"" + query + "\"}") + .exchange() + .expectStatus().isUnauthorized() + .expectBody() + .isEmpty(); + + } +} \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 6df3f2fd..7527e400 100644 --- a/settings.gradle +++ b/settings.gradle @@ -19,5 +19,6 @@ include 'spring-graphql', 'spring-graphql-test', 'graphql-spring-boot-starter', 'samples:webmvc-http', + 'samples:webmvc-http-security', 'samples:webflux-security', 'samples:webflux-websocket'