logo

User Roles and ThymeLeaf Extras

Published on
Authors

We learned how to use spring security to build a basic login form in the last lesson. Today we'll be looking at adding user roles and some nice features of the Thymeleaf library to show and hide content based on these roles.


Dependencies

As with all Spring boot applications, some starter libraries make it easy to add jars to your classpath.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
		 xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>

	<groupId>dev.mwhyte</groupId>
	<artifactId>spring-security-basic</artifactId>
	<version>0.0.1-SNAPSHOT</version>

	<name>spring-security-basic</name>
	<description>An introduction to spring security</description>

	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>2.6.1</version>
	</parent>

	<properties>
		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
		<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
		<java.version>17</java.version>
	</properties>

	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-thymeleaf</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-security</artifactId>
		</dependency>
		<dependency>
			<groupId>org.thymeleaf.extras</groupId>
			<artifactId>thymeleaf-extras-springsecurity5</artifactId>
			<version>3.0.4.RELEASE</version>
		</dependency>


		<!-- TEST -->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
		<dependency>
			<groupId>org.springframework.security</groupId>
			<artifactId>spring-security-test</artifactId>
			<scope>test</scope>
		</dependency>
	</dependencies>

	<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
			</plugin>
		</plugins>
	</build>
</project>

WebSecurityConfig

First, we'll be adding another user to our application's WebSecurityConfig.configureGlobal method. This time giving them a new role admin. The existing user has been granted the user role .roles("USER") :

package dev.mwhyte.spring.sec.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .antMatchers( "/css/**", "/images/**", "/favicon.ico").permitAll()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .loginPage("/login")
                .defaultSuccessUrl("/index")
                .permitAll()
                .and()
                .logout()
                .permitAll()
                .and().csrf().disable(); // we'll enable this in a later blog post
    }

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        auth
                .inMemoryAuthentication()
                .withUser("user").password("{noop}pass").roles("USER")
                .and()
                .withUser("admin").password("{noop}pass").roles("ADMIN");
    }
}

This now gives us two available users to log in with. Each user is configured with a different role that we can now use to hide and show different content.

The remainder of this class remains unchanged, and the key points can be summarised as:

  • authorizeRequests() — Allows us to configure which resources on our webserver to secure. You can see from our example code we have allowed un-secured access to our CSS, images directory and favicon. All other resources are secured and can only be accessed by an authenticated user.

  • formLogin() Tells spring security that we wish to use a login form. We provide the URL we want to redirect to if the authentication is successful, and finally, we permit access to the login and logout endpoints.

  • We are disabling cross-site request forgery protection, which is enabled by default. We will cover this later in the series.


Thymeleaf: Authorize

Next, we'll be using a Thymeleaf attribute sec:authorize to check user roles before rendering the divs in our index.html page.

To do this, we first need to add the thymeleaf XML namespace to our index.html.

<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/extras/spring-security"/>

Then we can use the sec:authorize attribute to check a users roles (what they are authorised to see):

<div sec:authorize="hasRole('ADMIN')">This content is only shown to administrators.§§</div>

<div sec:authorize="hasRole('USER')">This content is only shown to users.</div>

As you can see, the attribute sec:authorize is added to each div, and we use the spring security dialect to check users spring security roles. Content will only be rendered if the logged-in user has that role. i.e. hasRole returns true.

It is important to note that content is not just hidden from view but will not be rendered when our application server returns the page to the browser.


Thymeleaf: Authentication

Another useful Thymeleaf feature is the sec:authentication attribute. This attribute can return various security-related metadata. In the example below, we can retrieve the user's username and roles and display these in our HTML.

<div>
  User: <span sec:authentication="name">NOT FOUND</span> Spring Roles:
  <span sec:authentication="principal.authorities">NOT FOUND</span>
</div>

For more helpful attribute's see the Thymeleaf documentation


Demo

To run the demo, check out the source code and open the Application class and right-click run.

To start the example, port 8080 will need to be available on your machine. If it is not, you can change this default in the application.properties file using:

server.port=8081

Set this to whatever value you wish.

Alternatively, you can watch this short video:


Unit Testing

We've added a few more unit tests to cover this new functionality:

package dev.mwhyte.spring.sec;

import org.hamcrest.CoreMatchers;
import org.hamcrest.Matcher;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.FormLoginRequestBuilder;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.servlet.MockMvc;

import static org.hamcrest.core.StringContains.containsString;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin;
import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated;
import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.unauthenticated;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@ExtendWith(SpringExtension.class)
@SpringBootTest
@AutoConfigureMockMvc
public class ApplicationTests {
    @Autowired
    private MockMvc mockMvc;

    @Test
    public void loginWithValidUserThenAuthenticated() throws Exception {
        FormLoginRequestBuilder login = formLogin()
                .user("user")
                .password("pass");

        mockMvc.perform(login)
                .andExpect(authenticated().withUsername("user"));
    }

    @Test
    public void loginWithInvalidUserThenUnauthenticated() throws Exception {
        FormLoginRequestBuilder login = formLogin()
                .user("invalid")
                .password("invalidpassword");

        mockMvc.perform(login)
                .andExpect(unauthenticated());
    }

    @Test
    public void accessUnsecuredResourceThenOk() throws Exception {
        mockMvc.perform(get("/css/style.css"))
                .andExpect(status().isOk());
    }

    @Test
    public void accessSecuredResourceUnauthenticatedThenRedirectsToLogin() throws Exception {
        mockMvc.perform(get("/hello"))
                .andExpect(status().is3xxRedirection())
                .andExpect(redirectedUrlPattern("**/login"));
    }

    @Test
    @WithMockUser
    public void accessSecuredResourceAuthenticatedThenOk() throws Exception {
        mockMvc.perform(get("/index"))
                .andExpect(status().isOk());
    }


    @Test
    @WithMockUser(roles = "USER")
    public void loginWithRoleUserThenExpectUserSpecificContent() throws Exception {
        mockMvc.perform(get("/index"))
                .andExpect(status().isOk())
                .andExpect(content().string(containsString("This content is only shown to users.")))
                .andExpect(content().string(doesNotContainString("This content is only shown to administrators.")));
    }

    @Test
    @WithMockUser(roles = "ADMIN")
    public void loginWithRoleAdminThenExpectAdminSpecificContent() throws Exception {
        mockMvc.perform(get("/index"))
                .andExpect(status().isOk())
                .andExpect(content().string(containsString("This content is only shown to administrators.")))
                .andExpect(content().string(doesNotContainString("This content is only shown to users.")));
    }

    private Matcher<String> doesNotContainString(String s) {
        return CoreMatchers.not(containsString(s));
    }

}

We've created a small utility method doesNotContainString. Using this and Hamcrests containsString method, we can check that content is rendered (or not) based on a particular user role.

Crucially, the spring test annotation @WithMockUser provides the ability to mock certain users—an authenticated user with user or admin roles in our scenario.

Here is a quick recap of the Spring test support we are using:

  • @ExtendWith(SpringExtension.class) - Tells JUnit5 to run unit tests with Spring's testing support
  • @SpringBootTest — Run as spring boot app. i.e. load application.properties and spring beans
  • @AutoConfigureMockMvc — Creates a Test helper class called MockMvc. We can imitate a front-end client making requests to the server from this.
  • @WithMockUser — Provides the ability to mock certain users—an authenticated user in our case.
  • FormLoginRequestBuilder — A utility class that allows us to create a form-based login request.

Next up, we will continue to cover spring security's user roles, but this time we will be redirecting admins to their own admin page (admin.html) and securing this page so that only admin users can access it.

Spring Security — Redirect Based on User Roles


Resources

GitHub - mwhyte-dev/spring-security Spring Security docs Thymeleaf + Spring Security integration basics