Purpose
This article continues on from part one of this tutorial series – How to build a simple CRM with Spring Boot and Thymeleaf showing you how to incorporate Spring Security to authenticate users. We also explore how we can use different ‘Roles’ to determine which system features our users can access..
To achieve this, we will;
- Establish a login screen and rules to lock down unauthenticated access.
- Establish a new User database table and entity.
- Introduce 3 roles, to reflect administrators with the maximum access, a standard user who can manage customers, and read-only users who can simply browse and view our customer records.
- We will also introduce a new “Users” page where administrators can create and edit User accounts.
Whenever we introduce a new feature in the system, we always face different options and choices. For instance, we can choose Username/Password authentication, authenticate users against an LDAP server or Active Directory, or even enable social sign-in with Google, or Facebook(Meta). For the purpose of this tutorial, I will focus on the traditional username/password workflow.
This tutorial is based on Java 17, Spring Boot 3, Spring Security 6, a H2 in-memory database, Lombok, and Thymeleaf for the view rendering.
All source code is available on our Github repository: https://github.com/tucanoo/crm_spring_boot/tree/part_two_authentication.
Let’s move on!
Database / JPA Entity Updates
It makes sense to first introduce the changes to our data model, beginning with the User. As usual, I am providing the minimal viable changes you need to achieve the desired outcome, a secured system.
Therefore in the new table, we’re just going to require a username and password, so the user can store and use their credentials to log in. We’ll also add an ‘enabled’ flag, so the administrator can deactivate/activate accounts and a full name field so the administrator knows clearly who the account belongs to.
Lastly, a simple single Role field so we can specify the user’s role. This often appears as a one-to-many relationship, but in this example, we have only three roles, and they are mutually exclusive. You wouldn’t want an administrator with read-only access for example!
It’s worth noting there are a number of other fields we could have added that Spring Security would make use of, such as password expired, account locked, etc.
JPA Entities
Under data/entities, alongside our Customer entity, we need to create the new User class as below.
@Getter
@Setter
@Entity
@Table(name = "appuser")
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User {
@Id
@Column
@GeneratedValue(strategy = GenerationType.IDENTITY)
Long id;
@NotBlank
String role;
@NotBlank
String username;
@NotBlank
String fullName;
@Column(nullable = false, length = 64)
String password;
Boolean enabled = true;
@CreationTimestamp
private LocalDateTime dateCreated;
}
Why is the password field so long? You may wonder, well because we are not going to store plain text passwords, but rather encrypted representations of the users’ passwords.
Repositories
We will also need an additional repository, with the means to find a user record by username. So under repositories, create a new User Repository with the following:
@Repository
public interface UserRepository extends CrudRepository<User, Long>,
PagingAndSortingRepository<User, Long>,
JpaSpecificationExecutor<User> {
Optional<User> findByUsername(String username);
}
We have also implemented the PagingAndSortingRepository and JPASpecificationExecutor classes so that administrators can both browse paged results and search across the username and full name fields.
As such, we will also need a new Specification class to perform the necessary filtering and return data for our Datatables-driven UI, so under the specifications package, create a UserDatatableFilter class containing the following:
public class UserDatatableFilter implements org.springframework.data.jpa.domain.Specification<User> {
String userQuery;
public UserDatatableFilter(String queryString) {
this.userQuery = queryString;
}
@Override
public Predicate toPredicate(Root<User> root, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder) {
ArrayList<Predicate> predicates = new ArrayList<>();
if (StringUtils.hasText(userQuery)) {
String lowerCaseQuery = userQuery.toLowerCase(); // Convert query to lowercase
predicates.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("username")), '%' + lowerCaseQuery + '%'));
predicates.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("fullName")), '%' + lowerCaseQuery + '%'));
}
return (!predicates.isEmpty() ? criteriaBuilder.or(predicates.toArray(new Predicate[predicates.size()])) : null);
}
}
Now we’ve established our new data model. In addition to initializing our sample customer data at startup, we can also create initial user accounts to log in.
Previously we used schema.sql and data.sql to establish our database, but this time, we need to change things a little as we want to introduce code in the app to establish the User records, as we’ll need to use an injected password encoder class.
So firstly, we need to change our DDL behaviour in application properties, so that hibernate will create our tables at startup. So change the line:
spring.jpa.hibernate.ddl-auto=none
to
spring.jpa.hibernate.ddl-auto=create
This means we can now delete resources/schema.sql. After this change, the system will ignore data.sql, so it won’t automatically insert any customers unless we rename this file to import.sql
You can even remove application.properties completely since the default value for ddl-auto is create-drop.
To establish users at startup, my typical practice is to create a file named Bootstrap.java under a ‘init’ package. Within this Class I establish an EventListener configured to listen to the ApplicationReadyEvent, and within here I perform any system initialisation tasks. This class should contain the following:
@RequiredArgsConstructor
@Component
public class Bootstrap {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
@EventListener
void appReady(ApplicationReadyEvent event) {
// Initialise initial users
// Create admin user.
if (! userRepository.findByUsername("admin").isPresent()) {
User adminUser = User.builder()
.username("admin")
.fullName("Sheev Palpatine")
.password(passwordEncoder.encode("admin"))
.role("ROLE_ADMIN")
.enabled(true)
.build();
userRepository.save(adminUser);
}
// Create standard user.
if (! userRepository.findByUsername("user").isPresent()) {
User adminUser = User.builder()
.username("user")
.fullName("Darth Tyranus")
.password(passwordEncoder.encode("user"))
.role("ROLE_USER")
.enabled(true)
.build();
userRepository.save(adminUser);
}
// create readonly user
if (! userRepository.findByUsername("readonly_user").isPresent()) {
User adminUser = User.builder()
.username("readonly_user")
.fullName("Shin Hati")
.password(passwordEncoder.encode("readonly_user"))
.role("ROLE_READONLY_USER")
.enabled(true)
.build();
userRepository.save(adminUser);
}
}
}
Here I am creating three users, with three distinct roles, with the usernames/passwords set to admin/admin, user/user, and readonly_user/readonly_user respectively.
Security Configuration

To lock down the system and establish the security rules required as per the image above, we need to introduce the following three dependencies:
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
- “spring-boot-starter-security” for Spring Security 6.
- “thymeleaf-extras-springsecurity6” to provide us with convenient security tags for use within our Thymeleaf views.
- “jackson-datatype-jsr310” is simply to provide support to our controllers for rendering Date structures.
Spring Security Interfaces
Before we can configure Spring Security, we will first need to provide two custom classes that will allow Spring Security to work with our User entity. Under a new ‘security’ package, create a new class named CustomUserDetails. This is going to implement Spring Security UserDetails but with a constructor that takes in our User entity so we can populate a UserDetails object. This Class plays a role throughout the Spring Security authentication process.
Enter the following into CustomUserDetails:
@RequiredArgsConstructor
public class CustomUserDetails implements UserDetails {
private final User user;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
SimpleGrantedAuthority authority = new SimpleGrantedAuthority(user.getRole());
return List.of(authority);
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return user.getEnabled();
}
}
Now we need to provide the means for Spring Security to authenticate using our User JPA entity, we can achieve this by implementing UserDetailsService. Therefore also in the ‘security’ package, create a new class named CustomUserDetailsService and enter the following code:
@Service
public class CustomUserDetailsService implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
return new CustomUserDetails(user);
}
}
You can see how this will use our additional method findByUsername we added to our repository, and if found establishes a CustomUserDetails instance. Spring Security will then use this to validate the password etc.
Configuration
Next, we need to provide the configuration to instruct Spring Security to use these classes.
Under a new ‘config’ package create a new class named SecurityConfig. This class will provide several security-related Beans. Due to their class types, Spring Security will automatically detect and use them.
This class should contain the following code:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
MvcRequestMatcher.Builder mvc(HandlerMappingIntrospector introspector) {
return new MvcRequestMatcher.Builder(introspector);
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http, MvcRequestMatcher.Builder mvc) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(requests -> requests
// only allow standard user and admin to edit customers
.requestMatchers(mvc.pattern("/customer/edit/**")).hasAnyRole("ADMIN", "USER")
// only allow admin to edit users
.requestMatchers(mvc.pattern("/user/**")).hasRole("ADMIN")
.anyRequest().authenticated()
)
// allow access to login endpoint
.formLogin(form -> form
.loginPage("/login")
.permitAll()
)
// allow access to logout endpoint
.logout(LogoutConfigurer::permitAll)
// in case of 403 / Forbidden authorization failure, use a custom handler
.exceptionHandling(exception ->
exception.accessDeniedHandler(accessDeniedHandler()));
return http.build();
}
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
UserDetailsService userDetailsService() {
return new CustomUserDetailsService();
}
@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
authenticationProvider.setUserDetailsService(userDetailsService());
authenticationProvider.setPasswordEncoder(passwordEncoder());
return authenticationProvider;
}
@Bean
public AccessDeniedHandler accessDeniedHandler() {
return (request, response, accessDeniedException) -> {
request.setAttribute("error", "You do not have permission to access this page.");
RequestDispatcher dispatcher = request.getRequestDispatcher("/");
dispatcher.forward(request, response);
};
}
}
The SecurityFilterChain is perhaps the most significant section of this configuration class, as this is where we are establishing the rules, the who can access what.
It is worth noting that these rules, in this example project, are slightly more complex than they typically need to be, for example.
.requestMatchers(mvc.pattern(“/user/**”)).hasRole(“ADMIN”)
We need to use mvc.pattern here because of a known conflict between a console servlet within H2 and Spring Security 6. Otherwise, we’d just use the “/user/**” String.
Controllers and Services
One additional new class we’re introducing is the UserDTO (Or User Data Transfer Object unabbreviated). If you recall when we wrote our Customer controller, for any of the endpoint methods that performed updates, such as update, or create, we declared a Customer customerInstance parameter which Spring would bind automatically for us with the request parameters.
For the User endpoints, I’m introducing this practice because it aligns with best practices and is the approach typically used, rather than declaring Entity classes in your parameters. It also means we can use different validation as to what might be occurring in the Entity, and with only a subset of the fields declared. So under a ‘dto’ package, create a new class UserDTO as follows:
@Data
public class UserDTO {
Long id;
@NotBlank
String role;
@NotBlank
String username;
@NotBlank
String fullName;
String password;
Boolean enabled = false;
}
Now we can look at our services, we’ll start with the UserService, which is going to be responsible for filtering data depending on a query string. We will also have the persistence methods, to create a new user and update a user. In addition to the persistence, the create method takes the user-provided plain text password and encrypts it before persistence.
Under a new package ‘services’, create UserService and enter the following:
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public Page<User> getUsersForDatatable(String queryString, Pageable pageable) {
UserDatatableFilter userDatatableFilter = new UserDatatableFilter(queryString);
return userRepository.findAll(userDatatableFilter, pageable);
}
public User createNewUser(UserDTO userDTO) {
User user = User.builder()
.fullName(userDTO.getFullName())
.role(userDTO.getRole())
.username(userDTO.getUsername())
.password(passwordEncoder.encode(userDTO.getPassword()))
.enabled(true)
.build();
return userRepository.save(user);
}
public User updateUser(UserDTO userDTO) {
User user = userRepository.findById(userDTO.getId()).orElseThrow();
user.setFullName(userDTO.getFullName());
user.setUsername(userDTO.getUsername());
user.setEnabled(userDTO.getEnabled());
user.setRole(userDTO.getRole());
return userRepository.save(user);
}
}
Next, we can look at the User Controller, there is very little difference between the new UserWebController and our existing CustomerWebControl. One might say there is a good opportunity to refactor much of this code and prevent code duplication, but that is a topic for another tutorial.
Under the ‘controllers’ package, create a new class ‘UserWebController’, and provide the following code:
@Controller
@RequiredArgsConstructor
@RequestMapping("/user")
public class UserWebController {
private final UserRepository userRepository;
private final UserService userService;
private final ObjectMapper objectMapper;
@GetMapping
public String index() {
return "/user/index.html";
}
@GetMapping(value = "/data_for_datatable", produces = "application/json")
@ResponseBody
public String getDataForDatatable(@RequestParam Map<String, Object> params) {
int draw = params.containsKey("draw") ? Integer.parseInt(params.get("draw").toString()) : 1;
int length = params.containsKey("length") ? Integer.parseInt(params.get("length").toString()) : 30;
int start = params.containsKey("start") ? Integer.parseInt(params.get("start").toString()) : 30;
int currentPage = start / length;
String sortName = "id";
String dataTableOrderColumnIdx = params.get("order[0][column]").toString();
String dataTableOrderColumnName = "columns[" + dataTableOrderColumnIdx + "][data]";
if (params.containsKey(dataTableOrderColumnName))
sortName = params.get(dataTableOrderColumnName).toString();
String sortDir = params.containsKey("order[0][dir]") ? params.get("order[0][dir]").toString() : "asc";
Sort.Order sortOrder = new Sort.Order((sortDir.equals("desc") ? Sort.Direction.DESC : Sort.Direction.ASC), sortName);
Sort sort = Sort.by(sortOrder);
Pageable pageRequest = PageRequest.of(currentPage,
length,
sort);
String queryString = (String) (params.get("search[value]"));
Page<User> users = userService.getUsersForDatatable(queryString, pageRequest);
long totalRecords = users.getTotalElements();
List<Map<String, Object>> cells = new ArrayList<>();
users.forEach(user -> {
Map<String, Object> cellData = new HashMap<>();
cellData.put("id", user.getId());
cellData.put("username", user.getUsername());
cellData.put("fullName", user.getFullName());
cellData.put("enabled", user.getEnabled());
cellData.put("dateCreated", user.getDateCreated());
cells.add(cellData);
});
Map<String, Object> jsonMap = new HashMap<>();
jsonMap.put("draw", draw);
jsonMap.put("recordsTotal", totalRecords);
jsonMap.put("recordsFiltered", totalRecords);
jsonMap.put("data", cells);
String json = null;
try {
json = objectMapper.writeValueAsString(jsonMap);
} catch (JsonProcessingException e) {
e.printStackTrace();
}
return json;
}
@GetMapping("/edit/{id}")
public String edit(@PathVariable String id, Model model) {
User userInstance = userRepository.findById(Long.valueOf(id)).get();
model.addAttribute("userInstance", userInstance);
return "/user/edit.html";
}
@PostMapping("/update")
public String update(@Valid @ModelAttribute("userInstance") UserDTO userDTO,
BindingResult bindingResult,
RedirectAttributes atts) {
if (bindingResult.hasErrors()) {
return "/user/edit.html";
} else {
if (userService.updateUser(userDTO) != null)
atts.addFlashAttribute("message", "User updated successfully");
else
atts.addFlashAttribute("message", "User update failed.");
return "redirect:/user";
}
}
@GetMapping("/create")
public String create(Model model) {
model.addAttribute("userInstance", new User());
return "/user/create.html";
}
@PostMapping("/save")
public String save(@Valid @ModelAttribute("userInstance") UserDTO userDTO,
BindingResult bindingResult,
RedirectAttributes atts) {
if (bindingResult.hasErrors()) {
return "/user/create.html";
} else {
if (userService.createNewUser(userDTO) != null)
atts.addFlashAttribute("message", "User created successfully");
else
atts.addFlashAttribute("message", "User creation failed.");
return "redirect:/user";
}
}
@PostMapping("/delete")
public String delete(@RequestParam Long id, RedirectAttributes atts) {
User userInstance = userRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("User Not Found:" + id));
userRepository.delete(userInstance);
atts.addFlashAttribute("message", "User deleted.");
return "redirect:/user";
}
}
The View Layer and Thymeleaf Templates
There are a number of new views we need to introduce. We’re also going to refactor existing views, so we can extract the common nav header and place this in it’s own layout for example. For this tutorial, rather than me simply listing all the view content, I think it is more practical that you refer to my Github repository for this tutorial, as a reminder, you can find the templates here.
Some points of interest are as follows:
Under templates/layouts/navbar.html we’ve extracted the Navbar from each of the views and instead call this Thymeleaf layout.
<nav class="navbar navbar-dark bg-dark fixed-top justify-content-between px-4"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<a class="navbar-brand" href="/">Simple CRM</a>
<a class="mr-auto btn btn-sm btn-outline-light" href="/logout" sec:authorize="isAuthenticated()">Logout</a>
</nav>
What’s of significance here is the use of the ‘sec’ namespace. This allows us to add conditional markup so that we only display the Logout link when the current request/user is authenticated.
Another interesting use of conditional markup is found in the customer list (/customer/index.html). Since we need to check if the user has sufficient rights to edit customer records, or whether they are the read-only user, in which case we should allow them only to view customer records.
var isReadOnly = /*[[${#authorization.expression('hasAuthority(''ROLE_READONLY_USER'')')}]]*/ false;
var customerUrlPrefix = '/customer/' + (isReadOnly ? 'show' : 'edit') + '/';
function renderLink(data, type, row, meta) {
return '<a href="' + customerUrlPrefix + row.id + '">' + data + '</a>';
}
Our dashboard has been revised significantly, you can now see we are making use of the layouts, such as the navbar and common footer, but also again, you can see the use of the ‘sec’ namespace, where we only show a link to the Users administration page if the User has the role ‘ROLE_ADMIN’:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="en" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
<title>Simple CRM - Customer Management made Simple</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.2/css/bootstrap.min.css">
</head>
<body>
<nav th:replace="~{/layout/navbar}"/>
<div class="container" style="margin-top:80px">
<div>
<h1>Welcome to simple crm</h1>
<h2>Customer Management made Simple</h2>
</div>
<div class="alert alert-danger" th:if="${error}" th:text="${error}"></div>
<p class="mt-5"><a href="/customer" class="btn btn-primary w-100">Manage Customers</a></p>
<p class="mt-5" sec:authorize="hasRole('ROLE_ADMIN')">
<a href="/user" class="btn btn-primary w-100">Manage Users</a>
</p>
</div>
<footer th:replace="~{/layout/footer}"/>
</body>
Conclusion
I hope this article has helped you and shown you how you can quickly introduce Spring Security into your own applications. Do not hesitate to contact us if you are having problems with this tutorial, or if you need assistance with any Java and Spring Boot Development.








