Securing Spring boot applications with JWT: Part 2
A comprehensive guide to Authentication, Authorization and Role based access control.
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:
- Setting up spring boot application.
- Add dependencies for JWT.
- 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.
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
andRoleNotFoundException
both extend theException
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 typeMethodArgumentNotValidException
, which typically occurs when validation on response body annotated with@Valid
fails.UserAlreadyExistsExceptionHandler
: This method handles exceptions of typeUserAlreadyExistsException
, likely for scenarios where a user is trying to register but already exists in the system.RoleNotFoundExceptionHandler
: This method handles exceptions of typeRoleNotFoundException
, 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 aRoleRepository
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 theLoadRoles
method as an event listener. Specifically, it listens forContextRefreshedEvent
, 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 eachERole
, it checks if a role with that name already exists in the database. If not found, it creates a newRole
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 providedSignUpRequestDto
. It first checks if a user with the provided email or username already exists in the system. If so, it throws aUserAlreadyExistsException
. If the user does not exist, it creates a newUser
object using the information from theSignUpRequestDto
and saves it using theuserService
. Then, it returns a response entity indicating a successful sign-up.createUser(SignUpRequestDto signUpRequestDto)
: This method constructs aUser
object using the information provided in theSignUpRequestDto
. It encodes the password using thePasswordEncoder
and determines the user's roles using thedetermineRoles
method.determineRoles(Set<String> strRoles)
: This method determines the roles for the user based on the role names provided in thestrRoles
set. If no roles are provided, it defaults to assigning the "user" role. It uses theroleFactory
to obtain instances ofRole
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.
And We are done with Sign up.
Step 5. Sign in process.
Below flow chart explains how sign in is going to happen.
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 aSignInRequestDto
object containing the user's email and password.- Initially,
AuthenticationManager
to authenticate the user. It creates aUsernamePasswordAuthenticationToken
object with the provided email and password from thesignInRequestDto
. - If the authentication is successful, an
Authentication
object representing the authenticated user is returned. SecurityContextHolder.getContext().setAuthentication(authentication)
: This line sets the authenticated user'sAuthentication
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 theJwtUtils
class, passing the authenticatedAuthentication
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 theAuthentication
object. It assumes that theUserDetails
implementation used isUserDetailsImpl
.List<String> roles = userDetails.getAuthorities().stream()...
: This line extracts the roles of the authenticated user from theUserDetails
object. It maps the authorities (roles) to their string representations.SignInResponseDto signInResponseDto = SignInResponseDto.builder()...
: This line constructs aSignInResponseDto
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 theSignInResponseDto
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.
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 theROLE_USER
role are authorized to access this endpoint. - AdminDashboard(): Accessed via GET request at
/api/test/admin
. Only users with theROLE_ADMIN
role are authorized to access this endpoint. - SuperAdminDashboard(): Accessed via GET request at
/api/test/superAdmin
. Only users with theROLE_SUPER_ADMIN
role are authorized to access this endpoint. - AdminOrSuperAdminContent(): Accessed via GET request at
/api/test/AdminOrSuperAdmin
. Users with either theROLE_SUPER_ADMIN
orROLE_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.
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-