2 Security
djmil edited this page 2023-07-25 13:58:50 +02:00

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.

  • owner added as a field to the src\main\java\djmil\cashcard\CashCard.java record
    public record CashCard(@Id Long id, Double amount, String owner) {
    
  • owner added to all .sql files in src/test/resources/
    • schema.sql
      CREATE TABLE cash_card
      (
         ID     BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
         AMOUNT NUMBER NOT NULL DEFAULT 0,
         OWNER  VARCHAR(256) NOT NULL
      );
      
    • data.sql
      INSERT INTO CASH_CARD(ID, AMOUNT, OWNER) VALUES (101, 150.00, 'sarah1');
      ...
      
  • owner added to all .json files in src/test/resources/example/cashcard
    {
    "id": 99,
    "amount": 123.45,
    "owner": "sarah1"
    }
    
  • All application code and tests are updated to support the new owner field.
    @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 CashCard for a user named kumar2
    ...
    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 FOUND response instead of something else, like 401 UNAUTHORIZED. One argument in favor of choosing to return NOT FOUND is 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:

  • sarah1 is authenticated.
  • sarah1 is an authorized CARD-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 @Query annotation.

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 owner value? Answer: We risk allowing users to create CashCards for 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.