diff --git a/rest/pom.xml b/rest/pom.xml index 2db81ded..85158acd 100644 --- a/rest/pom.xml +++ b/rest/pom.xml @@ -18,6 +18,7 @@ starbucks multi-store projections + security @@ -31,4 +32,4 @@ - \ No newline at end of file + diff --git a/rest/security/.gitignore b/rest/security/.gitignore new file mode 100644 index 00000000..2d19fc76 --- /dev/null +++ b/rest/security/.gitignore @@ -0,0 +1 @@ +*.html diff --git a/rest/security/README.adoc b/rest/security/README.adoc new file mode 100644 index 00000000..3a96de33 --- /dev/null +++ b/rest/security/README.adoc @@ -0,0 +1,258 @@ +== Spring Data REST + Spring Security + +This example shows how to secure a http://projects.spring.io/spring-data-rest[Spring Data REST] application in multiple ways with http://projects.spring.io/spring-security[Spring Security]. + +=== Defining the domain + +For a basic Spring Data REST application, we need to define some domain objects. In this case, we have a company. The company has both employees and items it manages. The domain objects are declared as POJOs with JPA annotations. + +.src/main/java/example/company/Employee.java +==== +[source,java] +---- +@Entity +public class Employee { + + @Id @GeneratedValue + private Long id; + + private String firstName; + private String lastName; + private String title; +... +---- +==== + +.src/main/java/example/company/Item.java +==== +[source,java] +---- +@Entity +public class Item { + + @Id @GeneratedValue + private Long id; + + private String description; +... +---- +==== + +=== Defining the repositories + +Spring Data is based on the repository paradigm. In this case, we are defining a repository for each of these domain objects. + +.src/main/java/example/company/EmployeeRepository.java +==== +[source,java] +---- +public interface EmployeeRepository extends CrudRepository { + +} +---- +==== + +`EmployeeRepository` is about as simple as things can get. It extends Spring Data Commons' `CrudRepository`. This means that the repo doesn't HAVE to be tied to JPA. If the underlying `Employee` object was retooled for MongoDB, this repository definition would require no changes. + +Now let's look at the next repository: + +.src/main/java/example/company/ItemRepository.java +==== +[source,java] +---- +@PreAuthorize("hasRole('ROLE_USER')") +public interface ItemRepository extends CrudRepository { + + @PreAuthorize("hasRole('ROLE_ADMIN')") + @Override + Item save(Item s); + + @PreAuthorize("hasRole('ROLE_ADMIN')") + @Override + void delete(Long aLong); +} +---- +==== + +This repository is simple in its functionality, but it has been marked up with Spring Security annotations (`@PreAuthorize`). The repository at the top level requires that the user have *ROLE_USER* before ANYTHING is granted. + +NOTE: These code examples use Spring Security's more modern http://docs.spring.io/spring-security/site/docs/current/reference/htmlsingle/#method-security-expressions[]@PreAuthorize] annotations. But you can also use http://docs.spring.io/spring-security/site/docs/current/reference/htmlsingle/#enableglobalmethodsecurity[@Secured] or JSR-250's security annotations. (Be advised, that JSR-250 annotations do NOT work at the interface level.) + +To fine tune security policies, `save(Item)` and `delete(Item)` are overrides of `CrudRepository`, allowing us to further restrict these operations to require **ROLE_ADMIN*. + +NOTE: This issue was fixed in 3.2.6, but hasn't been published yet as an artifact for consumption. Spring Security 4.0.0.CI-SNAPSHOT does have the fix in place. + +=== Writing a security policy + +The final bit that is needed is a security policy. By default, when using Spring Boot, everything is locked down and a random password is generated. Usually, you will want to replace this with a user store of some kind. In addition to that, you need to configure method-level security. To top it off, it is also possible to secure Spring Data REST endpoints at the URL level. All of this is shown below: + +.src/main/java/example/company/SecurityConfiguration.java +==== +[source,java] +---- +@Configuration +@EnableGlobalMethodSecurity(prePostEnabled = true) +public class SecurityConfiguration extends WebSecurityConfigurerAdapter { + + /** + * This section defines the user accounts which can be used for + * authentication as well as the roles each user has. + */ + @Autowired + public void configureAuth(AuthenticationManagerBuilder auth) throws Exception { + auth.inMemoryAuthentication() + .withUser("greg").password("turnquist").roles("USER").and() + .withUser("ollie").password("gierke").roles("USER", "ADMIN"); + } + + /** + * This section defines the security policy for the app. + * - BASIC authentication is supported (enough for this REST-based demo) + * - /employees is secured using URL security shown below + * - CSRF headers are disabled since we are only testing the REST interface, + * not a web one. + * + * NOTE: GET is not shown which defaults to permitted. + */ + @Override + protected void configure(HttpSecurity http) throws Exception { + http + .httpBasic() + .and() + .authorizeRequests() + .antMatchers(HttpMethod.POST, "/employees").hasRole("ADMIN") + .antMatchers(HttpMethod.PUT, "/employees/**").hasRole("ADMIN") + .antMatchers(HttpMethod.PATCH, "/employees/**").hasRole("ADMIN") + .and() + .csrf().disable(); + } +} +---- +==== + +The top section shows the user accounts defined in the app. + +The second section shows the URL restrictions that have been applied. Note that *GET /employees* has no restrictions at all. The other operations require *ROLE_ADMIN*. + +=== Testing things out + +You can drill down into `Application.java` to find the data that is preloaded. + +. Run the app. ++ +---- +$ mvn spring-boot:run +---- ++ +. In another shell, look up the list of employees: ++ +---- +$ curl localhost:8080/employees +---- ++ +---- +{ + "_embedded" : { + "employees" : [ { + "firstName" : "Bilbo", + "lastName" : "Baggins", + "title" : "thief", + "_links" : { + "self" : { + "href" : "http://localhost:8080/employees/1" + } + } + }, { +... +---- +No security required! ++ +. Try to POST with no credentials. ++ +---- +$ curl -X POST -d '{"firstName": "Saruman", "lastName": "the evil one", "title": "the White"}' localhost:8080/employees +---- ++ +---- +{"timestamp":1412958386366,"status":401,"error":"Unauthorized","message":"Full authentication is required to access this resource","path":"/employees"} +---- +You are denied due a lack of authentication, i.e. confirming who you are. ++ +. Try to POST with *USER* level credentials. ++ +---- +$ curl -X POST -d '{"firstName": "Saruman", "lastName": "the evil one", "title": "the White"}' localhost:8080/employees -u greg:turnquist +---- ++ +---- +{"timestamp":1412958491870,"status":403,"error":"Forbidden","message":"Access is denied","path":"/employees"} +---- +You are now denied due to not having sufficient authorization. ++ +. Try to POST with *ADMIN* level credentials. ++ +---- +$ curl -i -X POST -d '{"firstName": "Saruman", "lastName": "the evil one", "title": "the White"}' -H "Content-Type: application/json" localhost:8080/employees -u ollie:gierke +---- ++ +---- +HTTP/1.1 201 Created +Server: Apache-Coyote/1.1 +X-Content-Type-Options: nosniff +X-XSS-Protection: 1; mode=block +Cache-Control: no-cache, no-store, max-age=0, must-revalidate +Pragma: no-cache +Expires: 0 +X-Frame-Options: DENY +Set-Cookie: JSESSIONID=D738A5C8E5EACF6C118F8452A8C98919; Path=/; HttpOnly +Location: http://localhost:8080/employees/4 +Content-Length: 0 +---- ++ +Finally you have managed to create a new entry as shown by the *Location* header. You can also read about these various http://docs.spring.io/spring-security/site/docs/current/reference/htmlsingle/#headers[security-based headers] that Spring Security adds by default and what extra protections they add. ++ +. Now, try to fetch the list of items. ++ +---- +$ curl localhost:8080/items +---- ++ +---- +{"timestamp":1412958853221,"status":401,"error":"Unauthorized","exception":"org.springframework.security.access.AccessDeniedException","message":"Access is denied","path":"/items"} +---- +This fails at the get go because the entire repository is secured. Only with a *USER* level or higher can you see anything. ++ +. Try to fetch the list of items with *USER* level credentials. ++ +---- +$ curl localhost:8080/items -u greg:turnquist +---- ++ +---- +{ + "_embedded" : { + "items" : [ { + "description" : "Sting", + "_links" : { + "self" : { + "href" : "http://localhost:8080/items/1" + } + } + }, { + "description" : "the one ring", + "_links" : { + "self" : { + "href" : "http://localhost:8080/items/2" + } + } + } ] + } +} +---- + +From here on, you can experiment with this sample application: + +* Try to perform various operations with the accounts like fetching, creating, updating, replacing, and deleting through the REST API. +* Inject the repositories inside some other code and use it there. +* Write your own custom controller and export either repository your own way. Find out what security controls are carried through by default and what ones you have to add. +* Finally, fiddle with the roles and permissions and change the security settings. diff --git a/rest/security/pom.xml b/rest/security/pom.xml new file mode 100644 index 00000000..897ff186 --- /dev/null +++ b/rest/security/pom.xml @@ -0,0 +1,50 @@ + + 4.0.0 + + spring-data-rest-security + + Spring Data REST - Security Example + + + org.springframework.data.examples + spring-data-rest-examples + 1.0.0.BUILD-SNAPSHOT + + + + + 4.0.0.CI-SNAPSHOT + + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + org.springframework.boot + spring-boot-starter-security + + + + org.hsqldb + hsqldb + + + + org.springframework.boot + spring-boot-starter-test + te + + + + org.projectlombok + lombok + + + + + diff --git a/rest/security/src/main/java/example/company/Application.java b/rest/security/src/main/java/example/company/Application.java new file mode 100644 index 00000000..23499ed4 --- /dev/null +++ b/rest/security/src/main/java/example/company/Application.java @@ -0,0 +1,64 @@ +/* + * Copyright 2014 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 + * + * http://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 example.company; + +import javax.annotation.PostConstruct; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.security.core.context.SecurityContextHolder; + +/** + * This example shows various ways to secure Spring Data REST applications using Spring Security + * + * @author Greg Turnquist + */ +@ComponentScan +@EnableAutoConfiguration +public class Application { + + @Autowired ItemRepository itemRepository; + @Autowired EmployeeRepository employeeRepository; + + public static void main(String[] args) { + SpringApplication.run(Application.class); + } + + /** + * Pre-load the system with employees and items. + */ + @PostConstruct + public void init() { + + employeeRepository.save(new Employee("Bilbo", "Baggins", "thief")); + employeeRepository.save(new Employee("Frodo", "Baggins", "ring bearer")); + employeeRepository.save(new Employee("Gandalf", "the Wizard", "servant of the Secret Fire")); + + /** + * Due to method-level protections on {@link example.company.ItemRepository}, the security context must be loaded + * with an authentication token containing the necessary privileges. + */ + SecurityUtils.runAs("system", "system", "ROLE_ADMIN"); + + itemRepository.save(new Item("Sting")); + itemRepository.save(new Item("the one ring")); + + SecurityContextHolder.clearContext(); + } + +} diff --git a/rest/security/src/main/java/example/company/Employee.java b/rest/security/src/main/java/example/company/Employee.java new file mode 100644 index 00000000..40b4bc9b --- /dev/null +++ b/rest/security/src/main/java/example/company/Employee.java @@ -0,0 +1,47 @@ +/* + * Copyright 2014 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 + * + * http://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 example.company; + +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; +import lombok.RequiredArgsConstructor; + +/** + * Domain object for an employee. + * + * @author Greg Turnquist + */ +@Entity +@JsonIgnoreProperties(ignoreUnknown = true) +@Data +@RequiredArgsConstructor +public class Employee { + + @Id @GeneratedValue private Long id; + private final String firstName, lastName, title; + + Employee() { + + this.firstName = null; + this.lastName = null; + this.title = null; + } + +} diff --git a/rest/security/src/main/java/example/company/EmployeeRepository.java b/rest/security/src/main/java/example/company/EmployeeRepository.java new file mode 100644 index 00000000..ab4bb812 --- /dev/null +++ b/rest/security/src/main/java/example/company/EmployeeRepository.java @@ -0,0 +1,28 @@ +/* + * Copyright 2014 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 + * + * http://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 example.company; + +import org.springframework.data.repository.CrudRepository; + +/** + * This repository has no method-level security annotations. That's because it's secured at the URL level inside + * {@link example.company.SecurityConfiguration}. + * + * @author Greg Turnquist + */ +public interface EmployeeRepository extends CrudRepository { + +} diff --git a/rest/security/src/main/java/example/company/Item.java b/rest/security/src/main/java/example/company/Item.java new file mode 100644 index 00000000..28cb0e72 --- /dev/null +++ b/rest/security/src/main/java/example/company/Item.java @@ -0,0 +1,43 @@ +/* + * Copyright 2014 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 + * + * http://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 example.company; + +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; + +import lombok.Data; +import lombok.RequiredArgsConstructor; + +/** + * Domain object for an item managed by the company. + * + * @author Greg Turnquist + */ +@Entity +@Data +@RequiredArgsConstructor +public class Item { + + @Id @GeneratedValue private Long id; + + private final String description; + + Item() { + this.description = null; + } + +} diff --git a/rest/security/src/main/java/example/company/ItemRepository.java b/rest/security/src/main/java/example/company/ItemRepository.java new file mode 100644 index 00000000..d019b8a7 --- /dev/null +++ b/rest/security/src/main/java/example/company/ItemRepository.java @@ -0,0 +1,37 @@ +/* + * Copyright 2014 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 + * + * http://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 example.company; + +import org.springframework.data.repository.CrudRepository; +import org.springframework.security.access.prepost.PreAuthorize; + +/** + * This repository shows interface and method-level security. The entire repository requires ROLE_USER, while certain + * operations require ROLE_ADMIN. + * + * @author Greg Turnquist + */ +@PreAuthorize("hasRole('ROLE_USER')") +public interface ItemRepository extends CrudRepository { + + @PreAuthorize("hasRole('ROLE_ADMIN')") + @Override + Item save(Item s); + + @PreAuthorize("hasRole('ROLE_ADMIN')") + @Override + void delete(Long aLong); +} diff --git a/rest/security/src/main/java/example/company/SecurityConfiguration.java b/rest/security/src/main/java/example/company/SecurityConfiguration.java new file mode 100644 index 00000000..c311b395 --- /dev/null +++ b/rest/security/src/main/java/example/company/SecurityConfiguration.java @@ -0,0 +1,71 @@ +/* + * Copyright 2014 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 + * + * http://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 example.company; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +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.WebSecurityConfigurerAdapter; + +/** + * This application is secured at both the URL level for some parts, and the method level for other parts. The URL + * security is shown inside this code, while method-level annotations are enabled at by + * {@link org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity + * EnableGlobalMethodSecurity} + * + * @author Greg Turnquist + */ +@Configuration +@EnableGlobalMethodSecurity(prePostEnabled = true) +public class SecurityConfiguration extends WebSecurityConfigurerAdapter { + + /** + * This section defines the user accounts which can be used for authentication as well as the roles each user has. + * + * @param auth + * @throws Exception + */ + @Autowired + public void configureAuth(AuthenticationManagerBuilder auth) throws Exception { + auth.inMemoryAuthentication() + .withUser("greg").password("turnquist").roles("USER").and() + .withUser("ollie").password("gierke").roles("USER", "ADMIN"); + } + + /** + * This section defines the security policy for the app. - BASIC authentication is supported (enough for this + * REST-based demo) - /employees is secured using URL security shown below - CSRF headers are disabled since we are + * only testing the REST interface, not a web one. NOTE: GET is not shown which defaults to permitted. + * + * @param http + * @throws Exception + */ + @Override + protected void configure(HttpSecurity http) throws Exception { + http + .httpBasic() + .and() + .authorizeRequests() + .antMatchers(HttpMethod.POST, "/employees").hasRole("ADMIN") + .antMatchers(HttpMethod.PUT, "/employees/**").hasRole("ADMIN") + .antMatchers(HttpMethod.PATCH, "/employees/**").hasRole("ADMIN") + .and() + .csrf().disable(); + } +} diff --git a/rest/security/src/main/java/example/company/SecurityUtils.java b/rest/security/src/main/java/example/company/SecurityUtils.java new file mode 100644 index 00000000..61bfcfa0 --- /dev/null +++ b/rest/security/src/main/java/example/company/SecurityUtils.java @@ -0,0 +1,34 @@ +/* + * Copyright 2014 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 + * + * http://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 example.company; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.context.SecurityContextHolder; + +/** + * Some convenient security utilities. + * + * @author Greg Turnquist + */ +public class SecurityUtils { + + public static void runAs(String username, String password, String... roles) { + + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(username, password, AuthorityUtils.createAuthorityList(roles))); + } +} diff --git a/rest/security/src/test/java/example/company/MethodLevelSecurityTests.java b/rest/security/src/test/java/example/company/MethodLevelSecurityTests.java new file mode 100644 index 00000000..b3462bb9 --- /dev/null +++ b/rest/security/src/test/java/example/company/MethodLevelSecurityTests.java @@ -0,0 +1,131 @@ +package example.company; + +import static org.hamcrest.CoreMatchers.*; +import static org.junit.Assert.*; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.IntegrationTest; +import org.springframework.boot.test.SpringApplicationConfiguration; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.codec.Base64; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import org.springframework.test.context.web.WebAppConfiguration; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Collection of test cases used to verify method-level security. + * + * @author Greg Turnquist + */ +@RunWith(SpringJUnit4ClassRunner.class) +@SpringApplicationConfiguration(classes = { Application.class, SecurityConfiguration.class }) +@WebAppConfiguration +@IntegrationTest("server.port:0") +public class MethodLevelSecurityTests { + + private String baseUrl; + + @Value("${local.server.port}") + private int port; + + @Autowired + ItemRepository itemRepository; + + @Before + public void setUp() { + + this.baseUrl = "http://localhost:" + port; + SecurityContextHolder.clearContext(); + } + + @Test + public void testMethodLevelSecurityForNoCreds() { + + HttpHeaders headers = new HttpHeaders(); + headers.set("Accept", "application/hal+json"); + String creds = new String(Base64.encode(("greg:turnquist").getBytes())); + headers.set("Authorization", "Basic " + creds); + + RestTemplate rest = new RestTemplate(); + HttpEntity> request = new HttpEntity<>(null, headers); + + System.out.println("============= GET " + baseUrl + "/items"); + + System.out.println(SecurityContextHolder.getContext().getAuthentication()); + + ResponseEntity itemsResponse = rest.exchange(baseUrl + "/items", HttpMethod.GET, request, JsonNode.class); + itemsResponse.getHeaders().entrySet().stream() + .map(e -> e.getKey() + ": " + e.getValue()) + .forEach(header -> System.out.println(header)); + assertThat(itemsResponse.getHeaders().get("Content-Type"), hasItems("application/hal+json")); + assertThat(itemsResponse.getStatusCode(), equalTo(HttpStatus.OK)); + System.out.println(); + System.out.println(itemsResponse.getBody()); + + try { + itemRepository.findAll(); + fail("Expected a security error"); + } catch (AuthenticationCredentialsNotFoundException e) { + // expected + } + + try { + itemRepository.save(new Item("MacBook Pro")); + fail("Expected a security error"); + } catch (AuthenticationCredentialsNotFoundException e) { + // expected + } + + try { + itemRepository.delete(1L); + fail("Expected a security error"); + } catch (AuthenticationCredentialsNotFoundException e) { + // expected + } + } + + @Test + public void testMethodLevelSecurityForUsers() { + + SecurityUtils.runAs("system", "system", "ROLE_USER"); + + itemRepository.findAll(); + + try { + itemRepository.save(new Item("MacBook Pro")); + fail("Expected a security error"); + } catch (AccessDeniedException e) { + // expected + } + try { + itemRepository.delete(1L); + fail("Expected a security error"); + } catch (AccessDeniedException e) { + // expected + } + } + + @Test + public void testMethodLevelSecurityForAdmins() { + + SecurityUtils.runAs("system", "system", "ROLE_USER", "ROLE_ADMIN"); + + itemRepository.findAll(); + itemRepository.save(new Item("MacBook Pro")); + itemRepository.delete(1L); + } +} diff --git a/rest/security/src/test/java/example/company/UrlLevelSecurityTests.java b/rest/security/src/test/java/example/company/UrlLevelSecurityTests.java new file mode 100644 index 00000000..0c424b74 --- /dev/null +++ b/rest/security/src/test/java/example/company/UrlLevelSecurityTests.java @@ -0,0 +1,180 @@ +/* + * Copyright 2014 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 + * + * http://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 example.company; + +import static org.hamcrest.CoreMatchers.*; +import static org.junit.Assert.*; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.IntegrationTest; +import org.springframework.boot.test.SpringApplicationConfiguration; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.codec.Base64; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import org.springframework.test.context.web.WebAppConfiguration; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Test cases that verify the URL level of security by using RestTemplate. + * + * @author Greg Turnquist + */ +@RunWith(SpringJUnit4ClassRunner.class) +@SpringApplicationConfiguration(classes = { Application.class, SecurityConfiguration.class }) +@WebAppConfiguration +@IntegrationTest("server.port:0") +public class UrlLevelSecurityTests { + + private String baseUrl; + + @Value("${local.server.port}") private int port; + + @Before + public void setUp() { + + this.baseUrl = "http://localhost:" + port; + SecurityContextHolder.clearContext(); + } + + @Test + public void testUrlSecurityForNoCreds() { + + HttpHeaders headers = new HttpHeaders(); + headers.set("Accept", "application/hal+json"); + + HttpEntity> baseRequest = new HttpEntity<>(headers); + + RestTemplate rest = new RestTemplate(); + + System.out.println("============= GET " + baseUrl); + + ResponseEntity baseResponse = rest.exchange(baseUrl, HttpMethod.GET, baseRequest, JsonNode.class); + baseResponse.getHeaders().entrySet().stream()// + .map(e -> e.getKey() + ": " + e.getValue())// + .forEach(header -> System.out.println(header)); + assertThat(baseResponse.getHeaders().get("Content-Type"), hasItems("application/hal+json")); + assertThat(baseResponse.getStatusCode(), equalTo(HttpStatus.OK)); + System.out.println(); + System.out.println(baseResponse.getBody()); + + System.out.println("============= POST " + baseUrl + "/employees"); + + HttpEntity newEmployee = new HttpEntity<>("{firstName: Saruman, lastName: the White, title: Wizard}", + headers); + + try { + rest.exchange(baseUrl + "/employees", HttpMethod.POST, newEmployee, JsonNode.class); + fail("Expected a security error"); + } catch (HttpClientErrorException e) { + assertThat(e.getStatusCode(), equalTo(HttpStatus.UNAUTHORIZED)); + } + } + + @Test + public void testUrlSecurityForUsers() { + + System.out.println("============= GET " + baseUrl + "/employees"); + + HttpHeaders headers = new HttpHeaders(); + headers.set("Accept", "application/hal+json"); + + String userCreds = new String(Base64.encode(("greg:turnquist").getBytes())); + headers.set("Authorization", "Basic " + userCreds); + headers.set("Accept", "application/hal+json"); + + HttpEntity> request = new HttpEntity<>(headers); + + RestTemplate rest = new RestTemplate(); + + ResponseEntity employeesResponse = rest.exchange(baseUrl + "/employees", HttpMethod.GET, request, + JsonNode.class); + employeesResponse.getHeaders().entrySet().stream()// + .map(e -> e.getKey() + ": " + e.getValue())// + .forEach(header -> System.out.println(header)); + assertThat(employeesResponse.getHeaders().get("Content-Type"), hasItems("application/hal+json")); + assertThat(employeesResponse.getStatusCode(), equalTo(HttpStatus.OK)); + System.out.println(); + System.out.println(employeesResponse.getBody()); + + System.out.println("============= POST " + baseUrl + "/employees"); + + HttpEntity newEmployee = new HttpEntity<>("{\"firstName\": \"Saruman\", " + "\"lastName\": \"the White\", " + + "\"title\": \"Wizard\"}", headers); + + try { + rest.exchange(baseUrl + "/employees", HttpMethod.POST, newEmployee, JsonNode.class); + fail("Expected a security error"); + } catch (HttpClientErrorException e) { + assertThat(e.getStatusCode(), equalTo(HttpStatus.FORBIDDEN)); + } + } + + @Test + public void testUrlSecurityForAdmins() { + + System.out.println("============= GET " + baseUrl + "/employees"); + + HttpHeaders headers = new HttpHeaders(); + headers.set("Accept", "application/hal+json"); + + String userCreds = new String(Base64.encode(("ollie:gierke").getBytes())); + headers.set("Authorization", "Basic " + userCreds); + headers.set("Accept", "application/hal+json"); + + HttpEntity> request = new HttpEntity<>(headers); + + RestTemplate rest = new RestTemplate(); + + ResponseEntity employeesResponse = rest.exchange(baseUrl + "/employees", HttpMethod.GET, request, + JsonNode.class); + employeesResponse.getHeaders().entrySet().stream()// + .map(e -> e.getKey() + ": " + e.getValue())// + .forEach(header -> System.out.println(header)); + assertThat(employeesResponse.getHeaders().get("Content-Type"), hasItems("application/hal+json")); + assertThat(employeesResponse.getStatusCode(), equalTo(HttpStatus.OK)); + System.out.println(); + System.out.println(employeesResponse.getBody()); + + System.out.println("============= POST " + baseUrl + "/employees"); + + headers.add("Content-Type", "application/json"); + + HttpEntity newEmployee = new HttpEntity<>("{\"firstName\": \"Saruman\", " + "\"lastName\": \"the White\", " + + "\"title\": \"Wizard\"}", headers); + + ResponseEntity response = rest.exchange(baseUrl + "/employees", HttpMethod.POST, newEmployee, + JsonNode.class); + assertThat(response.getStatusCode(), equalTo(HttpStatus.CREATED)); + String location = response.getHeaders().get("Location").get(0); + Employee employee = rest.getForObject(location, Employee.class); + assertThat(employee.getFirstName(), equalTo("Saruman")); + assertThat(employee.getLastName(), equalTo("the White")); + assertThat(employee.getTitle(), equalTo("Wizard")); + System.out.println(rest.getForObject(location, String.class)); + } +}