Recently, I've decided to use the latest Spring technology to create a simple web application with sign-in page. The design is the same as the one I have done before, Spring session is stored in the MySQL database. A sign-in page using Spring Security for user authentication, and ThymeLeaf and its security components for page display based on user assigned security roles. It is a very simple application, designed as a reference application or an application template, of which we can build more functionalities to it.
Spring Boot and its add-on components are ever evolving, and I will do this again in a year or so, these reference apps will help me stay on top of these technologies. It will give me answers in case I need it. It is a healthy exercise for me to stay on top of things.
The Java version used is Java 24. The Maven version used is 3.9.x. I used docker to host the latest MySQL server. These are the basic technology stack.
For Spring Boot, I use the version 4.0.0-M3. To enable Spring Session, I need spring-session-jdbc, version 4.0.0-M2 (there is no M3 at the time I wrote this). To interact with the MySQL, I use mysql-connector-j as JDBC connector. And for connection pooling configuration (application.properties), I use commons-dbcp2 from Apache (the latest version). I need javax.servlet-api because without this, the application will boot up with an exception about a missing class from servlet-api. It is quite weird since I thought Spring Boot will provide all are needed. But this dependency is needed. Lastly, the most important dependency needed is thymeleaf-extras-springsecurity6, the number at the end must be "6", or Spring Security for the web page will not work correctly.
All of these are defined in the POM file:
<?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>
<artifactId>hanbo-movie-app</artifactId>
<name>Hanbo Move And TV Show App</name>
<description>This is an experimental app for playing Movies and TV shows</description>
<version>1.0.0</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.0-M3</version>
</parent>
<properties>
<java.version>24</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-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-jdbc</artifactId>
<version>4.0.0-M2</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-dbcp2</artifactId>
<version>2.13.0</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>9.5.0</version>
</dependency>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>4.0.1</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity6</artifactId>
<version>3.1.3.RELEASE</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
Docker file for MySQL database hosting looks like this:
FROM mysql:latest ENV MYSQL_ROOT_PASSWORD='<mysql root password goes here>' COPY testdb.sql /docker-entrypoint-initdb.d/
The MySQL database setup script looks like this:
/* testdb.sql */ DROP USER IF EXISTS '<db user name>'@'localhost'; DROP USER IF EXISTS '<same db user name>'@'%'; DROP DATABASE IF EXISTS `<db name>`; CREATE DATABASE `<db name>`; ALTER DATABASE `<db name>` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER '<db user name>'@'localhost' IDENTIFIED BY '<db user password>'; CREATE USER '<db user name>'@'%' IDENTIFIED BY '<db user password>'; GRANT ALL ON `<db name>`.* TO '<db user name>'@'localhost'; GRANT ALL ON `<db name>`.* TO '<db user name>'@'%'; FLUSH PRIVILEGES; USE `<db name>`; DROP TABLE IF EXISTS SPRING_SESSION_ATTRIBUTES; DROP TABLE IF EXISTS SPRING_SESSION; CREATE TABLE SPRING_SESSION ( PRIMARY_ID CHAR(36) NOT NULL, SESSION_ID CHAR(36) NOT NULL, CREATION_TIME BIGINT NOT NULL, LAST_ACCESS_TIME BIGINT NOT NULL, MAX_INACTIVE_INTERVAL INT NOT NULL, EXPIRY_TIME BIGINT NOT NULL, PRINCIPAL_NAME VARCHAR(100), CONSTRAINT SPRING_SESSION_PK PRIMARY KEY (PRIMARY_ID) ) ENGINE=InnoDB ROW_FORMAT=DYNAMIC; CREATE UNIQUE INDEX SPRING_SESSION_IX1 ON SPRING_SESSION (SESSION_ID); CREATE INDEX SPRING_SESSION_IX2 ON SPRING_SESSION (EXPIRY_TIME); CREATE INDEX SPRING_SESSION_IX3 ON SPRING_SESSION (PRINCIPAL_NAME); CREATE TABLE SPRING_SESSION_ATTRIBUTES ( SESSION_PRIMARY_ID CHAR(36) NOT NULL, ATTRIBUTE_NAME VARCHAR(200) NOT NULL, ATTRIBUTE_BYTES BLOB NOT NULL, CONSTRAINT SPRING_SESSION_ATTRIBUTES_PK PRIMARY KEY (SESSION_PRIMARY_ID, ATTRIBUTE_NAME), CONSTRAINT SPRING_SESSION_ATTRIBUTES_FK FOREIGN KEY (SESSION_PRIMARY_ID) REFERENCES SPRING_SESSION(PRIMARY_ID) ON DELETE CASCADE ) ENGINE=InnoDB ROW_FORMAT=DYNAMIC; DROP TABLE IF EXISTS `userrole`; DROP TABLE IF EXISTS `passwordsalt`; DROP TABLE IF EXISTS `userinfo`; CREATE TABLE `userinfo` ( `id` VARCHAR(33) NOT NULL, `username` VARCHAR(64) NOT NULL, `active` BIT(1) NOT NULL DEFAULT 1, `password` VARCHAR(68) NOT NULL, `createdby` VARCHAR(64) NULL, `createddate` DATETIME NULL, `updatedby` VARCHAR(64) NULL, `updateddate` DATETIME NULL, CONSTRAINT `userinfo_pk` PRIMARY KEY (`id`) ); CREATE TABLE `passwordsalt` ( `id` VARCHAR(33) NOT NULL, `saltvalue1` VARCHAR(16) NOT NULL, `saltvalue2` VARCHAR(16) NOT NULL, `createdby` VARCHAR(64) NULL, `createddate` DATETIME NULL, `updatedby` VARCHAR(64) NULL, `updateddate` DATETIME NULL, CONSTRAINT `passwordsalt_pk` PRIMARY KEY (`id`), CONSTRAINT `passwordsalt_to_userinfo` FOREIGN KEY `fk_passwordsalt_to_userinfo` (`id`) REFERENCES `userinfo`(`id`) ); CREATE TABLE `userrole` ( `id` VARCHAR(33) NOT NULL, `rolename` VARCHAR(64) NOT NULL, `createdby` VARCHAR(64) NULL, `createddate` DATETIME NULL, `updatedby` VARCHAR(64) NULL, `updateddate` DATETIME NULL, CONSTRAINT `userrole_pk` PRIMARY KEY (`id`, `rolename`), CONSTRAINT `userrole_to_userinfo` FOREIGN KEY `fk_userrole_to_userinfo` (`id`) REFERENCES `userinfo`(`id`) ); INSERT INTO `userinfo` ( `id`, `username`, `active`, `password`, `createdby`, `createddate`, `updatedby`, `updateddate` ) VALUES ( '<app user id (GUID with no dash)>', '<app user name>', 1, '<hashed user password>', 'sysadmin', NOW(), 'sysadmin', NOW() ); INSERT INTO `passwordsalt` ( `id`, `saltvalue1`, `saltvalue2`, `createdby`, `createddate`, `updatedby`, `updateddate` ) VALUES ( '<app user id (GUID with no dash)>', '<password salt 1>', '<password salt 2>', 'sysadmin', NOW(), 'sysadmin', NOW() ); INSERT INTO `userrole` ( `id`, `rolename`, `createdby`, `createddate`, `updatedby`, `updateddate` ) VALUES ( '<app user id>', 'Administrator', 'sysadmin', NOW(), 'sysadmin', NOW() );
The top part of the script creates the standard Spring session database tables. The bottom part creates the app user tables for storing user credential and user role.
To create the docker image, I use the following command:
docker build -t 'hanmysql1' .
To run the docker image in a container, I use the following command:
docker run -dit -p 3306:3306 'hanmysql1'
These are the basic setup I needed for this sample application. In the next section, I will describe the application design in details.
The application is a Spring Boot based web application. We need an application entry point. The file looks like this:
package org.hanbo.boot.app;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class App
{
public static void main(String[] args)
{
SpringApplication.run(App.class, args);
}
}
This is a typical Spring Boot application entry point. Next, I need a class that defines how user can access pages. Some pages have to be publicly accessible, like the login page, the logged out page, the access denied page. Some pages must be accessed only after user is authenticated and authorized. Spring Security 4 to 5 had the same type of mechanism for HTTP access configurations. The newest one I used, had a different type of configuration. It is similar to how it is done for Spring Security 4 or 5. Here is how the whole file looks like:
package org.hanbo.boot.app.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.hanbo.boot.app.security.UserAuthenticationService;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class WebAppSecurityConfig {
@Autowired
private UserAuthenticationService authenticationProvider;
@Autowired
private AccessDeniedHandler accessDeniedHandler;
@Bean
public SecurityFilterChain securedFilterChain(HttpSecurity http)
throws Exception
{
http.csrf((x) -> {
x.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
})
.authorizeHttpRequests((x) -> {
x.requestMatchers("/assets/**", "/public/**").permitAll().anyRequest().authenticated();
}).formLogin((x) -> {
x.loginPage("/login").permitAll().usernameParameter("username").passwordParameter("userpass")
.successHandler(new SavedRequestAwareAuthenticationSuccessHandler())
.defaultSuccessUrl("/secure/index", true).failureUrl("/public/authFailed");
}).logout((x) -> {
x.logoutSuccessUrl("/public/logout").permitAll();
}).exceptionHandling((x) -> {
x.accessDeniedHandler(accessDeniedHandler);
});
return http.build();
}
@Bean
public AuthenticationManager authManager(HttpSecurity http)
throws Exception
{
AuthenticationManagerBuilder authenticationManagerBuilder = http
.getSharedObject(AuthenticationManagerBuilder.class);
authenticationManagerBuilder.authenticationProvider(authenticationProvider);
return authenticationManagerBuilder.build();
}
}
There are a few things about this class. The first is the annotations used for the class:
@Configuration @EnableWebSecurity @EnableMethodSecurity
The importance is centered on the second and the third line. The second line mark the whole application to be protected by Spring Security. Any restricted pages must be accessed after user is somehow logged in. The third line enables the @PreAuthorize() used by the methods in the SecurePageController class. Without this, all of the methods in the controller are accessible without any authentication or authorization.
Next, it is the bean method securedFilterChain(). This method contains the configuration for page access. It is drastically different from Spring Security 4 and 5:
@Bean
public SecurityFilterChain securedFilterChain(HttpSecurity http)
throws Exception
{
http.csrf((x) -> {
x.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
})
.authorizeHttpRequests((x) -> {
x.requestMatchers("/assets/**", "/public/**").permitAll().anyRequest().authenticated();
}).formLogin((x) -> {
x.loginPage("/login").permitAll().usernameParameter("username").passwordParameter("userpass")
.successHandler(new SavedRequestAwareAuthenticationSuccessHandler())
.defaultSuccessUrl("/secure/index", true).failureUrl("/public/authFailed");
}).logout((x) -> {
x.logoutSuccessUrl("/public/logout").permitAll();
}).exceptionHandling((x) -> {
x.accessDeniedHandler(accessDeniedHandler);
});
return http.build();
}
The way I have done in the code works exactly the same as my previous projects. In this latest Spring Security, the library significantly changed the way these configuration can be defined. A little search and experiment, I was able to figure out how this works. It took about 15 minutes. And it worked.
The bean method authManager() works as before.
We also need to use a class to enable Spring Session. In it, I can do two things. The first is enabling the Spring Session using a class annotation. This is how:
@EnableJdbcHttpSession(maxInactiveIntervalInSeconds=300)
@Configuration
public class SessionDataAccessConfiguration
{
...
}
The first line of the code snippet enables the security session, and I set the session duration to 5 minutes (300 seconds).
The next thing is the the JDBC data access configuration. This is not only for Spring Security session to access the database, but also enables the application itself to access the database. It is truly one stone drops two birds. This is how I define the session data source:
@SpringSessionDataSource
@Bean
public DataSource sessionDataSource()
{
BasicDataSource dataSource = new BasicDataSource();
dataSource.setDriverClassName(dbJdbcDriver);
dataSource.setUrl(dbSesnConnString);
dataSource.setUsername(dbSesnAccessUserName);
dataSource.setPassword(dbSesnAccessPassword);
dataSource.setMaxIdle(4);
dataSource.setMaxTotal(20);
dataSource.setInitialSize(4);
dataSource.setMaxWait(Duration.of(180, ChronoUnit.SECONDS));
dataSource.setTestOnBorrow(true);
dataSource.setValidationQuery(dbSesnAccessValityQuery);
return dataSource;
}
This data source bean method will provide database connection for both Spring JDBC session and for application's own data access operations. It returns an object of the tpe BasicDataSource. This class is defined in Apache's commons-dbcp2 jar(s).
I also need two beans for JDBC transaction management. Here they are:
@Bean
public PlatformTransactionManager sessionTransactionManager(DataSource dataSource)
{
return new DataSourceTransactionManager(dataSource);
}
@Bean
public TransactionOperations springSessionTransactionOperations(PlatformTransactionManager sessionTransactionManager)
{
return new TransactionTemplate(sessionTransactionManager);
}
I believe, with these two bean methods, I can use the annotation @Transaction on the methods in the repository class. For accessing the user info in the database, all I need is a NamedParameterJdbcTemplate, so I need a bean that generates such an object, and later I can inject into places where I need it. Here is the bean method that generates this:
@Bean
public NamedParameterJdbcTemplate namedParameterJdbcTemplate(DataSource dataSource)
{
NamedParameterJdbcTemplate retBean
= new NamedParameterJdbcTemplate(dataSource);
return retBean;
}
The whole class for the Spring session and JDBC access configuration looks like this:
package org.hanbo.boot.app.config;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
import javax.sql.DataSource;
import org.apache.commons.dbcp2.BasicDataSource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.session.jdbc.config.annotation.SpringSessionDataSource;
import org.springframework.session.jdbc.config.annotation.web.http.EnableJdbcHttpSession;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionOperations;
import org.springframework.transaction.support.TransactionTemplate;
@EnableJdbcHttpSession(maxInactiveIntervalInSeconds=300)
@Configuration
public class SessionDataAccessConfiguration
{
@Value("${db.jdbc.driver}")
private String dbJdbcDriver;
@Value("${db.session.conn.string}")
private String dbSesnConnString;
@Value("${db.session.access.username}")
private String dbSesnAccessUserName;
@Value("${db.session.access.password}")
private String dbSesnAccessPassword;
@Value("${db.session.access.validity.query}")
private String dbSesnAccessValityQuery;
@SpringSessionDataSource
@Bean
public DataSource sessionDataSource()
{
BasicDataSource dataSource = new BasicDataSource();
dataSource.setDriverClassName(dbJdbcDriver);
dataSource.setUrl(dbSesnConnString);
dataSource.setUsername(dbSesnAccessUserName);
dataSource.setPassword(dbSesnAccessPassword);
dataSource.setMaxIdle(4);
dataSource.setMaxTotal(20);
dataSource.setInitialSize(4);
dataSource.setMaxWait(Duration.of(180, ChronoUnit.SECONDS));
dataSource.setTestOnBorrow(true);
dataSource.setValidationQuery(dbSesnAccessValityQuery);
return dataSource;
}
@Bean
public PlatformTransactionManager sessionTransactionManager(DataSource dataSource)
{
return new DataSourceTransactionManager(dataSource);
}
@Bean
public TransactionOperations springSessionTransactionOperations(PlatformTransactionManager sessionTransactionManager)
{
return new TransactionTemplate(sessionTransactionManager);
}
@Bean
public NamedParameterJdbcTemplate namedParameterJdbcTemplate(DataSource dataSource)
{
NamedParameterJdbcTemplate retBean
= new NamedParameterJdbcTemplate(dataSource);
return retBean;
}
}
The info needed to connect to the MySQL database are all defined in the application.properties. The key names are available in the annotations @Value().
Another class I need is to authenticate the user, and assign at least one user role to the user. Afterward, the user will have the access to view the secured pages. What I need is a class derive from the Spring's AuthenticationProvider interface; then a bean in my WebAppSecurityConfig class to return an instance of my user authentication provider class.
My user authentication provider class looks like this:
package org.hanbo.boot.app.security;
import java.util.ArrayList;
import java.util.List;
import org.hanbo.boot.app.dataAccess.UserInfoDataAccess;
import org.hanbo.boot.app.entities.UserInfo;
import org.hanbo.boot.app.models.AuthenticatedUser;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@Service
public class UserAuthenticationService
implements AuthenticationProvider
{
private UserInfoDataAccess _userInfoDataAccess;
public UserAuthenticationService(UserInfoDataAccess userInfoDataAccess)
{
_userInfoDataAccess = userInfoDataAccess;
}
@Override
public Authentication authenticate(Authentication auth) throws AuthenticationException
{
Authentication retVal = null;
if (auth != null)
{
String userName = auth.getName();
String userPass = auth.getCredentials().toString();
System.out.println("name: " + userName);
System.out.println("password: " + userPass);
retVal = authenticateUser(userName, userPass);
}
else
{
retVal = invalidAuthentication();
}
return retVal;
}
@Override
public boolean supports(Class<?> tokenType)
{
return tokenType.equals(UsernamePasswordAuthenticationToken.class);
}
protected Authentication authenticateUser(String userName, String password)
{
Authentication retVal = null;
if (!StringUtils.hasText(userName))
{
retVal = invalidAuthentication();
return retVal;
}
if (!StringUtils.hasText(password))
{
retVal = invalidAuthentication();
return retVal;
}
UserInfo userInfo = _userInfoDataAccess.getUserCredentialByName(userName);
if (userInfo != null)
{
String encryptedPassword = userInfo.getEncryptedUserPass();
String salt1 = userInfo.getPasswordSalt1();
String salt2 = userInfo.getPasswordSalt2();
if (!StringUtils.hasText(encryptedPassword))
{
retVal = invalidAuthentication();
return retVal;
}
if (!StringUtils.hasText(salt1))
{
retVal = invalidAuthentication();
return retVal;
}
if (!StringUtils.hasText(salt2))
{
retVal = invalidAuthentication();
return retVal;
}
String saltedPassword = String.format("%s%s%s", salt1, password, salt2);
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
boolean arePasswordsMatch = encoder.matches(saltedPassword, encryptedPassword);
if (arePasswordsMatch)
{
List<GrantedAuthority> userRoles = GetUserAuthorities(userInfo.getUserRoles());
AuthenticatedUser authUser = new AuthenticatedUser();
authUser.setUserId(userInfo.getUserId());
authUser.setActive(true);
authUser.setUserName(userInfo.getUserName());
authUser.getUserRoles().clear();
transferUserRolesToAuthUser(userRoles, authUser.getUserRoles());
retVal = new UsernamePasswordAuthenticationToken(
authUser, "authenticated", userRoles
);
return retVal;
}
else
{
retVal = invalidAuthentication();
return retVal;
}
}
else
{
retVal = invalidAuthentication();
return retVal;
}
}
protected Authentication invalidAuthentication()
{
return new UsernamePasswordAuthenticationToken(
null, null, new ArrayList<GrantedAuthority>()
);
}
private List<GrantedAuthority> GetUserAuthorities(List<String> rolesFromDB)
{
System.out.println("called");
List<GrantedAuthority> retVal = new ArrayList<GrantedAuthority>();
if (rolesFromDB != null && rolesFromDB.size() > 0)
{
for (String roleName : rolesFromDB)
{
System.out.println("Role Name: " + roleName);
if (StringUtils.hasText(roleName))
{
if (roleName.equalsIgnoreCase("administrator"))
{
System.out.println("administrator");
insertUniqueRole(retVal, new SimpleGrantedAuthority("ROLE_ADMIN"));
insertUniqueRole(retVal, new SimpleGrantedAuthority("ROLE_STAFF"));
insertUniqueRole(retVal, new SimpleGrantedAuthority("ROLE_USER"));
}
else if (roleName.equalsIgnoreCase("staffer"))
{
System.out.println("staffer");
insertUniqueRole(retVal, new SimpleGrantedAuthority("ROLE_STAFF"));
insertUniqueRole(retVal, new SimpleGrantedAuthority("ROLE_USER"));
}
else if (roleName.equalsIgnoreCase("user"))
{
System.out.println("user");
insertUniqueRole(retVal, new SimpleGrantedAuthority("ROLE_USER"));
}
}
}
}
return retVal;
}
private void insertUniqueRole(List<GrantedAuthority> allRoles, GrantedAuthority roleToAdd)
{
if (allRoles == null)
{
return;
}
if (roleToAdd == null)
{
return;
}
String roleVal = roleToAdd.getAuthority();
if (!StringUtils.hasText(roleVal))
{
return;
}
for (GrantedAuthority auth : allRoles)
{
if (auth != null && auth.getAuthority() != null && auth.getAuthority().equalsIgnoreCase(roleVal))
{
return;
}
}
allRoles.add(roleToAdd);
}
private void transferUserRolesToAuthUser(List<GrantedAuthority> allRoles, List<String> authUserRoles)
{
if (allRoles == null || allRoles.size() == 0)
{
return;
}
if (authUserRoles == null)
{
return;
}
for (GrantedAuthority auth : allRoles)
{
if (auth != null && StringUtils.hasText(auth.getAuthority()))
{
System.out.println("Auth role added: " + auth.getAuthority());
authUserRoles.add(auth.getAuthority());
}
}
}
}
This class has the longest code segment. It does a lot of things. The two most important functionality in this class are that:
The way user password verification is done as the following:
String saltedPassword = String.format("%s%s%s", salt1, password, salt2);
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
boolean arePasswordsMatch = encoder.matches(saltedPassword, encryptedPassword);
This is the method that check and assign application roles to the authenticated user:
private List<GrantedAuthority> GetUserAuthorities(List<String> rolesFromDB)
{
System.out.println("called");
List<GrantedAuthority> retVal = new ArrayList<GrantedAuthority>();
if (rolesFromDB != null && rolesFromDB.size() > 0)
{
for (String roleName : rolesFromDB)
{
System.out.println("Role Name: " + roleName);
if (StringUtils.hasText(roleName))
{
if (roleName.equalsIgnoreCase("administrator"))
{
System.out.println("administrator");
insertUniqueRole(retVal, new SimpleGrantedAuthority("ROLE_ADMIN"));
insertUniqueRole(retVal, new SimpleGrantedAuthority("ROLE_STAFF"));
insertUniqueRole(retVal, new SimpleGrantedAuthority("ROLE_USER"));
}
else if (roleName.equalsIgnoreCase("staffer"))
{
System.out.println("staffer");
insertUniqueRole(retVal, new SimpleGrantedAuthority("ROLE_STAFF"));
insertUniqueRole(retVal, new SimpleGrantedAuthority("ROLE_USER"));
}
else if (roleName.equalsIgnoreCase("user"))
{
System.out.println("user");
insertUniqueRole(retVal, new SimpleGrantedAuthority("ROLE_USER"));
}
}
}
}
return retVal;
}
This method will assign 3 different roles to the authenticated user if the user is an admin user, admin, staff, and user. Essentially an admin user has all access to security pages. If the authenticated user is a staffer, then 2 roles are assigned, staff and user. This user will not be able to access admin specific pages. Finally, if authenticated user is just a plain user, then 1 role - user - would be assigned. This user will not be able to access pages exclusive for staff level or exclusive to admin level users.
Before going into the SecuredPageController class, I need to show one important change I did to the index.html page. To use Thymeleaf properly, at the beginning of the page, I need to import the namespaces of the Thymeleaf tags. In order to use the Thymeleaf security tags sec:authorize, I need to do this at the beginning of the html page:
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:sec="https://www.thymeleaf.org/thymeleaf-extras-springsecurity6">
When I was doing the coding, I didn't have the part xmlns:sec. As a result, all the parts that are only viewable by specific user roles are all displaying. It took a long while before I realize the attribute sec:authorize was not working. And I found out that I was using thymeleaf-extras-springsecurity5 instead of thymeleaf-extras-springsecurity6, and it was not working with latest Thymeleaf/Spring Boot libraries. Once I upgraded to thymeleaf-extras-springsecurity6, and added that xmlns import. Everything in the HTML page started working.
Similarly, the SecuredPageController class has a few methods that had annotations @PreAuthorize, and they are not working at first. The issue is that I removed an old annotation, and didn't add the new @EnableMethodSecurity on class WebAppSecurityConfig. Once I finally figure this out, I was able add the missing annotation and have the methods with the @PreAuthorize annotations working as expected.
This is the sample index page source code:
<!DOCTYPE HTML>
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:sec="https://www.thymeleaf.org/thymeleaf-extras-springsecurity6">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>Login</title>
<link rel="stylesheet" th:href="@{/assets/bootstrap/css/bootstrap.min.css}"/>
<link rel="stylesheet" th:href="@{/assets/css/index.css}"/>
</head>
<body>
<div class="container-fluid">
<div th:replace="~{parts/pieces::logoutForm}">
</div>
<nav th:replace="~{parts/pieces::header}"></nav>
</div>
<div class="container">
<div class="row">
<div class="col-xs-12">
<div class="panel panel-default">
<div class="panel-body">
<h3>Index Page</h3>
<p>You can see this section as long as you are logged in.</p>
</div>
</div>
</div>
</div>
<div class="row" sec:authorize="hasRole('ROLE_USER')">
<div class="col-xs-12">
<div class="panel panel-default">
<div class="panel-body">
<h3>Only User can See This</h3>
<p>If you have the "ROLE_USER". You will be able to see this section. User Section.</p>
</div>
</div>
</div>
</div>
<div class="row" sec:authorize="hasRole('ROLE_STAFF')">
<div class="col-xs-12">
<div class="panel panel-default">
<div class="panel-body">
<h3>Only Staff can See This</h3>
<p>If you have the "ROLE_STAFF". You will be able to see this section. Staff Section.</p>
</div>
</div>
</div>
</div>
<div class="row" sec:authorize="hasRole('ROLE_ADMIN')">
<div class="col-xs-12">
<div class="panel panel-default">
<div class="panel-body">
<h3>Only Admin can See This</h3>
<p>If you have the "ROLE_ADMIN". You will be able to see this section. Admin Section.</p>
</div>
</div>
</div>
</div>
</div>
<script type="text/javascript" th:src="@{/assets/jquery/js/jquery-3.7.1.min.js}"></script>
<script type="text/javascript" th:src="@{/assets/bootstrap/js/bootstrap.min.js}"></script>
<script th:replace="~{parts/pieces::logoutJs}"></script>
</body>
</html>
This is the source code of the SecuredPageController class:
package org.hanbo.boot.app.controllers;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.servlet.ModelAndView;
@Controller
public class SecuredPageController
{
@PreAuthorize("isAuthenticated()")
@RequestMapping(value="/secure/index", method = RequestMethod.GET)
public ModelAndView index1()
{
ModelAndView retVal = new ModelAndView();
retVal.setViewName("indexPage");
return retVal;
}
@PreAuthorize("hasRole('ADMIN')")
@RequestMapping(value="/secure/adminPage", method = RequestMethod.GET)
public ModelAndView adminPage()
{
ModelAndView retVal = new ModelAndView();
retVal.setViewName("AdminPage");
return retVal;
}
@PreAuthorize("hasRole('STAFF')")
@RequestMapping(value="/secure/staffPage", method = RequestMethod.GET)
public ModelAndView staffPage()
{
ModelAndView retVal = new ModelAndView();
retVal.setViewName("StaffPage");
return retVal;
}
@PreAuthorize("hasRole('USER')")
@RequestMapping(value="/secure/userPage", method = RequestMethod.GET)
public ModelAndView userPage()
{
ModelAndView retVal = new ModelAndView();
retVal.setViewName("UserPage");
return retVal;
}
}
And this is everything about my new Spring Boot web application with enhanced security add-on. It is not super complicated, and it has everything I needed as a start point to create a great web application using Spring Boot, Spring Security and Thymeleaf.
I have done this a few times. So why do I repeat myself with a new version? The reason is that I need the new versions to upgrade my applications, without the prototype work done with this sample application, it would be somewhat difficult to do the upgrade work. If I need to create a new web application with secure access, without knowing all these details, the work can be difficult as well. As far as I know, this work and the effort of writing it down will save me a lot of time in the future.
This sample application is a start point that I need to pass. Once I step over this point, I can add more functionalities without worrying about the page security related designs. The work will become much easier. Writing this done as a reference for the future work would also save me a lot of time running around finding answers. This work took a week. And. It is totally worth the time.
Guest comment is not allowed for this post.
There is no comments to this post/article.