User Roles and ThymeLeaf Extras
Today, we'll look at adding user roles and some excellent features of the Thymeleaf library, such as the ability to show and hide content based on these roles.
The last lesson taught us how to use spring security to build a basic login form.
Today, we'll be looking at adding user roles and some excellent features of the Thymeleaf library, including the ability to show and hide content based on these roles.
Dependencies
As with all Spring boot applications, some starter libraries make adding jars to your classpath easy.
<?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 add another user to our application's WebSecurityConfig.configureGlobal method.
This time, giving them a new role as 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 web server to secure.
Our example code shows we have allowed unsecured 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 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 use the 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 user’s roles (what they are permitted 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 attribute options, see the Thymeleaf documentation
Demo
To run the demo, check out the source code, open the Application class and right-click run.
To start the example, port 8080 must 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's containsString method, we can check whether the content is rendered (or not) based on a particular user role.
Crucially, the spring test annotation @WithMockUser allows us 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. From this, we can imitate a front-end client making requests to the server.
@WithMockUser — It allows us to mock certain users, such as authenticated users.
FormLoginRequestBuilder — A utility class that allows us to create a form-based login request.
Next, we will continue to cover Spring Security's user roles, but this time, we will redirect admins to their admin page (admin.html) and secure this page so that only admin users can access it.