logo

Go back to Blogs

Securing our REST API with Spring Security

May 22, 2024 0 Comments

Spring Boot Security

What is Security

Security could have different meanings in many different contexts, but in the end, it is all about protecting sensitive and valuable resources against malicious attacks. IT has many layers of infrastructure and code that can be subject to malicious attacks, and we should ensure that all these layers get the appropriate level of protection. The first step in protecting IT devices from malicious attacks is to define a defense-in-depth (DiD) strategy and security layers.

Defense-in-depth (DiD) is a security strategy that involves implementing multiple layers of defense to protect a system or network from potential threats. It aims to provide a comprehensive and resilient security posture by incorporating  various security measures  at different levels, such as, physical, technical and administrative controls.

The following figure illustrates common defense-in-depth mechanisms across IT infrastructure security layers:

Authentication

Authentication is the process of verifying the identity of a user or entity attempting to access our application. It ensures that the user is who they claim to be. Common authentication methods include User and password, Multi-factor authentication, Biometric authentication.

The diagram below presents a simple, standard authentication mechanism:

Spring Security and Authentication

Spring Security implements authentication in the Filter Chain. The Filter Chain is a component of Java web architecture which allows programmers to define a sequence of methods that get called prior to the Controller. Each filter in the chain decides whether to allow request processing to continue, or not. Spring Security inserts a filter which checks the user’s authentication and returns with a 401 UNAUTHORIZED response if the request is not authenticated.

Authorization

Authorization determines what actions or resources a user can access within an application. Once a user is authenticated, authorization mechanisms control their permissions based on predefined rules and policies. This ensures that users can only access the features and data they are authorized to use. Authorization can be role-based, attribute-based or rule-based.

1) Role-based access control – Users are assigned roles and permissions are granted based on those roles. For example, manager roles may access administrative features, while employee roles may access basic functionalities.

2) Attribute-based access control – Access is granted based on specific attributes or characteristics of the user, such as job title, department or location.

3) Rule-based access control –  Access control rules are defined on predefined conditions or criteria. For example, granting access during specific timeframes or based on certain data conditions.

The diagram below shows simple authorization process:

Implementing Simple Spring Security

In the following section, we will implement security mechanism, authentication and authorization, in our API application implemented in Guide to Unlocking TDD. The API Controllers, GET and POST, were implemented without any security in place. Now is the time to add simple Spring Security mechanism to our API Controller.

Introducing Spring boot security to our API Controller

Our API endpoints currently lack any mechanisms for authentication and authorization, leaving them vulnerable to unauthorized access. To enhance the security of our application, we intend to implement Spring Boot Security. This will involve configuring authentication to verify user identities and authorization to ensure users have the appropriate permissions for their actions. By integrating Spring Boot Security, we can safeguard our API and protect sensitive data from potential threats.

We want to implement basic authentication (using username and password) and role base authorization mechanism where only owners of the record has the authority to create employee record. To accomplish this, let’s add the concept of owner in our EmployeeRecord:

@Entity
public class EmployeeRecord {
   @Id
   @GeneratedValue(strategy= GenerationType.IDENTITY)
   private Long id;
   private String name;
   private String department;
   private String role;

   // Owner of the record could be manager or 
   // HR-Manager who is tasked with keeping employee record
   private String owner;

   public EmployeeRecord() {
   }
   // Getters and Setters
}

Similarly, lets update schema.sql and data.sql codes as given below respectively:

CREATE SCHEMA IF NOT EXISTS employee;
SET SCHEMA employee;
CREATE TABLE IF NOT EXISTS employee_record
(
   ID BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
   NAME VARCHAR (255),
   DEPARTMENT VARCHAR (255),
   ROLE VARCHAR (255),
   OWNER VARCHAR (255)
);
INSERT INTO employee_record(ID, NAME, DEPARTMENT, ROLE, OWNER) VALUES (
    201, 
    'Jannet Jackson', 
    'Engineering', 
    'Software Engineer', 
    'placeholder'
);

Add Spring Security dependency – the first step to introduce security is to add support for Spring Security by adding the appropriate dependency in our pom.xml file:

<!-- 
https://mvnrepository.com/artifact/org.springframework.boot
/spring-boot-starter-security 
-->
<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-security</artifactId>
</dependency>

Run the tests – By including the Spring Security dependency to our project path, we have added Spring Security capability to our application. Let’s run our test with mvn clean test:

[INFO]
[INFO] Results:
[INFO]
[ERROR] Failures:
[ERROR] EmployeeRecordApplicationTests.
    employee_record_does_not_exist_get_test:51 
    expected: <404 NOT_FOUND> but was: <200 OK>
[ERROR] EmployeeRecordApplicationTests.
    test_create_new_employee_record_test:64 
    expected: <201 CREATED> but was: <401 UNAUTHORIZED>
[ERROR] Errors:
[ERROR] EmployeeRecordApplicationTests.
    employee_record_exists_get_test » PathNotFound 
    Expected to find an object with property ['id'] 
    in path $ but found 'java.lang.String'. 
This is not a json object according to the JsonProvider: 
'com.jayway.jsonpath.spi.json.JsonSmartJsonProvider'.
[INFO]
[ERROR] Tests run: 3, Failures: 2, Errors: 1, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------
[INFO] BUILD FAILURE
[INFO] ------------------------------------------------------

The result shows that all the test methods failed, everything is broken. The test expects some Employee Record data to be retrieved from our API, but nothing is returned.

What happened is that, when we add Spring Security to our application by including the dependency in the pom.xml path, security was enabled by default. But We haven’t specified how the security (authentication and authorization) is handled in our Employee Record API, Spring Security has completely locked down our API.

Let’s configure Spring Security in our application and enable authentication and authorization. Let’s focus on getting our tests pass again by providing the minimum Spring Security configuration needed.

We will create a new java bean file which is required to configure Spring Security for our application, SecurityConfig:

package com.consultancy.employeerecord;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation
   .web.builders.HttpSecurity;
import org.springframework.security.config.annotation
   .web\.configurers.AbstractHttpConfigurer;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

   @Bean
   SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
       http.authorizeHttpRequests(request -> request
                       .requestMatchers("/employee/**")
                       .authenticated())
               .httpBasic(Customizer.withDefaults())
               .csrf(AbstractHttpConfigurer::disable);
       return http.build();
   }

   @Bean
   PasswordEncoder passwordEncoder() {
       return new BCryptPasswordEncoder();
   }
}

The @Configuration annotation tells Spring to use SecurityConfig class to configure Spring and beans specified in this class will now be available to the Spring’s Auto Configuration engine.

@Bean SecurityFilterChan filterChain(..) satisfies the Spring Security requirement that Beans should configure Filter Chain.

The Operations inside the filterChain method secures our application by configures basic authentication. It configures all HTTP requests to /employee/** endpoints to be authenticated using HTTP Basic Authentication Security (USERNAME and PASSWORD), also disables CSRF Security.

If we run the test, we will have the following output:

[INFO] Results:
[INFO]
[ERROR] Failures:
[ERROR] EmployeeRecordApplicationTests.
    employee_record_does_not_exist_get_test:51 
    expected: <404 NOT_FOUND> but was: <401 UNAUTHORIZED>
[ERROR] EmployeeRecordApplicationTests.
    employee_record_exists_get_test:27 
    expected: <200 OK> but was: <401 UNAUTHORIZED>
[ERROR] EmployeeRecordApplicationTests.
    test_create_new_employee_record_test:64 
    expected: <201 CREATED> but was: <401 UNAUTHORIZED>
[INFO]
[ERROR] Tests run: 3, Failures: 3, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------
[INFO] BUILD FAILURE
[INFO] ------------------------------------------------------

We have enabled basic authentication in our application, requiring that all requests must provide username and password, but our tests do not provide username and password. Hence, all our tests fails as shown above with <401 UNAUTHORIZED> HttpStatusCode.

There are many way to provide user authentication and authorization information for a Spring Boot Application using Spring Security.

For our case, we will configure test only service that Spring Security will use InMemoryUserDetailsManager.

Let’s add the following Bean to our SecurityConfig:

@Bean
UserDetailsService testOnlyUsers(PasswordEncoder passwordEncoder) {
   User.UserBuilder usersEngineering = User.builder();
   UserDetails userEngineering = users
           .username("Engineering")
           .password(passwordEncoder.encode("placeholder"))
           .roles() // No roles for now
           .build();
   return new InMemoryUserDetailsManager(userEngineering);
}

The UserDetailsService configures username and password. The Spring IoC container will find the UserDetailsService Bean and Spring Data will use it when needed.

Now, configure Basic Auth in our tests – our test class will be updated as follows:

package com.consultancy.employeerecord;

import com.jayway.jsonpath.DocumentContext;
import com.jayway.jsonpath.JsonPath;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;

import java.net.URI;

import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest(webEnvironment = 
   SpringBootTest.WebEnvironment.RANDOM_PORT)
class EmployeeRecordApplicationTests {

   @Autowired
   TestRestTemplate restTemplate;

   @Test
   void employee_record_exists_get_test() {
       ResponseEntity<String> response = restTemplate
               .withBasicAuth("Engineering", "placeholder")
               .getForEntity("/employee/getEmployee/201", String.class);

       // Assert that the response is OK
       assertEquals(HttpStatus.OK, response.getStatusCode());

       // Retrieve the response body
       DocumentContext context = JsonPath.parse(response.getBody());

       // Check employee record (id, name, department and role)
       Number id = context.read("$.id");
       assertEquals(201, id);

       String name = context.read("$.name");
       assertEquals ("Jannet Jackson", name);

       String department = context.read("$.department");
       assertEquals("Engineering", department);

       String role = context.read("$.role");
       assertEquals("Software Engineer", role);
   }

   @Test
   void employee_record_does_not_exist_get_test() {
       ResponseEntity<String> response = restTemplate
               .withBasicAuth("Engineering", "placeholder")
               .getForEntity("/employee/getEmployee/1001", String.class);

       // Assert that the response is NOT_FOUND
       assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
       assertNull(response.getBody());
   }

   @Test
   void test_create_new_employee_record_test() {
       EmployeeRecord employeeRecord = new EmployeeRecord();
       employeeRecord.setName("Alexandar Pontaja");
       employeeRecord.setDepartment("Engineering");
       employeeRecord.setRole("Senior Software Engineer");

       // 1) Create Employee Record
       ResponseEntity<Void> createResponse = restTemplate
               .withBasicAuth("Engineering", "placeholder")
               .postForEntity(
                     "/employee/addEmployee", 
                     employeeRecord, 
                     Void.class
                );
       assertEquals(HttpStatus.CREATED, createResponse.getStatusCode());

       // 2) Retrieve the location of the created Employee Record
       URI location = createResponse.getHeaders().getLocation();
       ResponseEntity<String> getResponse = restTemplate
               .withBasicAuth("Engineering", "placeholder")
               .getForEntity(location, String.class);
       assertEquals(HttpStatus.OK, getResponse.getStatusCode());

       // 3) Retrieve the response body
       DocumentContext context = JsonPath.parse(getResponse.getBody());
       String name = context.read("$.name");
       assertEquals ("Alexandar Pontaja", name);

       String department = context.read("$.department");
       assertEquals("Engineering", department);

       String role = context.read("$.role");
       assertEquals("Senior Software Engineer", role);
   }
}

For basic authentication, our tests will use Engineering as username and ‘placeholder’ as password for HTTP requests. Note that, in production environments, you should save your username and password in a secure environment and use placeholders in place of username and password.

As shown bellow, all our tests now pass.

[INFO] Results:
[INFO]
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------

Now we have implemented authentication to our API endpoints.

Enabling Authorization

Spring Security supports many forms of authorization, for example, we will implement Role-Based Access Control (RBAC).

A user service will provide access to many authenticated users, but only owners of the record should be allowed access to the Employee Record managed by our application. The owners of the record could be MANAGERS or HR-MANAGER whose task is record keeping. Let’s make these updates to our application.

Add users and rules to the UserDetailsService Bean – we need multiple users with a variety of roles to test authorization. Update SecurityConfig.testOnlyUsers and add MANAGER and HR-MANAGER roles to Engineering users and EMPLOYEE roles to employee users  who don’t have access to Employee Record.

The updated method is provided below:

@Bean
UserDetailsService testOnlyUsers(PasswordEncoder passwordEncoder) {
   User.UserBuilder usersEngineering = User.builder();
   UserDetails userEngineering = users.username("Engineering")
           .password(passwordEncoder.encode("placeholder"))
           .roles("MANAGER", "HR-MANAGER")
           .build();

   UserDetails employee = users.username("employee")
           .password(passwordEncoder.encode("placeholder1"))
           .roles("EMPLOYEE")
           .build();
   return new InMemoryUserDetailsManager(usersEngineering, employee);
}

Test for Role verification – Let’s add a test method to verify that EMPLOYEE Role should not have access to Employee Record:

@Test
void should_reject_users_with_employee_role() {
   ResponseEntity<String> response = restTemplate
           .withBasicAuth("Employee", "placeholder1")
           .getForEntity("/employee/getEmployee/201", String.class);
   assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode());
}

Let’s run the test:

[ERROR] Failures:
[ERROR] EmployeeRecordApplicationTests.
    should_reject_users_with_employee_role:96 
expected: <403 FORBIDDEN> but was: <200 OK>
[INFO]
[ERROR] Tests run: 4, Failures: 1, Errors: 0, Skipped: 0
[INFO]
[INFO] -------------------------------------------------------
[INFO] BUILD FAILURE
[INFO] -------------------------------------------------------

What is happening? Since the employee users are not assigned to either MANAGER, HR-MANAGER or OWNER Role, the test is expecting <403 FORBIDDEN> HTTP response but the actual response code is <200 OK>. Although we are assigning users Roles, we are not enforcing role-based security. Let’s do it now:

Enable Role-based-authorization – Update SecurityConfig.filterChain(..) method to restrict access to Employee Record only to users with MANAGER and HR-MANAGER roles:

@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
   http.authorizeHttpRequests(request -> request
               .requestMatchers("/employee/**")
               // Replace .authenticated() with 
               // ROLE-BASED-AUTHORIZATION CONTROL .hasAnyRole(...)
               .hasAnyRole("MANAGER", "HR-MANAGER"))
           .httpBasic(Customizer.withDefaults())
           .csrf(AbstractHttpConfigurer::disable);
   return http.build();
}

If we run the test again, the result shows that we have successfully enabled ROLE BASED AUTHORIZATION CONTROL to our API Controller.

[INFO] Results:
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
[INFO] -------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] -------------------------------------------------------

Employee Record ownership Repository update

In the previous implementation of authorization, anyone with MANAGER OR OWNER Role can access all employees Record. We want to add another layer of security to our application where managers of one department should not have access to Employee Record in another department. For instance, Engineering department manager (Engineering users) should not have access to Employee Record from the Data Science department (DataScience users)

To fix this security hole in our application, we have to update our EmployeeRecordRepository and EmployeeRecordController to manage the ownership:

  • Add functionality to EmployeeRecordRepository to restrict queries to the correct MANAGER or OWNER.
  • Then, update EmployeeRecordcontroller to guarantee that only the correct MANAGER OR OWNER can access the Employee Record.

Let’s add a new Employee Record in Data Science Department – Update src/test/resources/data.sql with an EmployeeRecord owned by a different user (DataScience user):

INSERT INTO EMPLOYEE_RECORD(ID, NAME, DEPARTMENT, ROLE, OWNER) VALUES (
    202, 
    BAFFET', 
    'Data Science', 
    'Data Scientist', 
    'DataScience'
);

Below we provide a test method where the MANAGER Role in Engineering department should not have access to the Employee Record from Data Science:

@Test
void should_not_allow_access_to_another_department_employee() {
   ResponseEntity<String> response = restTemplate
           .withBasicAuth("Engineering", "placeholder")
           .getForEntity("/employee/getEmployee/202", String.class);
   assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
}

In the test, we expect the API endpoint to return NOT_FOUND HTTP response if MANAGER Role from Engineering Department requests Employee Record from Data Science, but our test fails as given below:

[INFO] Results:
[INFO]
[ERROR] Failures:
[ERROR] EmployeeRecordApplicationTests.
   should_not_allow_access_to_another_department_employee:104 
   expected: <404 NOT_FOUND> but was: <200 OK>
[INFO]
[ERROR] Tests run: 5, Failures: 1, Errors: 0, Skipped: 0
[INFO]
[INFO] -------------------------------------------------------
[INFO] BUILD FAILURE
[INFO] -------------------------------------------------------

What’s happening? User ‘Engineering’ is able to view EmployeeRecord in Data Science Department owned by ‘DataScience’ user because:

  • Engineering user is authenticated.
  • Engineering user is an authorized MANAGER or HR-MANAGER.

Let’s prevent MANAGER from Engineering from accessing Employee Record from Data Science. Update the EmployeeRecordRepository with new findByIdAndOwner(…) method:

package com.consultancy.employeerecord;

import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.PagingAndSortingRepository;

import java.util.Optional;

public interface EmployeeRecordRepository extends 
      CrudRepository<EmployeeRecord, Long>,
      PagingAndSortingRepository<EmployeeRecord, Long> {
   Optional<EmployeeRecord> findByIdAndOwner(Long id, String owner);
}

We’ve added the method in our EmployeeRecordRepository and the Spring Data with take care of the actual implementation and the SQL queries

Employee Record ownership Controller update

Now the EmployeeRecordRepository supports filtering EmployeeRecord by the owner of the record. But we are not using the new functionality yet. Let’s update our EmployeeRecordController by introducing the concept of Principal, which will be available for us to use inside our Controller. The Principal holds our user’s authentication and authorization information.

Update our application API Controllers GET and POST endpoints – Update the EmployeeRecordController to pass the Principal’s information to our Repository’s new findByIdAndOwner method for the GET endpoint. For the POST endpoint, use the provided principal to ensure that the correct OWNER is saved with the new EmployeeRecord.

The updated EmployeeRecordController is provided below:

package com.consultancy.employeerecord;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.util.UriComponentsBuilder;

import java.net.URI;
import java.security.Principal;
import java.util.Optional;

@RestController
@RequestMapping("employee")
public class EmployeeRecordController {

   private final EmployeeRecordRepository repository;

   public EmployeeRecordController(
          EmployeeRecordRepository repository) {
       this.repository = repository;
   }
   @GetMapping("/getEmployee/{requestId}")
   public ResponseEntity<EmployeeRecord> getEmployee(
        @PathVariable Long requestId, 
        Principal principal) {
       // Request the EmployeeRecord with the correct 
       // OWNER using the Principal
       Optional<EmployeeRecord> employeeRecord = repository
            .findByIdAndOwner(requestId, principal.getName());
       return employeeRecord.map(ResponseEntity::ok)
          .orElseGet(() -> ResponseEntity.notFound().build());
   }

   @PostMapping("/addEmployee")
   public ResponseEntity<Void> addEmployee(
         @RequestBody EmployeeRecord employeeRecord,
         UriComponentsBuilder uriComponentsBuilder,
         Principal principal) {
         // Update the new EmployeeRecord with the correct 
         // OWNER using the Principal
       employeeRecord.setOwner(principal.getName());
       // Save Employee Record
       EmployeeRecord savedEmployeeRecord = repository
            .save(employeeRecord);
       // Retrieve Saved Employee URI
       URI recordPath = uriComponentsBuilder
            .path("employee/getEmployee/{id}")
            .buildAndExpand(savedEmployeeRecord.getId())
            .toUri();
       return ResponseEntity.created(recordPath).build();
   }
}

As we see in the output below, all the tests pass again:

[INFO] Results:
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------

We have ensured that Managers from one Department should not have access to Employee Record in another department.

Let’s add another test in our EmployeeRecordApplicationTests class to ensure that Data Science managers have access to EmployeeRecord in the Data Science Department:

@Test
void data_science_user_should_access_datascience_employee_test() {
   ResponseEntity<String> response = restTemplate
           .withBasicAuth("DataScience", "placeholder")
           .getForEntity("/employee/getEmployee/202", String.class);
   assertEquals(HttpStatus.OK, response.getStatusCode());
   // Retrieve the Response Body
   DocumentContext context = JsonPath.parse(response.getBody());
   String name = context.read("$.name");
   assertEquals ("Lauredana Bafet", name);

   String department = context.read("$.department");
   assertEquals("Data Science", department);

   String role = context.read("$.role");
   assertEquals("Data Scientist", role);
}

Let’s run the test:

[INFO] Results:
[INFO]
[ERROR] Failures:
[ERROR] EmployeeRecordApplicationTests.data_science_user_should_access_datascience_employee_test:112 expected: <200 OK> but was: <401 UNAUTHORIZED>
[INFO]
[ERROR] Tests run: 6, Failures: 1, Errors: 0, Skipped: 0
[INFO]
[INFO] ---------------------------------------------------------
[INFO] BUILD FAILURE
[INFO] ---------------------------------------------------------

What is going on? We are expecting ‘DataScience’ users to have access to EmployeeRecord in the Data Science Department, but the HTTP Response is <401 UNAUTHORIZED>. We haven’t yet added the ‘DataScience’ users to have access to any EmployeeRecord in UserDetailsService testOnlyUsers(…) method in SecurityConfig class. Let’s update that now:

@Bean
UserDetailsService testOnlyUsers(PasswordEncoder passwordEncoder) {
   User.UserBuilder users = User.builder();
   UserDetails engineering = users.username("Engineering")
           .password(passwordEncoder.encode("placeholder"))
           .roles("MANAGER", "HR-MANAGER")
           .build();

   UserDetails dataScience = users.username("DataScience")
           .password(passwordEncoder.encode("placeholder"))
           .roles("MANAGE", "HR-MANAGER")
           .build();

   UserDetails employee = users.username("employee")
           .password(passwordEncoder.encode("placeholder1"))
           .roles("EMPLOYEE")
           .build();
   return new InMemoryUserDetailsManager(
        engineering, dataScience, employee
   );
}

Thankfully, all our tests pass again:

[INFO] Results:
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
[INFO] ---------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ---------------------------------------------------------

Summary

In this blog, we have integrated Spring Boot Security into our employee record controller application implemented in the article Test-Driven Development (TDD). This security measures ensure that only authenticated and authorized users can create and access the employee record controller services. This integration involved configuring authentication mechanisms using user credentials (username and password) and setting up authorization using Role-Based-Access-Control. Furthermore, we added an extra layer of security by implementing Attribute-Based-Access-Control, ensuring that only users within the same department can view their respective employee records. We also included exception handling for unauthorized access attempts and provided comprehensive tests to verify the security configurations.

References

Spring Security Architecture

Footer