Provide basic security for your Spring Boot application with Spring Security and Kotlin

When you’re creating your REST API, most of the time you don’t want it to be publically accessible. Moreover, sometimes you’d like to restrict certain paths for users with specific roles (for example administrators).

In this blog post, I’m going to provide very basic Spring Security integration for Spring Boot application written in Kotlin language.

Please note that I’m using the example REST API I provided in the previous article. So if you don’t have your Spring Boot REST API built yet, move through the steps I described there.

Adding dependencies

At first, let’s add Spring Boot Starter Security dependency pack into our project.
It contains all the stuff we need.

Gradle dependency:

implementation("org.springframework.boot:spring-boot-starter-security:2.2.5.RELEASE")
build.gradle

Maven dependency:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
    <version>2.2.5.RELEASE</version>
</dependency>
pom.xml

Providing configuration class

As I mentioned in the preface, let’s use the REST API that was created in the previous blogpost.

For simplification, we’d like to use out-of-the-box Spring Security users and roles and we’d use two roles here: ADMIN and USER.

We can secure our endpoints using @Secured annotation or via the config class. Let’s follow the latter approach.

Create a SecurityConfig class in the config package.


@Configuration
class SecurityConfig : WebSecurityConfigurerAdapter() {

    @Bean
    fun encoder(): PasswordEncoder {
        return BCryptPasswordEncoder()
    }

    override fun configure(auth: AuthenticationManagerBuilder) {
        auth.inMemoryAuthentication()
                .withUser("admin")
                .password(encoder().encode("pass"))
                .roles("USER", "ADMIN")
                .and()
                .withUser("user")
                .password(encoder().encode("pass"))
                .roles("USER")
    }

    @Throws(Exception::class)
    override fun configure(http: HttpSecurity) {
        http.httpBasic()
                .and()
                .authorizeRequests()
                .antMatchers(HttpMethod.GET, "/tasks").hasRole("ADMIN")
                .antMatchers(HttpMethod.POST, "/tasks/**").hasRole("ADMIN")
                .antMatchers(HttpMethod.PUT, "/tasks/**").hasRole("ADMIN")
                .antMatchers(HttpMethod.DELETE, "/tasks/**").hasRole("ADMIN")
                .antMatchers(HttpMethod.GET, "/tasks/**").hasAnyRole("ADMIN", "USER")
                .and()
                .csrf().disable()
                .formLogin().disable()
    }

}
SecurityConfig.kt

The config provides two in-memory users: admin and user with encrypted passwords. If you don’t want to encrypt the password you can use a plain text password with {noop} prefix.

The second part of the configuration is about paths, permitted HTTP methods and user roles permissions.

The admin user will be able to list all existing tasks, retrieve a single one, create a new one, update and delete existing ones.
The ordinary user will be able to retrieve a single task only.

csrf().disable is about disabling Spring Security built-in Cross Site Request Forgery protection.
formLogin().disable() is about disabling default login form. If you’d like to redirect user to the specific login page you can specify it here.

Okay, the basic configuration is done here. When you provide your own user details representation you’ll have to add more stuff here, but for our purposes is enough.

Updating the tests

Obviously, at this point, previously provided tests will fail.

Which is a very good sign, because it means that our security configuration works as expected.

Let’s update the tests and make sure they pass.
As TestRestTemplate has been used for testing the endpoints, the authentication in tests is really easy. In our case, withBasicAuth method will do the job.

In MockMvc-based tests you’d use @WithMockUser(“userName”) annotation.

First of all, let’s provide useful constants with credentials.
If you’re going to use it in other tests, it’s a good idea to delegate them to test utils class. But for our purposes, companion object does the job.

    companion object {
        private const val DEFAULT_PASSWORD = "pass"
        private const val DEFAULT_USER = "user"
        private const val DEFAULT_ADMIN = "admin"
    }
TaskConrollerIntegrationTest.kt

Example of a single updated test which uses withBasicAuth method:


    @Test
    fun `should return all tasks`() {
        saveOneTask()

        val response = restTemplate
                .withBasicAuth(DEFAULT_ADMIN, DEFAULT_PASSWORD)
                .getForEntity(
                        getRootUrl(),
                        List::class.java
                )

        assertEquals(200, response.statusCode.value())
        assertNotNull(response.body)
        assertEquals(1, response.body?.size)
    }
TaskControllerIntegrationTest.kt

Change the other tests like that and it will be all green again. As simple as this.

But let’s provide additional tests right now to check if the behavior is as we expected.

    fun `should not allow to return all tasks list for a non-authenticated user`() {...}

    fun `should not allow to return all tasks list for a non-authorized user`() {...}
    
    fun `should not allow to create new task as a non-authorized user`() {...}

    fun `should not allow to update existing task as a non-authorized user`() {...}
    
    fun `should not allow to delete existing task as a non-authorized user`() {...}
TaskControllerIntegrationTest.kt

The finally updated test class is as follows:

package net.mestwin.mongodbrestapidemo.controller

...


@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ExtendWith(SpringExtension::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class TaskControllerIntegrationTest @Autowired constructor(
        private val taskRepository: TaskRepository,
        private val restTemplate: TestRestTemplate
) {
    companion object {
        private const val DEFAULT_PASSWORD = "pass"
        private const val DEFAULT_USER = "user"
        private const val DEFAULT_ADMIN = "admin"
    }

    private val defaultTaskId = ObjectId.get()

    @LocalServerPort
    protected var port: Int = 0

    @BeforeEach
    fun setUp() {
        taskRepository.deleteAll()
    }

    @Test
    fun `should return all tasks as an ADMIN user`() {
        saveOneTask()

        val response = restTemplate
                .withBasicAuth(DEFAULT_ADMIN, DEFAULT_PASSWORD)
                .getForEntity(
                        getRootUrl(),
                        List::class.java
                )

        assertEquals(200, response.statusCode.value())
        assertNotNull(response.body)
        assertEquals(1, response.body?.size)
    }

    @Test
    fun `should return single task by id as an ADMIN user`() {
        checkIfSingleTagIsReturned(DEFAULT_ADMIN)
    }

    @Test
    fun `should return single task by id as a USER user`() {
        checkIfSingleTagIsReturned(DEFAULT_USER)
    }

    @Test
    fun `should create new task as an ADMIN user`() {
        val taskRequest = prepareTaskRequest()

        val response = restTemplate
                .withBasicAuth(DEFAULT_ADMIN, DEFAULT_PASSWORD)
                .postForEntity(
                        getRootUrl(),
                        taskRequest,
                        Task::class.java
                )

        assertEquals(201, response.statusCode.value())
        assertNotNull(response.body)
        assertNotNull(response.body?.id)
        assertEquals(taskRequest.description, response.body?.description)
        assertEquals(taskRequest.title, response.body?.title)
    }

    @Test
    fun `should update existing task as an ADMIN user`() {
        saveOneTask()
        val taskRequest = prepareTaskRequest()

        val updateResponse = restTemplate
                .withBasicAuth(DEFAULT_ADMIN, DEFAULT_PASSWORD)
                .exchange(
                        getRootUrl() + "/$defaultTaskId",
                        HttpMethod.PUT,
                        HttpEntity(taskRequest, HttpHeaders()),
                        Task::class.java
                )
        val updatedTask = taskRepository.findOneById(defaultTaskId)

        assertEquals(200, updateResponse.statusCode.value())
        assertEquals(defaultTaskId, updatedTask.id)
        assertEquals(taskRequest.description, updatedTask.description)
        assertEquals(taskRequest.title, updatedTask.title)
    }

    @Test
    fun `should delete existing task as an ADMIN user`() {
        saveOneTask()

        val delete = restTemplate
                .withBasicAuth(DEFAULT_ADMIN, DEFAULT_PASSWORD)
                .exchange(
                getRootUrl() + "/$defaultTaskId",
                HttpMethod.DELETE,
                HttpEntity(null, HttpHeaders()),
                ResponseEntity::class.java
        )

        assertEquals(204, delete.statusCode.value())
        assertThrows(EmptyResultDataAccessException::class.java) { taskRepository.findOneById(defaultTaskId) }
    }

    @Test
    fun `should not allow to return all tasks list for a non-authenticated user`() {
        saveOneTask()

        val response = restTemplate
                .getForEntity(
                getRootUrl(),
                Object::class.java
        )

        assertEquals(401, response.statusCode.value())
    }

    @Test
    fun `should not allow to return all tasks list for a non-authorized user`() {
        saveOneTask()

        val response = restTemplate
                .withBasicAuth(DEFAULT_USER, DEFAULT_PASSWORD)
                .getForEntity(
                        getRootUrl(),
                        Object::class.java
                )

        assertEquals(403, response.statusCode.value())
    }

    @Test
    fun `should not allow to create new task as a non-authorized user`() {
        val taskRequest = prepareTaskRequest()

        val response = restTemplate
                .withBasicAuth(DEFAULT_USER, DEFAULT_PASSWORD)
                .postForEntity(
                        getRootUrl(),
                        taskRequest,
                        Object::class.java
                )

        assertEquals(403, response.statusCode.value())
    }

    @Test
    fun `should not allow to update existing task as a non-authorized user`() {
        saveOneTask()
        val taskRequest = prepareTaskRequest()

        val updateResponse = restTemplate
                .withBasicAuth(DEFAULT_USER, DEFAULT_PASSWORD)
                .exchange(
                        getRootUrl() + "/$defaultTaskId",
                        HttpMethod.PUT,
                        HttpEntity(taskRequest, HttpHeaders()),
                        Object::class.java
                )

        assertEquals(403, updateResponse.statusCode.value())
    }

    @Test
    fun `should not allow to delete existing task as a non-authorized user`() {
        saveOneTask()

        val delete = restTemplate
                .withBasicAuth(DEFAULT_USER, DEFAULT_PASSWORD)
                .exchange(
                        getRootUrl() + "/$defaultTaskId",
                        HttpMethod.DELETE,
                        HttpEntity(null, HttpHeaders()),
                        Object::class.java
                )

        assertEquals(403, delete.statusCode.value())
    }

    private fun getRootUrl(): String? = "http://localhost:$port/tasks"

    private fun saveOneTask() = taskRepository.save(Task(defaultTaskId, "Title", "Description"))

    private fun prepareTaskRequest() = TaskRequest("Default title", "Default description")

    private fun checkIfSingleTagIsReturned(userName: String) {
        saveOneTask()

        val response = restTemplate
                .withBasicAuth(userName, DEFAULT_PASSWORD)
                .getForEntity(
                        getRootUrl() + "/$defaultTaskId",
                        Task::class.java
                )

        assertEquals(200, response.statusCode.value())
        assertNotNull(response.body)
        assertEquals(defaultTaskId, response.body?.id)
    }
}
TaskControllerIntegrationTest.kt

And the result is satisfying:

Testing with curl

Of course, you can test it manually as well, using your terminal along with curl.

Take a look at example requests and responses:

$ curl localhost:8080/tasks
{
  "timestamp": "2020-03-14T08:37:16.718+0000",
  "status": 401,
  "error": "Unauthorized",
  "message": "Unauthorized",
  "path": "/tasks"
}

$ curl localhost:8080/tasks -u user:pass
{
  "timestamp": "2020-03-14T08:38:49.094+0000",
  "status": 403,
  "error": "Forbidden",
  "message": "Forbidden",
  "path": "/tasks"
}

$ curl localhost:8080/tasks -u admin:pass
[
  {
    "id": {
      "timestamp": 1584174661,
      "counter": 923070,
      "date": "2020-03-14T08:31:01.000+0000",
      "time": 1584174661000,
      "machineIdentifier": 14441788,
      "processIdentifier": 4120,
      "timeSecond": 1584174661
    },
    "title": "Title",
    "description": "Description",
    "createdDate": "2020-03-14T09:31:03.327",
    "modifiedDate": "2020-03-14T09:31:03.327"
  }
]

$ curl -d '{"title":"Test title","description":"Test description"}' -H "Content-Type: application/json" -X POST http://localhost:8080/tasks -u admin:pass
{
  "id": {
    "timestamp": 1584175283,
    "counter": 4450354,
    "date": "2020-03-14T08:41:23.000+0000",
    "time": 1584175283000,
    "machineIdentifier": 6450927,
    "processIdentifier": 25501,
    "timeSecond": 1584175283
  },
  "title": "Test title",
  "description": "Test description",
  "createdDate": "2020-03-14T09:41:23.575904",
  "modifiedDate": "2020-03-14T09:41:23.575927"
}

 

Conclusion

As you can see the basic Spring Security integration is very easy and works as expected out-of-the-box.

Please keep in mind that this tutorial provides basic security integration which is good but not entirely safe. Sending credentials with every request is not a secure approach. You should never use it publically without protecting your traffic with HTTPS.

The approach demonstrated in this blog post could be enough for basic purposes on the one hand, but on the other hand, you’ll need more sophisticated solutions like OAuth2.0.

I hope this post will help you to introduce basic protection mechanisms into your Spring Boot application.

Good luck!

 

Leave a Reply

Your email address will not be published. Required fields are marked *