Implementing Authorization with Spring Security and JWT
Topics to be discussed¶
- Connecting Spring Authorization Server to a User Database
- Adding Custom Claims to JWT
- Securing ProductService Endpoints
Connecting Spring Authorization Server to a User Database¶
In production environments, storing user information in a database is critical. We will replace the default in-memory user setup provided by Spring with a fully functional database-driven solution.
Initial Setup: In-Memory User¶
By default, Spring provides a simple way to define user details in memory. The following example demonstrates how to create a user with hardcoded credentials.
@Bean
public UserDetailsService userDetailsService() {
UserDetails userDetails = User.builder()
.username("user")
.password("$2a$16$AcBmaZLe06Hx5QSL1PVmRev3W3Fuzy..A18THjaUM.AYEcEDoTORC")
.roles("USER")
.build();
return new InMemoryUserDetailsManager(userDetails);
}
Explanation:
- In this example, a user is created with a username, password (hashed), and role.
- The
InMemoryUserDetailsManager
manages this user, but it doesn't communicate with a real database, making it unsuitable for a production environment.
We will now switch to using a custom user service that interacts with a database.
Step-by-Step: Implementing a Custom UserDetailsService¶
-
Create a
CustomUserDetailsService
Class:- This class will implement the
UserDetailsService
interface provided by Spring Security. - The
UserDetailsService
interface defines a single method,loadUserByUsername
, which retrieves user details for authentication.
- This class will implement the
-
Define Custom Granted Authorities:
- User roles and permissions in Spring Security are referred to as granted authorities. These define what actions a user can perform.
- We will implement a
CustomGrantedAuthority
class to manage these roles.
CustomGrantedAuthority.java¶
package com.scaler.userservicemwfeve.security.models;
public class CustomGrantedAuthority implements GrantedAuthority {
private Role role;
public CustomGrantedAuthority(Role role) {
this.role = role;
}
@Override
public String getAuthority() {
return role.getName(); // This method returns the name of the role.
}
}
Explanation:
- This class implements the GrantedAuthority
interface.
- The getAuthority
method returns the role name (e.g., "USER" or "ADMIN") assigned to the user.
- Load User Details from the Database:
- In the
CustomUserDetailsService
class, implement theloadUserByUsername
method to fetch the user from the database based on their email (used as the username in this case).
- In the
CustomUserDetailsService.java¶
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<User> userOptional = userRepository.findByEmail(username);
if (userOptional.isEmpty()) {
throw new UsernameNotFoundException("User by email: " + username + " doesn't exist.");
}
CustomUserDetails userDetails = new CustomUserDetails(userOptional.get());
return userDetails;
}
Explanation:
- The
loadUserByUsername
method retrieves aUser
entity from the database usinguserRepository.findByEmail
. - If no user is found, a
UsernameNotFoundException
is thrown. - If the user is found, it is converted into
CustomUserDetails
, which Spring Security will use for authentication.
Handling Common Errors¶
While implementing the custom user service, you may encounter common issues, especially with how user roles are loaded and serialized.
Eager Fetching of Roles¶
- By default, roles in the database might be lazily loaded, meaning they are only fetched when explicitly needed. However, during authentication, Spring Security expects the roles to be available immediately, causing potential errors.
- To fix this, set the fetching strategy of roles to EAGER in your
User
entity.
@OneToMany(fetch = FetchType.EAGER)
Explanation:
- This ensures that roles are loaded immediately when the user is fetched from the database, preventing any lazy loading issues during authentication.
JSON Serialization Issues¶
- You might encounter serialization errors when using custom user details or authorities, as these classes may not be properly serialized into JSON.
- To resolve this, add the
@JsonDeserialize
annotation to your custom classes (CustomUserDetails
andCustomGrantedAuthority
).
CustomUserDetails.java¶
@JsonDeserialize
public class CustomUserDetails implements UserDetails {
// Define properties like username, password, and roles
// Implement methods such as getAuthorities(), getUsername(), etc.
}
CustomGrantedAuthority.java¶
@JsonDeserialize
public class CustomGrantedAuthority implements GrantedAuthority {
private String authority;
public CustomGrantedAuthority(Role role) {
this.authority = role.getName(); // Assign the role's name as the authority.
}
@Override
public String getAuthority() {
return authority; // Return the role name.
}
}
Explanation:
- Adding
@JsonDeserialize
ensures that these custom classes can be serialized and deserialized properly when transmitted as JSON, which is essential for handling requests and responses in a web service.
Adding Custom Claims to JWT¶
JWT (JSON Web Token) is a popular format for securely transmitting information between parties. It contains claims, which are pieces of information about the user. By default, JWT includes standard claims like username and expiration time. However, you can also add custom claims to store additional user information.
Custom Claims in JWT¶
-
Customizing JWT Tokens:
- To add custom claims, such as user roles or user IDs, we need to modify the JWT encoding process in the security configuration.
SecurityConfig.java¶
@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> jwtTokenCustomizer() {
return (context) -> {
if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) {
context.getClaims().claims((claims) -> {
Set<String> roles = AuthorityUtils.authorityListToSet(context.getPrincipal().getAuthorities())
.stream()
.map(c -> c.replaceFirst("^ROLE_", ""))
.collect(Collectors.toSet());
claims.put("roles", roles); // Add user roles to the JWT claims.
});
}
};
}
Explanation:
- This configuration customizes the JWT encoding process, specifically for access tokens.
-
It retrieves the user’s roles from their authorities and adds them to the JWT under the
roles
claim. -
Adding Custom User ID Claim:
- If you want to include the user's ID in the JWT, you can modify the code to add it as a custom claim.
claims.put("userId", ((CustomUserDetails) context.getPrincipal().getPrincipal()).getUserId());
Explanation:
- This line adds the user's unique ID (
userId
) to the JWT, allowing it to be used in client-side applications or other services.
Securing ProductService Endpoints¶
Once the Spring Authorization Server is set up and JWT tokens are issued, the next step is to secure the productService
endpoints. This ensures that only authenticated users can access certain routes, and that users with specific roles have additional permissions.
Step-by-Step: Securing ProductService¶
- Add OAuth2 Resource Server Dependency:
- To enable JWT-based authentication in the
productService
, you need to add thespring-boot-starter-oauth2-resource-server
dependency.
- To enable JWT-based authentication in the
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
- Create a Security Configuration for ProductService:
SpringSecurityConfig.java¶
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.anyRequest().authenticated() // All endpoints require authentication
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt()); // Enable JWT-based security
return http.build();
}
Explanation:
-
This configuration ensures that all endpoints in
productService
require authentication using JWT tokens issued by the authorization server. -
Specify JWT Issuer:
-
In the
application.properties
file, specify the JWT issuer to ensure the tokens are validated correctly.
spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:8181
-
Restrict Access to Specific Endpoints:
- You can control which roles can access specific routes. For example, you can restrict access to
/products
for only admin users.
- You can control which roles can access specific routes. For example, you can restrict access to
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize
-> authorize
.requestMatchers("/products/{id}").authenticated() // Requires authentication
.requestMatchers("/products").hasAuthority("SCOPE_ADMIN") // Only admin users can access
.anyRequest().permitAll() // Other routes are accessible by everyone
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt());
return http.build();
}
Explanation:
- This allows fine-grained control over which endpoints require authentication and which roles are allowed to access them.
Extracting Authorities Manually¶
In some cases, you may need to manually extract and customize user authorities (roles) from the JWT token. Spring Security provides tools for this.
Custom JWT Authority Extraction¶
- Implement a
JwtAuthenticationConverter
:
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
grantedAuthoritiesConverter.setAuthoritiesClaimName("roles"); // Specify the claim for roles
JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
return jwtAuthenticationConverter;
}
Explanation:
- This configuration ensures that the roles stored in the
roles
claim of the JWT are extracted and recognized by Spring Security. - You can use this converter in your security configuration to manually process the authorities for better control.