Securing Spring boot applications with JWT: Part 2

A comprehensive guide to Authentication, Authorization and Role based access control.

Dharshi Balasubramaniyam
10 min readApr 22, 2024

Welcome back to the second part of the article ‘Securing Spring Boot Applications with JWT.’ In the initial segment, we delved into various significant topics, encompassing:

  1. Setting up spring boot application.
  2. Add dependencies for JWT.
  3. Configuring Spring Security and JWT, covering aspects like UserDetails, UserDetailsService, JWTUtils, AuthenticationEntryPoint, and WebSecurityConfig.

If you did not check it out yet, read the 1st part of this topic using below link.

Securing Spring boot applications with JWT: Part 1

In this installment, we proceed from step 4, focusing on sign-up, sign-in, and access control.

So, without delay, let’s delve into these topics.

Step 4. Sign up process.

Below flow chart explains how sign up is going to happen.

Sign up - Flow chart.

Now let’s see how we can implement above flow chart programmatically in our application.

Step 4.1. Setting up exceptions and exception handlers

// UserAlreadyExistsException.java
public class UserAlreadyExistsException extends Exception{
public UserAlreadyExistsException(String message) {
super(message);
}
}

// RoleNotFoundException.java
public class RoleNotFoundException extends Exception{
public RoleNotFoundException(String message) {
super(message);
}
}
// GlobalExceptionHandler.java

@RestControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(value = MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponseDto<?>> MethodArgumentNotValidExceptionHandler(MethodArgumentNotValidException exception) {

List<String> errorMessage = new ArrayList<>();

exception.getBindingResult().getFieldErrors().forEach(error -> {
errorMessage.add(error.getDefaultMessage());
});
return ResponseEntity
.badRequest()
.body(
ApiResponseDto.builder()
.isSuccess(false)
.message("Registration Failed: Please provide valid data.")
.response(errorMessage)
.build()
);
}

@ExceptionHandler(value = UserAlreadyExistsException.class)
public ResponseEntity<ApiResponseDto<?>> UserAlreadyExistsExceptionHandler(UserAlreadyExistsException exception) {
return ResponseEntity
.status(HttpStatus.CONFLICT)
.body(
ApiResponseDto.builder()
.isSuccess(false)
.message(exception.getMessage())
.build()
);
}

@ExceptionHandler(value = RoleNotFoundException.class)
public ResponseEntity<ApiResponseDto<?>> RoleNotFoundExceptionHandler(RoleNotFoundException exception) {
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(
ApiResponseDto.builder()
.isSuccess(false)
.message(exception.getMessage())
.build()
);
}

}
  • UserAlreadyExistsException and RoleNotFoundException both extend the Exception class and have a constructor that accepts a message to be displayed when the exception is thrown.
  • GlobalExceptionHandler class is aimed at handling exceptions globally within your Spring application.
  • MethodArgumentNotValidExceptionHandler: This method handles exceptions of type MethodArgumentNotValidException, which typically occurs when validation on response body annotated with @Valid fails.
  • UserAlreadyExistsExceptionHandler: This method handles exceptions of type UserAlreadyExistsException, likely for scenarios where a user is trying to register but already exists in the system.
  • RoleNotFoundExceptionHandler: This method handles exceptions of type RoleNotFoundException, handle custom exception specific to role not found scenarios.

Step 4.2. Setting up a DTO for receive sign up request.

@Data
@AllArgsConstructor
@NoArgsConstructor
public class SignUpRequestDto {
@NotBlank(message = "Username is required!")
@Size(min= 3, message = "Username must have atleast 3 characters!")
@Size(max= 20, message = "Username can have have atmost 20 characters!")
private String userName;

@Email(message = "Email is not in valid format!")
@NotBlank(message = "Email is required!")
private String email;

@NotBlank(message = "Password is required!")
@Size(min = 8, message = "Password must have atleast 8 characters!")
@Size(max = 20, message = "Password can have have atmost 20 characters!")
private String password;

private Set<String> roles;

@Autowired
public SignUpRequestDto(String userName, String email, String password) {
this.userName = userName;
this.email = email;
this.password = password;
this.roles = null;
}
}

Step 4.3. Setting up a role factory to create role instances.

@Component
public class RoleFactory {
@Autowired
RoleRepository roleRepository;

public Role getInstance(String role) throws RoleNotFoundException {
switch (role) {
case "admin" -> {
return roleRepository.findByName(ERole.ROLE_ADMIN);
}
case "user" -> {
return roleRepository.findByName(ERole.ROLE_USER);
}
case "super_admin" -> {
return roleRepository.findByName(ERole.ROLE_SUPER_ADMIN);
}
default -> throw new RoleNotFoundException("No role found for " + role);
}
}

}
  • RoleFactory class acts as a centralized place for creating instances of roles based on role names, utilizing a RoleRepository to fetch role instances from the database. It provides a clean way to abstract the creation of role instances and handle exceptions if a requested role is not found.

Step 4.4. Insert roles in the table.

  • When we deploy a new application or set up a new database, you often need some initial data to work with. This could include default user roles, configuration settings, or other essential entities. A data seeder allows you to automatically populate the database with this initial data.
  • By automating the process of populating the database with initial data, you eliminate the need for manual input each time you deploy or set up your application.
  • Here also we are going to create such data seeder for inserting roles automatically, when they are not exists in the database.
@Component
public class RoleDataSeeder {
@Autowired
private RoleRepository roleRepository;

@EventListener
@Transactional
public void LoadRoles(ContextRefreshedEvent event) {

List<ERole> roles = Arrays.stream(ERole.values()).toList();

for(ERole erole: roles) {
if (roleRepository.findByName(erole)==null) {
roleRepository.save(new Role(erole));
}
}

}

}
  • RoleDataSeeder class designed to populate the database with roles if they don't already exist during application startup.
  • @EventListener annotation marks the LoadRoles method as an event listener. Specifically, it listens for ContextRefreshedEvent, which is triggered when the Spring application context is initialized or refreshed.
  • @Transactional annotation indicates that the method should be executed within a transaction. This ensures that either all database operations within the method complete successfully or none of them do.
  • Inside the LoadRoles method, For each ERole, it checks if a role with that name already exists in the database. If not found, it creates a new Role entity with that name and saves it to the database.

Step 4.5. Setting up Auth Service and implement business logic for sign up process.

  • AuthServiceImpl class appears to handle user authentication-related operations.
@Service
public interface AuthService {
ResponseEntity<ApiResponseDto<?>> signUpUser(SignUpRequestDto signUpRequestDto) throws UserAlreadyExistsException, RoleNotFoundException;
}
@Component
public class AuthServiceImpl implements AuthService {

@Autowired
private UserService userService;

@Autowired
private PasswordEncoder passwordEncoder;

@Autowired
private RoleFactory roleFactory;

@Override
public ResponseEntity<ApiResponseDto<?>> signUpUser(SignUpRequestDto signUpRequestDto)
throws UserAlreadyExistsException, RoleNotFoundException {
if (userService.existsByEmail(signUpRequestDto.getEmail())) {
throw new UserAlreadyExistsException("Registration Failed: Provided email already exists. Try sign in or provide another email.");
}
if (userService.existsByUsername(signUpRequestDto.getUserName())) {
throw new UserAlreadyExistsException("Registration Failed: Provided username already exists. Try sign in or provide another username.");
}

User user = createUser(signUpRequestDto);
userService.save(user);
return ResponseEntity.status(HttpStatus.CREATED).body(
ApiResponseDto.builder()
.isSuccess(true)
.message("User account has been successfully created!")
.build()
);
}

private User createUser(SignUpRequestDto signUpRequestDto) throws RoleNotFoundException {
return User.builder()
.email(signUpRequestDto.getEmail())
.username(signUpRequestDto.getUserName())
.password(passwordEncoder.encode(signUpRequestDto.getPassword()))
.enabled(true)
.roles(determineRoles(signUpRequestDto.getRoles()))
.build();
}

private Set<Role> determineRoles(Set<String> strRoles) throws RoleNotFoundException {
Set<Role> roles = new HashSet<>();

if (strRoles == null) {
roles.add(roleFactory.getInstance("user"));
} else {
for (String role : strRoles) {
roles.add(roleFactory.getInstance(role));
}
}
return roles;
}
}
  • signUpUser(SignUpRequestDto signUpRequestDto): This method is responsible for signing up a new user based on the provided SignUpRequestDto. It first checks if a user with the provided email or username already exists in the system. If so, it throws a UserAlreadyExistsException. If the user does not exist, it creates a new User object using the information from the SignUpRequestDto and saves it using the userService. Then, it returns a response entity indicating a successful sign-up.
  • createUser(SignUpRequestDto signUpRequestDto): This method constructs a User object using the information provided in the SignUpRequestDto. It encodes the password using the PasswordEncoder and determines the user's roles using the determineRoles method.
  • determineRoles(Set<String> strRoles): This method determines the roles for the user based on the role names provided in the strRoles set. If no roles are provided, it defaults to assigning the "user" role. It uses the roleFactory to obtain instances of Role based on the role names.

Step 4.6. Setting up controller

  • AuthController class serves as the entry point for authentication-related HTTP requests in our application.
@RestController
@CrossOrigin("*")
@RequestMapping("/api/auth")
public class AuthController {
@Autowired
private AuthService authService;

@PostMapping("/signup")
public ResponseEntity<ApiResponseDto<?>> registerUser(@RequestBody @Valid SignUpRequestDto signUpRequestDto)
throws UserAlreadyExistsException, RoleNotFoundException {
return authService.signUpUser(signUpRequestDto);
}

}

That’s it guys. Now let’s test our application using postman.

Test case 1: provide invalid email, username and password.
Test case 2: Provide valid email, username and password.
Test case 3: Try to sign up using already exists email or username.
Test case 4: Provide invalid role name.
Test case 5: Provide one role.
Test case 6: Provide multiple roles.

And We are done with Sign up.

Step 5. Sign in process.

Below flow chart explains how sign in is going to happen.

Sign in — Flow chart.

Step 5.1. Setting up a DTO for receive sign in request.

@Data
@AllArgsConstructor
public class SignInRequestDto {
@NotBlank(message = "Email is required!")
private String email;

@NotBlank(message = "Password is required!")
private String password;

}

Step 5.2. Update Auth Service and add Logic for Sign in process.

@Service
public interface AuthService {
// Sign up method here

ResponseEntity<ApiResponseDto<?>> signInUser(SignInRequestDto signInRequestDto);
}
@Component
public class AuthServiceImpl implements AuthService {

// Sign up stuff here

@Autowired
private AuthenticationManager authenticationManager;

@Autowired
private JwtUtils jwtUtils;

@Override
public ResponseEntity<ApiResponseDto<?>> signInUser(SignInRequestDto signInRequestDto) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(signInRequestDto.getEmail(), signInRequestDto.getPassword()));

SecurityContextHolder.getContext().setAuthentication(authentication);
String jwt = jwtUtils.generateJwtToken(authentication);

UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal();
List<String> roles = userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList());

SignInResponseDto signInResponseDto = SignInResponseDto.builder()
.username(userDetails.getUsername())
.email(userDetails.getEmail())
.id(userDetails.getId())
.token(jwt)
.type("Bearer")
.roles(roles)
.build();

return ResponseEntity.ok(
ApiResponseDto.builder()
.isSuccess(true)
.message("Sign in successfull!")
.response(signInResponseDto)
.build()
);
}

}

AuthServiceImpl class has expanded to include functionality for user sign-in.

  • signInUser(SignInRequestDto signInRequestDto): This method handles the sign-in process for users. It expects a SignInRequestDto object containing the user's email and password.
  • Initially, AuthenticationManager to authenticate the user. It creates a UsernamePasswordAuthenticationToken object with the provided email and password from the signInRequestDto.
  • If the authentication is successful, an Authentication object representing the authenticated user is returned.
  • SecurityContextHolder.getContext().setAuthentication(authentication): This line sets the authenticated user's Authentication object in the security context to manage the user's security details during the session.
  • String jwt = jwtUtils.generateJwtToken(authentication): This line generates a JSON Web Token (JWT) using the JwtUtils class, passing the authenticated Authentication object. This token will be used for subsequent authenticated requests.
  • UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal(): This line retrieves the details of the authenticated user from the Authentication object. It assumes that the UserDetails implementation used is UserDetailsImpl.
  • List<String> roles = userDetails.getAuthorities().stream()...: This line extracts the roles of the authenticated user from the UserDetails object. It maps the authorities (roles) to their string representations.
  • SignInResponseDto signInResponseDto = SignInResponseDto.builder()...: This line constructs a SignInResponseDto object containing details about the signed-in user, including their username, email, ID, JWT token, token type, and roles.
  • It constructs an ApiResponseDto object with a success message and the SignInResponseDto containing details about the signed-in user.

Step 5.3. Update AuthController.

@RestController
@CrossOrigin("*")
@RequestMapping("/api/auth")
public class AuthController {
@Autowired
private AuthService authService;

// Sign up method here

@PostMapping("/signin")
public ResponseEntity<ApiResponseDto<?>> signInUser(@RequestBody @Valid SignInRequestDto signInRequestDto){
return authService.signInUser(signInRequestDto);
}

}

That’s it. Now run and test whether we can Sign in using previously saved accounts.

Test case 1: Sign in using invalid credentials.
Test case 2: Sign in using valid credentials

And we have finished Sign in process also.

Step 6. Access Resource with JWT.

To test access control, Let’s modify our Test controller little bit as below.

Let’s update a Test controller to check above process.

@CrossOrigin(origins = "*")
@RestController
@RequestMapping("/api/test")
public class TestController {

// Only users with 'ROLE_USER' role can access this end point
@GetMapping("/user")
@PreAuthorize("hasRole('ROLE_USER')")
public ResponseEntity<ApiResponseDto<?>> UserDashboard() {
return ResponseEntity
.status(HttpStatus.OK)
.body(ApiResponseDto.builder()
.isSuccess(true)
.message("User dashboard!")
.build());
}

// Only users with 'ROLE_ADMIN' role can access this end point'
@GetMapping("/admin")
@PreAuthorize("hasRole('ROLE_ADMIN')")
public ResponseEntity<ApiResponseDto<?>> AdminDashboard() {
return ResponseEntity
.status(HttpStatus.OK)
.body(ApiResponseDto.builder()
.isSuccess(true)
.message("Admin dashboard!")
.build());
}

// Only users with 'ROLE_SUPER_ADMIN' role can access this end point'
@GetMapping("/superAdmin")
@PreAuthorize("hasRole('ROLE_SUPER_ADMIN')")
public ResponseEntity<ApiResponseDto<?>> SuperAdminDashboard() {
return ResponseEntity
.status(HttpStatus.OK)
.body(ApiResponseDto.builder()
.isSuccess(true)
.message("Super Admin dashboard!")
.build());
}

// Users with 'ROLE_SUPER_ADMIN' or 'ROLE_ADMIN' roles can access this end point'
@GetMapping("/AdminOrSuperAdmin")
@PreAuthorize("hasRole('ROLE_USER') or hasRole('ROLE_ADMIN')")
public ResponseEntity<ApiResponseDto<?>> AdminOrSuperAdminContent() {
return ResponseEntity
.status(HttpStatus.OK)
.body(ApiResponseDto.builder()
.isSuccess(true)
.message("Admin or Super Admin Content!")
.build());
}
}

TestController contains several endpoints for testing purposes. Each endpoint has role-based access control using Spring Security's @PreAuthorize annotation.

  • UserDashboard(): Accessed via GET request at /api/test/user. Only users with the ROLE_USER role are authorized to access this endpoint.
  • AdminDashboard(): Accessed via GET request at /api/test/admin. Only users with the ROLE_ADMIN role are authorized to access this endpoint.
  • SuperAdminDashboard(): Accessed via GET request at /api/test/superAdmin. Only users with the ROLE_SUPER_ADMIN role are authorized to access this endpoint.
  • AdminOrSuperAdminContent(): Accessed via GET request at /api/test/AdminOrSuperAdmin. Users with either the ROLE_SUPER_ADMIN or ROLE_ADMIN role are authorized to access this endpoint.

Now let’s test our application. I am using the JWT which was returned in previous sign in example.

Test case 1: Without JWT the request, responded with an unauthorized error
Test case 2: With correct JWT the request, responded correctly
Test case 3: When user tries to access admin content using their JWT, responded unauthorized error.

Check it out the GitHub repository here.

In this comprehensive guide of “Securing Spring Boot Applications with JWT”, we’ve expanded crucial processes security configuration, user sign-up, sign-in, and access control. By delving deeper into these areas, we’ve fortified our understanding of how JWT can be effectively implemented within a Spring Boot application. With each step, we’ve worked towards enhancing the security posture of our application, ensuring that user authentication and authorization mechanisms are robust and resilient.

In this comprehensive guide of “Securing Spring Boot Applications with JWT”, we’ve expanded crucial processes security configuration, user sign-up, sign-in, and access control. By delving deeper into these areas, we’ve fortified our understanding of how JWT can be effectively implemented within a Spring Boot application. With each step, we’ve worked towards enhancing the security posture of our application, ensuring that user authentication and authorization mechanisms are robust and resilient.

If you found my articles useful, please consider giving it claps and sharing it with your friends and colleagues.

Until we meet again in the second part of this article, keep learning, exploring, and creating amazing things with Java!

Happy coding!

-Dharshi Balasubramaniyam-

--

--

Dharshi Balasubramaniyam

BSc (hons) in Software Engineering, UG, University of Kelaniya, Sri Lanka. Looking for software engineer/full stack developer internship roles.