diff --git a/samples/employee-service/build.gradle b/samples/employee-service/build.gradle new file mode 100644 index 00000000..46f42657 --- /dev/null +++ b/samples/employee-service/build.gradle @@ -0,0 +1,22 @@ +plugins { + id 'org.springframework.boot' version '2.4.1' + id 'io.spring.dependency-management' version '1.0.10.RELEASE' + id 'java' +} +group = 'com.example' +version = '0.0.1-SNAPSHOT' +description = "GraphQL webflux security example" +sourceCompatibility = '1.8' + +dependencies { + implementation project(':graphql-spring-boot-starter') + implementation 'org.springframework.boot:spring-boot-starter-webflux' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + developmentOnly 'org.springframework.boot:spring-boot-devtools' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' +} +test { + useJUnitPlatform() +} \ No newline at end of file diff --git a/samples/employee-service/src/main/java/io/spring/sample/graphql/DataFetchers.java b/samples/employee-service/src/main/java/io/spring/sample/graphql/DataFetchers.java new file mode 100644 index 00000000..19e1817b --- /dev/null +++ b/samples/employee-service/src/main/java/io/spring/sample/graphql/DataFetchers.java @@ -0,0 +1,36 @@ +package io.spring.sample.graphql; + +import graphql.schema.DataFetcher; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.util.Map; + +@Component +public class DataFetchers { + + @Autowired + EmployeeService employeeService; + + @Autowired + SalaryService salaryService; + + public DataFetcher employeeDataFetcher = env -> { + return employeeService.getAllEmployees(); + }; + + public DataFetcher salaryDataFetcher = env -> { + Employee employee = env.getSource(); + return salaryService.getSalaryForEmployee(employee).toFuture(); + }; + + public DataFetcher updateSalaryFetcher = 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/employee-service/src/main/java/io/spring/sample/graphql/Employee.java b/samples/employee-service/src/main/java/io/spring/sample/graphql/Employee.java new file mode 100644 index 00000000..e2bc9b69 --- /dev/null +++ b/samples/employee-service/src/main/java/io/spring/sample/graphql/Employee.java @@ -0,0 +1,29 @@ +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/employee-service/src/main/java/io/spring/sample/graphql/EmployeeService.java b/samples/employee-service/src/main/java/io/spring/sample/graphql/EmployeeService.java new file mode 100644 index 00000000..8d64597c --- /dev/null +++ b/samples/employee-service/src/main/java/io/spring/sample/graphql/EmployeeService.java @@ -0,0 +1,15 @@ +package io.spring.sample.graphql; + +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.List; + +@Component +public class EmployeeService { + + public List getAllEmployees() { + return Arrays.asList(new Employee("1", "Andi")); + } + +} diff --git a/samples/employee-service/src/main/java/io/spring/sample/graphql/EmployeeServiceApplication.java b/samples/employee-service/src/main/java/io/spring/sample/graphql/EmployeeServiceApplication.java new file mode 100644 index 00000000..2538c875 --- /dev/null +++ b/samples/employee-service/src/main/java/io/spring/sample/graphql/EmployeeServiceApplication.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 EmployeeServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(EmployeeServiceApplication.class, args); + } +} diff --git a/samples/employee-service/src/main/java/io/spring/sample/graphql/GraphQLWiring.java b/samples/employee-service/src/main/java/io/spring/sample/graphql/GraphQLWiring.java new file mode 100644 index 00000000..22f6f5e1 --- /dev/null +++ b/samples/employee-service/src/main/java/io/spring/sample/graphql/GraphQLWiring.java @@ -0,0 +1,33 @@ +package io.spring.sample.graphql; + +import graphql.schema.idl.RuntimeWiring; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.graphql.boot.RuntimeWiringCustomizer; +import org.springframework.stereotype.Component; + +@Component +public class GraphQLWiring implements RuntimeWiringCustomizer { + + @Autowired + DataFetchers dataFetchers; + + @Override + public void customize(RuntimeWiring.Builder builder) { + builder.type("Query", + wiringBuilder -> { + return wiringBuilder. + dataFetcher("employees", dataFetchers.employeeDataFetcher); + }); + builder.type("Employee", + wiringBuilder -> { + return wiringBuilder. + dataFetcher("salary", dataFetchers.salaryDataFetcher); + }); + builder.type("Mutation", + wiringBuilder -> { + return wiringBuilder. + dataFetcher("updateSalary", dataFetchers.updateSalaryFetcher); + }); + + } +} diff --git a/samples/employee-service/src/main/java/io/spring/sample/graphql/SalaryService.java b/samples/employee-service/src/main/java/io/spring/sample/graphql/SalaryService.java new file mode 100644 index 00000000..5a17b1b9 --- /dev/null +++ b/samples/employee-service/src/main/java/io/spring/sample/graphql/SalaryService.java @@ -0,0 +1,23 @@ +package io.spring.sample.graphql; + +import org.springframework.security.access.annotation.Secured; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +import java.math.BigDecimal; + +@Component +public class SalaryService { + + + @PreAuthorize("hasRole('MANAGER')") + public Mono getSalaryForEmployee(Employee employee) { + return Mono.just(new BigDecimal("42")); + } + + @Secured({"ROLE_HR"}) + public void updateSalary(String employeeId, BigDecimal newSalary) { + + } +} diff --git a/samples/employee-service/src/main/java/io/spring/sample/graphql/SecurityConfig.java b/samples/employee-service/src/main/java/io/spring/sample/graphql/SecurityConfig.java new file mode 100644 index 00000000..1794b022 --- /dev/null +++ b/samples/employee-service/src/main/java/io/spring/sample/graphql/SecurityConfig.java @@ -0,0 +1,39 @@ +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.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; + +@Configuration +@EnableWebFluxSecurity +@EnableReactiveMethodSecurity +public class SecurityConfig { + + @Bean + SecurityWebFilterChain springWebFilterChain(ServerHttpSecurity http) throws Exception { + http.csrf().disable(); + return http + // Demonstrate that method security works + // Best practice to use both for defense in depth + .authorizeExchange() + .anyExchange().permitAll() + .and() + .httpBasic().and() + .build(); + } + + @Bean + 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/samples/employee-service/src/main/resources/application.properties b/samples/employee-service/src/main/resources/application.properties new file mode 100644 index 00000000..cc94084b --- /dev/null +++ b/samples/employee-service/src/main/resources/application.properties @@ -0,0 +1,7 @@ +spring.graphql.websocket.path=/graphql +management.endpoints.web.exposure.include=health,metrics,info +logging.level.org.springframework.web=debug +logging.level.org.springframework.http=debug +logging.level.org.springframework.graphql=debug +logging.level.org.springframework.security=debug +logging.level.reactor.netty=debug \ No newline at end of file diff --git a/samples/employee-service/src/main/resources/schema.graphqls b/samples/employee-service/src/main/resources/schema.graphqls new file mode 100644 index 00000000..7687c854 --- /dev/null +++ b/samples/employee-service/src/main/resources/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/employee-service/src/test/java/io/spring/sample/graphql/EmployeeServiceApplicationTest.java b/samples/employee-service/src/test/java/io/spring/sample/graphql/EmployeeServiceApplicationTest.java new file mode 100644 index 00000000..b2d1c499 --- /dev/null +++ b/samples/employee-service/src/test/java/io/spring/sample/graphql/EmployeeServiceApplicationTest.java @@ -0,0 +1,98 @@ +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.context.SpringBootTest; +import org.springframework.boot.web.reactive.context.ReactiveWebApplicationContext; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.reactive.function.client.ExchangeFilterFunctions; + +import java.util.Collections; + +@SpringBootTest() +class EmployeeServiceApplicationTest { + + @Autowired + private ReactiveWebApplicationContext context; + private static final String BASE_URL = "https://spring.example.org/graphql"; + + + WebTestClient client; + + @BeforeEach + public void setup() { + this.client = WebTestClient + .bindToApplicationContext(this.context) + .apply(SecurityMockServerConfigurers.springSecurity()) + .configureClient() + .filter(ExchangeFilterFunctions.basicAuthentication()) + .defaultHeaders(headers -> { + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); + }) + .baseUrl(BASE_URL) + .build(); + } + + @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 + @WithMockUser(roles = "MANAGER") + void canQuerySalaryAsManager() { + 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").isEqualTo("42"); + + } +} \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index c1194735..1bd1ed3b 100644 --- a/settings.gradle +++ b/settings.gradle @@ -19,4 +19,5 @@ include 'spring-graphql', 'spring-graphql-test', 'graphql-spring-boot-starter', 'samples:webmvc-http', - 'samples:webflux-websocket' + 'samples:webflux-websocket', + 'samples:employee-service'