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 thesrc\main\java\djmil\cashcard\CashCard.java
recordpublic record CashCard(@Id Long id, Double amount, String owner) {
owner
added to all.sql
files insrc/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 insrc/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 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 FOUND
response instead of something else, like401 UNAUTHORIZED
. One argument in favor of choosing to returnNOT 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 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
@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 createCashCards
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.
- 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.