diff --git a/samples/webflux-security/build.gradle b/samples/webflux-security/build.gradle new file mode 100644 index 00000000..493aea7d --- /dev/null +++ b/samples/webflux-security/build.gradle @@ -0,0 +1,21 @@ +plugins { + id 'org.springframework.boot' version '2.5.0' + 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/webflux-security/src/main/java/io/spring/sample/graphql/Employee.java b/samples/webflux-security/src/main/java/io/spring/sample/graphql/Employee.java new file mode 100644 index 00000000..0737dada --- /dev/null +++ b/samples/webflux-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/webflux-security/src/main/java/io/spring/sample/graphql/EmployeeService.java b/samples/webflux-security/src/main/java/io/spring/sample/graphql/EmployeeService.java new file mode 100644 index 00000000..b131f583 --- /dev/null +++ b/samples/webflux-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/webflux-security/src/main/java/io/spring/sample/graphql/SalaryService.java b/samples/webflux-security/src/main/java/io/spring/sample/graphql/SalaryService.java new file mode 100644 index 00000000..7cd9fb36 --- /dev/null +++ b/samples/webflux-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 Mono getSalaryForEmployee(Employee employee) { + return Mono.just(new BigDecimal("42")); + } + + @Secured({ "ROLE_HR" }) + public void updateSalary(String employeeId, BigDecimal newSalary) { + + } +} diff --git a/samples/webflux-security/src/main/java/io/spring/sample/graphql/SampleApplication.java b/samples/webflux-security/src/main/java/io/spring/sample/graphql/SampleApplication.java new file mode 100644 index 00000000..19225fa6 --- /dev/null +++ b/samples/webflux-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/webflux-security/src/main/java/io/spring/sample/graphql/SampleWiring.java b/samples/webflux-security/src/main/java/io/spring/sample/graphql/SampleWiring.java new file mode 100644 index 00000000..69fdf12d --- /dev/null +++ b/samples/webflux-security/src/main/java/io/spring/sample/graphql/SampleWiring.java @@ -0,0 +1,49 @@ +package io.spring.sample.graphql; + +import java.math.BigDecimal; +import java.util.Map; + +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 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/webflux-security/src/main/java/io/spring/sample/graphql/SecurityConfig.java b/samples/webflux-security/src/main/java/io/spring/sample/graphql/SecurityConfig.java new file mode 100644 index 00000000..145b6ea1 --- /dev/null +++ b/samples/webflux-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.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 static org.springframework.security.config.Customizer.withDefaults; + +@Configuration +@EnableWebFluxSecurity +@EnableReactiveMethodSecurity +public class SecurityConfig { + + @Bean + SecurityWebFilterChain springWebFilterChain(ServerHttpSecurity http) throws Exception { + return http + .csrf(c -> c.disable()) + // Demonstrate that method security works + // Best practice to use both for defense in depth + .authorizeExchange(requests -> requests + .anyExchange().permitAll() + ) + .httpBasic(withDefaults()) + .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/webflux-security/src/main/resources/application.properties b/samples/webflux-security/src/main/resources/application.properties new file mode 100644 index 00000000..cc94084b --- /dev/null +++ b/samples/webflux-security/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/webflux-security/src/main/resources/graphql/schema.graphqls b/samples/webflux-security/src/main/resources/graphql/schema.graphqls new file mode 100644 index 00000000..7687c854 --- /dev/null +++ b/samples/webflux-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/webflux-security/src/test/java/io/spring/sample/graphql/SampleApplicationTests.java b/samples/webflux-security/src/test/java/io/spring/sample/graphql/SampleApplicationTests.java new file mode 100644 index 00000000..24ef3c33 --- /dev/null +++ b/samples/webflux-security/src/test/java/io/spring/sample/graphql/SampleApplicationTests.java @@ -0,0 +1,117 @@ +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.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 SampleApplicationTests { + + @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 + 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 c1194735..6df3f2fd 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-security', 'samples:webflux-websocket'