Defining our security requirements
Who should be allowed to manage any given Cash Card?
In our simple domain, let's state that the user who created the Cash Card "owns" the Cash Card. Thus, they are the "card owner". Only the card owner can view or update a Cash Card.
The logic will be something like this:
IF the user is authenticated ... AND they are authorized as a "card owner" ... ... AND they own the requested Cash Card THEN complete the users's request BUT do not allow users to access Cash Cards they do not own.
Meaning that we'll secure our Family Cash Card API and restrict access to any given Cash Card to the card's "owner".
The owner concept
The owner is the unique identity of the person who created and can manage a given Cash Card.
owneradded as a field to thesrc\main\java\djmil\cashcard\CashCard.javarecordpublic record CashCard(@Id Long id, Double amount, String owner) {owneradded to all.sqlfiles insrc/test/resources/schema.sqlCREATE TABLE cash_card ( ID BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, AMOUNT NUMBER NOT NULL DEFAULT 0, OWNER VARCHAR(256) NOT NULL );data.sqlINSERT INTO CASH_CARD(ID, AMOUNT, OWNER) VALUES (101, 150.00, 'sarah1'); ...
owneradded to all.jsonfiles insrc/test/resources/example/cashcard{ "id": 99, "amount": 123.45, "owner": "sarah1" }- All application code and tests are updated to support the new
ownerfield.@Test public void cashCardSerializationTest() throws IOException { CashCard cashCard = new CashCard(99L, 123.45, "sarah1");
No functionality has changed as a result of these updates.
Minimal and non functional Spring Security
build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
// Add the following dependency
implementation 'org.springframework.boot:spring-boot-starter-security'
...
src/main/java/djmil/cashcard/SecurityConfig.java
package djmil.cashcard;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.*;
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
The @Configuration annotation tells Spring to use this class to configure Spring and Spring Boot itself. Any Beans specified in this class will now be available to Spring's Auto Configuration engine.
Spring Security expects a Bean to configure its Filter Chain. Annotating a method returning a SecurityFilterChain with the @Bean satisfies this expectation.
@Bean
public SecurityFilterChain filterChain
At this moment all tests should pass except for the test for creating a new CashCard via a POST.
CashCardApplicationTests > shouldCreateANewCashCard() FAILED
org.opentest4j.AssertionFailedError:
expected: 201 CREATED
but was: 403 FORBIDDEN
...
11 tests completed, 1 failed
Basic Authentication
Update SecurityConfig.filterChain with the following to enable basic authentication:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests()
.requestMatchers("/cashcards/**")
.authenticated()
.and()
.csrf().disable()
.httpBasic();
return http.build();
}
Here if we explain Spring Security's builder pattern in more understandable language, we see:
All HTTP requests to
cashcards/endpoints are required to be authenticated using HTTP Basic Authentication security (username and password).Also, do not require CSRF security.
All application tests should fail with a 401 UNAUTHORIZED HTTP status code. Thus, it is time to add some authentication code!
Create a test user
In SecurityConfig:
@Bean
public UserDetailsService testOnlyUsers(PasswordEncoder passwordEncoder) {
User.UserBuilder users = User.builder();
UserDetails sarah = users
.username("sarah1")
.password(passwordEncoder.encode("abc123"))
.roles() // No roles for now
.build();
return new InMemoryUserDetailsManager(sarah);
}
This UserDetailsService configuration should be understandable: configure a user named sarah1 with password abc123. The Spring's IoC container will find the UserDetailsService Bean and Spring Data will use it when needed.
Add basic Auth in HTTP tests
Each HTTP request shall be updated with basic authentication for sarah1:
void shouldReturnACashCardWhenDataIsSaved() {
ResponseEntity<String> response = restTemplate
.withBasicAuth("sarah1", "abc123") // Basic authentication
.getForEntity("/cashcards/99", String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
...
Testing for incorrect credentials
@Test
void shouldNotReturnACashCardWhenUsingBadCredentials() {
ResponseEntity<String> response = restTemplate
.withBasicAuth("BAD-USER", "abc123")
.getForEntity("/cashcards/99", String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
response = restTemplate
.withBasicAuth("sarah1", "BAD-PASSWORD")
.getForEntity("/cashcards/99", String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
Authorization
It is likely that a user service will provide access to many authenticated users, but only "card owners" should be allowed to access Family Cash Cards managed by our application. Here we'll implement Role-Based Access Control (RBAC).
Assign roles to the User
To test authorization, we need multiple test users with a variety of roles. Update SecurityConfig.testOnlyUsers and add the CARD-OWNER role to sarah1. Also, let's add a new user named "hank-owns-no-cards" with a role of NON-OWNER.
...
@Bean
public UserDetailsService testOnlyUsers(PasswordEncoder passwordEncoder) {
User.UserBuilder users = User.builder();
UserDetails sarah = users
.username("sarah1")
.password(passwordEncoder.encode("abc123"))
.roles("CARD-OWNER") // new role
.build();
UserDetails hankOwnsNoCards = users
.username("hank-owns-no-cards")
.password(passwordEncoder.encode("qrs456"))
.roles("NON-OWNER") // new role
.build();
return new InMemoryUserDetailsManager(sarah, hankOwnsNoCards);
}
Role verification
@Test
void shouldRejectUsersWhoAreNotCardOwners() {
ResponseEntity<String> response = restTemplate
.withBasicAuth("hank-owns-no-cards", "qrs456")
.getForEntity("/cashcards/99", String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
We see that our new test fails when we run it.
CashCardApplicationTests > shouldRejectUsersWhoAreNotCardOwners() FAILED
org.opentest4j.AssertionFailedError:
expected: 403 FORBIDDEN
but was: 200 OK
Why was hank-owns-no-cards able to access a CashCard as indicated by the 200 OK response?
Although we have given the test users roles, we are not enforcing role-based security.
Role enforcing
In SecurityConfig.filterChain we can restrict access to only users with the CARD-OWNER role. Previously, the complete access was granted to all authenticated users! Now, we will grant complete access to users with CARD-OWNER role only. Which is better than it was before, but less than ideal. Since any authenticated user with role CARD-OWNER can view anyone else's Family Cash Cards!
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests()
.requestMatchers("/cashcards/**")
//.authenticated()
.hasRole("CARD-OWNER") // <<-- enable RBAC
.and()
.csrf().disable()
.httpBasic();
return http.build();
}
Roles and Ownership
As mentioned in the Security#Role enforcing paragraph, we have a glaring security hole in our application! The security technologies such as Spring Security are amazing. There are features such as Spring Security Method Security that might help in this situation, but it is still your responsibility as a developer to write secure code and follow best security practices:
For example, don't write code that allows users to access other users' data!
To fix this, we will update our tests, CashCardRepository, and CashCardController.
Synopsis
To better understand what is going on and how to fix this, do the following:
- Add a new
CashCardfor a user namedkumar2... INSERT INTO CASH_CARD(ID, AMOUNT, OWNER) VALUES (102, 200.00, 'kumar2'); - Test that users cannot access each other's data
@Test void shouldNotAllowAccessToCashCardsTheyDoNotOwn() { ResponseEntity<String> response = restTemplate .withBasicAuth("sarah1", "abc123") .getForEntity("/cashcards/102", String.class); // <<-- kumar2's data assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); }
So now we have a test, where sarah1 attempts to access kumar2's data.
Note: You might wonder why we want to return a
404 NOT FOUNDresponse instead of something else, like401 UNAUTHORIZED. One argument in favor of choosing to returnNOT FOUNDis that it's the same response that we'd return if the requested Cash Card doesn't exist. It's safer to err on the side of not revealing any information about data which is not authorized for the user.
Currently, user sarah1 is able to view kumar2's data because:
sarah1is authenticated.sarah1is an authorizedCARD-OWNER. As expected, our new test fails, along with many others.
CashCardApplicationTests > shouldNotAllowAccessToCashCardsTheyDoNotOwn() FAILED
org.opentest4j.AssertionFailedError:
expected: 404 NOT_FOUND
but was: 200 OK
In addition, our test for fetching a list of CashCards is also failing:
CashCardApplicationTests > shouldReturnAllCashCardsWhenListIsRequested() FAILED
org.opentest4j.AssertionFailedError:
expected: 3
but was: 4
Restrict CashCardRepository queries "scope" to the correct OWNER
The simplest thing we can do is to always filter our data access by CashCard owner. We will need to filter by owner when finding both a single CashCard or a list of CashCards.
Edit CashCardRepository to add a new finder methods.
...
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
public interface
CashCardRepository
extends
CrudRepository<CashCard, Long>,
PagingAndSortingRepository<CashCard, Long>
{
CashCard findByIdAndOwner(Long id, String owner); // new method
Page<CashCard> findByOwner(String owner, PageRequest amount); // new method
}
We need only to define the methods, since Spring Data is perfectly capable of generating the actual SQL for the queries we need. Thanks, Spring Data!
Note: You might wonder whether Spring Data allows you to write your own SQL. After all, Spring Data can't anticipate every need, right? The answer is Yes! It's easy for you to write your own SQL code. The Spring Data Query Methods documentation describes how to do so by using the
@Queryannotation.
Btw, here is slightly different approach to the Delete#Enforce ownership. As stated in the linked paragraph, which approach to choose - essentially boils down to the question of efficiency.
The Principal
The CashCardRepository now supports filtering CashCard data by owner. But we're not using this new functionality. Let's enhance our app security by introducing a concept the Principal concept to the CashCardController. As with other helpful objects, the Principal is available for us to use in our Controller. The Principal holds our user's authenticated, authorized information.
import java.security.Principal;
...
@GetMapping("/{requestedId}")
public ResponseEntity<CashCard> findById(@PathVariable Long requestedId, Principal principal) {
Optional<CashCard> cashCardOptional = Optional.ofNullable(cashCardRepository.findByIdAndOwner(
requestedId,
principal.getName()));
if (cashCardOptional.isPresent()) {
...
@GetMapping
public ResponseEntity<List<CashCard>> findAll(Pageable pageable, Principal principal) {
Page<CashCard> page = cashCardRepository.findByOwner(
principal.getName(),
PageRequest.of(
pageable.getPageNumber(),
...
Note that Principal.name() will return the username provided from Basic Auth.
Creation
We have one more remaining security hole: creating CashCards. The authenticated, authorized Principal should be used as the owner when creating a new CashCard.
Question: What would happen if we automatically used the submitted
ownervalue? Answer: We risk allowing users to createCashCardsfor someone else!
Let's ensure that only the authenticated, authorized Principal owns the CashCards they are creating. To prove that we do not need to submit an owner, thus null shall be used as the owner for the CashCard.
@Test
void shouldCreateANewCashCard() {
CashCard newCashCard = new CashCard(null, 250.00, null);
...
What do you think will happen when we run the updated test?
CashCardApplicationTests > shouldCreateANewCashCard() FAILED
org.opentest4j.AssertionFailedError:
expected: 201 CREATED
but was: 500 INTERNAL_SERVER_ERROR
Our application is crashing due to the missing owner which is required by the database during the creation of the new CashCard entry. Review test/resources/schema.sql to see more.
CREATE TABLE cash_card
(
...
OWNER VARCHAR(256) NOT NULL
);
To fix this, we have to update POST endpoint in the Controller. Once again we will use the provided Principal to ensure that the correct owner is saved with the new CashCard.
@PostMapping
private ResponseEntity<Void> createCashCard(@RequestBody CashCard newCashCardRequest, UriComponentsBuilder ucb, Principal principal) {
CashCard cashCardWithOwner = new CashCard(null, newCashCardRequest.amount(), principal.getName());
CashCard savedCashCard = cashCardRepository.save(cashCardWithOwner);
...
Here, instead of immediately saving newCashCardRequest parsed from request body, we are extracting major information from it. The information than used to create another cashCardWithOwner, with has owner set to the value provided from Principal.
Dealing with Cross-Site Request Forgery (CSRF)
As we learned in the accompanying lesson, protection against Cross-Site Request Forgery (CSRF, or "sea-surf") is an important aspect of HTTP-based APIs used by web-based applications. Yet, we've disabled CSRF via the .csrf().disable() line in SecurityConfig.filterChain.
Why have we disabled CSRF?
For the purposes of our Family Cash Card API, we're going to follow the guidance from the Spring Security team regarding non-browser clients:
When should you use CSRF protection? Our recommendation is to use CSRF protection for any request that could be processed by a browser by normal users. If you are only creating a service that is used by non-browser clients, you will likely want to disable CSRF protection.
Meaning, that our service is a Backend server that is most likely to be used by our user facing Frontend server, thus it is safe to disable CSRF protection in such a scenario.
If you would like to add CSRF security to Front-facing applications, please review the testing support options below.
- MockMVC CSRF testing examples
- WebTestClient CSRF testing examples.
- A description of the Double-Submit Cookie Pattern.
- The Cash Card codebase, with CSRF protection enabled and implementing tests using the Double-Submit Cookie pattern: TestRestReplate CSRF testing examples.