If you're working on a Spring Security (and especially an OAuth) implementation, definitely have a look at the Learn Spring Security course:
>> LEARN SPRING SECURITYMocking is an essential part of unit testing, and the Mockito library makes it easy to write clean and intuitive unit tests for your Java code.
Get started with mocking and improve your application tests using our Mockito guide:
Handling concurrency in an application can be a tricky process with many potential pitfalls. A solid grasp of the fundamentals will go a long way to help minimize these issues.
Get started with understanding multi-threaded applications with our Java Concurrency guide:
Spring 5 added support for reactive programming with the Spring WebFlux module, which has been improved upon ever since. Get started with the Reactor project basics and reactive programming in Spring Boot:
Since its introduction in Java 8, the Stream API has become a staple of Java development. The basic operations like iterating, filtering, mapping sequences of elements are deceptively simple to use.
But these can also be overused and fall into some common pitfalls.
To get a better understanding on how Streams work and how to combine them with other language features, check out our guide to Java Streams:
Get started with Spring and Spring Boot, through the Learn Spring course:
>> LEARN SPRINGExplore Spring Boot 3 and Spring 6 in-depth through building a full REST API with the framework:
Yes, Spring Security can be complex, from the more advanced functionality within the Core to the deep OAuth support in the framework.
I built the security material as two full courses - Core and OAuth, to get practical with these more complex scenarios. We explore when and how to use each feature and code through it on the backing project.
You can explore the course here:
Spring Data JPA is a great way to handle the complexity of JPA with the powerful simplicity of Spring Boot.
Get started with Spring Data JPA through the guided reference course:
Refactor Java code safely β and automatically β with OpenRewrite.
Refactoring big codebases by hand is slow, risky, and easy to put off. Thatβs where OpenRewrite comes in. The open-source framework for large-scale, automated code transformations helps teams modernize safely and consistently.
Each month, the creators and maintainers of OpenRewrite at Moderne run live, hands-on training sessions β one for newcomers and one for experienced users. Youβll see how recipes work, how to apply them across projects, and how to modernize code with confidence.
Join the next session, bring your questions, and learn how to automate the kind of work that usually eats your sprint time.
1. Overview
This tutorial continues the Registration with Spring Security series with a look at how to properly implement Roles and Privileges.
Further reading:
Intro to Spring Security Expressions
Introduction to Spring Method Security
Spring Security - Redirect to the Previous URL After Login
2. User, Role and Privilege
Letβs start with our entities. We have three main entities:
- The User
- The Role represents the high-level roles of the user in the system. Each role will have a set of low-level privileges.
- The Privilege represents a low-level, granular privilege/authority in the system.
Hereβs the user:
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
private String firstName;
private String lastName;
private String email;
private String password;
private boolean enabled;
private boolean tokenExpired;
@ManyToMany
@JoinTable(
name = "users_roles",
joinColumns = @JoinColumn(
name = "user_id", referencedColumnName = "id"),
inverseJoinColumns = @JoinColumn(
name = "role_id", referencedColumnName = "id"))
private Collection<Role> roles;
}
As we can see, the user contains the roles as well as a few additional details that are necessary for a proper registration mechanism.
Next, hereβs the role:
@Entity
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
private String name;
@ManyToMany(mappedBy = "roles")
private Collection<User> users;
@ManyToMany
@JoinTable(
name = "roles_privileges",
joinColumns = @JoinColumn(
name = "role_id", referencedColumnName = "id"),
inverseJoinColumns = @JoinColumn(
name = "privilege_id", referencedColumnName = "id"))
private Collection<Privilege> privileges;
}
Finally, letβs look at the privilege:
@Entity
public class Privilege {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
private String name;
@ManyToMany(mappedBy = "privileges")
private Collection<Role> roles;
}
As we can see, weβre considering both the User <-> Role as well as the Role <-> Privilege relationships to be many-to-many bidirectional.
3. Setup Privileges and Roles
Next, letβs focus on doing some early setup of the Privileges and Roles in the system.
Weβll tie this to the startup of the application, and weβll use an ApplicationListener on ContextRefreshedEvent to load our initial data on server start:
@Component
public class SetupDataLoader implements
ApplicationListener<ContextRefreshedEvent> {
boolean alreadySetup = false;
@Autowired
private UserRepository userRepository;
@Autowired
private RoleRepository roleRepository;
@Autowired
private PrivilegeRepository privilegeRepository;
@Autowired
private PasswordEncoder passwordEncoder;
@Override
@Transactional
public void onApplicationEvent(ContextRefreshedEvent event) {
if (alreadySetup)
return;
Privilege readPrivilege
= createPrivilegeIfNotFound("READ_PRIVILEGE");
Privilege writePrivilege
= createPrivilegeIfNotFound("WRITE_PRIVILEGE");
List<Privilege> adminPrivileges = Arrays.asList(
readPrivilege, writePrivilege);
createRoleIfNotFound("ROLE_ADMIN", adminPrivileges);
createRoleIfNotFound("ROLE_USER", Arrays.asList(readPrivilege));
Role adminRole = roleRepository.findByName("ROLE_ADMIN");
User user = new User();
user.setFirstName("Test");
user.setLastName("Test");
user.setPassword(passwordEncoder.encode("test"));
user.setEmail("[email protected]");
user.setRoles(Arrays.asList(adminRole));
user.setEnabled(true);
userRepository.save(user);
alreadySetup = true;
}
@Transactional
Privilege createPrivilegeIfNotFound(String name) {
Privilege privilege = privilegeRepository.findByName(name);
if (privilege == null) {
privilege = new Privilege(name);
privilegeRepository.save(privilege);
}
return privilege;
}
@Transactional
Role createRoleIfNotFound(
String name, Collection<Privilege> privileges) {
Role role = roleRepository.findByName(name);
if (role == null) {
role = new Role(name);
role.setPrivileges(privileges);
roleRepository.save(role);
}
return role;
}
}
So, whatβs happening during this simple setup code? Nothing complicated:
- Weβre creating the privileges.
- Then weβre creating the roles and assigning the privileges to them.
- Finally, weβre creating a user and assigning a role to it.
Note how weβre using an alreadySetup flag to determine if the setup needs to run or not. This is simply because the ContextRefreshedEvent may be fired multiple times depending on how many contexts we have configured in our application. And we only want to run the setup once.
Two quick notes here. Weβll first look at terminology. Weβre using the Privilege β Role terms here. But in Spring, these are slightly different. In Spring, our Privilege is referred to as Role and also as a (granted) authority, which is slightly confusing.
This is not a problem for the implementation of course, but itβs definitely worth noting.
Second, these Spring Roles (our Privileges) need a prefix. By default, that prefix is βROLEβ, but it can be changed. Weβre not using that prefix here, just to keep things simple, but keep in mind that it will be required if weβre not explicitly changing it.
4. Custom UserDetailsService
Now letβs check out the authentication process.
Weβre going to see how to retrieve the user within our custom UserDetailsService and how to map the right set of authorities from the roles and privileges the user has assigned:
@Service("userDetailsService")
@Transactional
public class MyUserDetailsService implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Autowired
private IUserService service;
@Autowired
private MessageSource messages;
@Autowired
private RoleRepository roleRepository;
@Override
public UserDetails loadUserByUsername(String email)
throws UsernameNotFoundException {
User user = userRepository.findByEmail(email);
if (user == null) {
return new org.springframework.security.core.userdetails.User(
" ", " ", true, true, true, true,
getAuthorities(Arrays.asList(
roleRepository.findByName("ROLE_USER"))));
}
return new org.springframework.security.core.userdetails.User(
user.getEmail(), user.getPassword(), user.isEnabled(), true, true,
true, getAuthorities(user.getRoles()));
}
private Collection<? extends GrantedAuthority> getAuthorities(
Collection<Role> roles) {
return getGrantedAuthorities(getPrivileges(roles));
}
private List<String> getPrivileges(Collection<Role> roles) {
List<String> privileges = new ArrayList<>();
List<Privilege> collection = new ArrayList<>();
for (Role role : roles) {
privileges.add(role.getName());
collection.addAll(role.getPrivileges());
}
for (Privilege item : collection) {
privileges.add(item.getName());
}
return privileges;
}
private List<GrantedAuthority> getGrantedAuthorities(List<String> privileges) {
List<GrantedAuthority> authorities = new ArrayList<>();
for (String privilege : privileges) {
authorities.add(new SimpleGrantedAuthority(privilege));
}
return authorities;
}
}
The interesting thing to follow here is how the Privileges (and Roles) are mapped to GrantedAuthority entities.
This mapping makes the entire security configuration highly flexible and powerful. We can mix and match roles and privileges as granular as necessary, and at the end, theyβll be correctly mapped to authorities and returned back to the framework.
5. Role Hierarchy
Additionally, letβs organize our roles into hierarchies.
Weβve seen how to implement role-based access control by mapping privileges to roles. This allows us to assign a single role to a user rather than having to assign all the individual privileges.
However, as the number of roles increases, users might require multiple roles, leading to role explosion:
π role explosionTo overcome this, we can use Spring Securityβs role hierarchies:
π role-hAssigning the role ADMIN automatically gives the user the privileges of both the STAFF and USER roles.
However, a user with the role STAFF can only perform STAFF and USER role actions.
Letβs create this hierarchy in Spring Security by simply exposing a bean of type RoleHierarchy:
@Bean
public RoleHierarchy roleHierarchy() {
RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
String hierarchy = "ROLE_ADMIN > ROLE_STAFF \n ROLE_STAFF > ROLE_USER";
roleHierarchy.setHierarchy(hierarchy);
return roleHierarchy;
}
We use the > symbol in the expression to define the role hierarchy. Here, weβve configured the role ADMIN to include the role STAFF, which in turn includes the role USER.
To include this role hierarchy in Spring Web Expressions, we add the roleHierarchy instance to the WebSecurityExpressionHandler:
@Bean
public DefaultWebSecurityExpressionHandler customWebSecurityExpressionHandler() {
DefaultWebSecurityExpressionHandler expressionHandler = new DefaultWebSecurityExpressionHandler();
expressionHandler.setRoleHierarchy(roleHierarchy());
return expressionHandler;
}
Finally, add the expressionHandler into http.authorizeRequests():
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf()
.disable()
.authorizeRequests()
.expressionHandler(webSecurityExpressionHandler())
.antMatchers(HttpMethod.GET, "/roleHierarchy")
.hasRole("STAFF")
...
}
The endpoint /roleHierarchy is protected with ROLE_STAFF in order to prove that the webSecurityExpressionHandler is working.
As we can see, role hierarchies are a great way to reduce the number of roles and authorities we need to add to a user.
6. User Registration
Finally, letβs take a look at registration for a new user.
Weβve seen how setup goes about creating the User and assigning Roles (and Privileges) to it.
Letβs now take a look at how this needs to be done during registration of a new user:
@Override
public User registerNewUserAccount(UserDto accountDto) throws EmailExistsException {
if (emailExist(accountDto.getEmail())) {
throw new EmailExistsException
("There is an account with that email adress: " + accountDto.getEmail());
}
User user = new User();
user.setFirstName(accountDto.getFirstName());
user.setLastName(accountDto.getLastName());
user.setPassword(passwordEncoder.encode(accountDto.getPassword()));
user.setEmail(accountDto.getEmail());
user.setRoles(Arrays.asList(roleRepository.findByName("ROLE_USER")));
return repository.save(user);
}
In this simple implementation, since weβre assuming that a standard user is being registered, weβre assigning it the ROLE_USER role.
Of course, more complex logic can easily be implemented in the same way, either by having multiple, hard-coded registration methods or by allowing the client to send the type of user thatβs being registered.
7. Conclusion
In this article, we illustrated how to implement Roles and Privileges with JPA, for a Spring Security-backed system.
We also configured a role hierarchy to simplify our access control configuration.
