A Step-by-Step Exploration of Spring Security 6
Introduction:
I'd like to start by letting you know that the inspiration behind this blog post comes from Ali Bouali. I want to sincerely thank Ali Bouali for initiating the sharing of this fantastic content. Your contribution is greatly appreciated, Ali Bouali!
Spring Security 6 has introduced numerous changes. In this blog, we will dissect its architecture into smaller components, providing both theoretical insights and practical explanations.
JwtAuthFilter (JWT Authentication Filter):
When using Spring security in Spring Boot, the initial component that handles client requests is the Jwt Authentication Filter. Its primary function is to validate and verify all aspects of the JWT provided.
the first thing that the JWTAuthFilter do is to check if JWT exist or not.
in case it doesn't exist we will respond to the client 403 Unauthorized request, Missing JWT.
In case it exist It will extract the username from the JWT and making a call to UserDetailsService class to fetch this user by It's username and check if he is exist.
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
//In this service we locate everything related with the manipulation of the JWT like extractUsername ...
private final JwtService jwtService;
// This is a functional interface where we provide our implementation for defining a username.
// It allows us to inject our custom logic for loading user details based on a username.
private final UserDetailsService userDetailsService;
private final TokenRepository tokenRepository;
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
// Bypass authentication for paths related to authentication
if (request.getServletPath().contains("/api/v1/auth")) {
filterChain.doFilter(request, response);
return;
}
// Extract JWT from Authorization header
final String authHeader = request.getHeader("Authorization");
final String jwt;
final String userEmail;
// If Authorization header is missing or doesn't start with "Bearer "
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
// Bypass authentication
filterChain.doFilter(request, response);
return;
}
jwt = authHeader.substring(7); // Remove "Bearer " from the JWT
userEmail = jwtService.extractUsername(jwt);
// If user is not authenticated and JWT is valid
if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) {
// Load user from database using userDetailsService, It's available within spring security,
//so we need to defaine our custom config how to load user from database using Its username
// check below ApplicationConfig to see how to define It.
UserDetails userDetails = this.userDetailsService.loadUserByUsername(userEmail);
// Check if the token is valid and not expired or revoked
var isTokenValid = tokenRepository.findByToken(jwt)
.map(t -> !t.isExpired() && !t.isRevoked())
.orElse(false);
// If both JWT and token in the repository are valid, authenticate the user
if (jwtService.isTokenValid(jwt, userDetails) && isTokenValid) {
// Alright proceed to SecurityContextHolder
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authToken.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request)
);
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
// Continue the filter chain
filterChain.doFilter(request, response);
}
}
UserDetailsService:
UserDetailsService get called once the JwtAuthFilter find the token within the user request , he take username which extracted from JwtAuthFilter and check if this token exist in the database.
// UserDetailsService is an interface provided by Spring Security for loading user details.
public interface UserDetailsService {
// This method is responsible for loading user details based on the provided username.
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
// this how we define our implementation for this method
@Configuration
@RequiredArgsConstructor
public class ApplicationConfig {
private final UserRepository repository;
@Bean
public UserDetailsService userDetailsService() {
return username -> repository.findByEmail(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
}
//...
}
In JwtService we put everything related with extraction, generation ... of the JWT:
@Service
public class JwtService {
//Configuration properties injected for JWT settings
@Value("${application.security.jwt.secret-key}")
private String secretKey;
@Value("${application.security.jwt.expiration}")
private long jwtExpiration;
@Value("${application.security.jwt.refresh-token.expiration}")
private long refreshExpiration;
// Methods related to token extraction and manipulation
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
private Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
private Claims extractAllClaims(String token) {
return Jwts
.parserBuilder()
.setSigningKey(getSignInKey())
.build()
.parseClaimsJws(token)
.getBody();
}
// Methods related to token generation and validation
public String generateToken(UserDetails userDetails) {
return generateToken(new HashMap<>(), userDetails);
}
public String generateToken(
Map<String, Object> extraClaims,
UserDetails userDetails
) {
return buildToken(extraClaims, userDetails, jwtExpiration);
}
private String buildToken(
Map<String, Object> extraClaims,
UserDetails userDetails,
long expiration
) {
return Jwts
.builder()
.setClaims(extraClaims)
.setSubject(userDetails.getUsername())
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + expiration))
.signWith(getSignInKey(), SignatureAlgorithm.HS256)
.compact();
}
public boolean isTokenValid(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return (username.equals(userDetails.getUsername())) && !isTokenExpired(token);
}
private boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
// Helper method
private Key getSignInKey() {
// retrieve the signing key used for JWT
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
return Keys.hmacShaKeyFor(keyBytes);
}
}
- Now we have built the Filter, so now it's time to utilize it:
at the start up spring security will look for a bean of type SecurityFilterchain, It's the responsible for configuring all Http security for our application and It defines URL-based access controls, allowing certain paths without authentication, while specifying required roles or authorities for various HTTP request methods to specific endpoints.
Additionally, it configures stateless session management (once Per request filter which means each request should be authenticated we don't store It's session), integrates authentication providers and a custom JWT authentication filter, and defines logout handling, ensuring a secure and controlled access environment for the application's endpoints.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@EnableMethodSecurity
public class SecurityConfiguration {
// URLs that do not require authentication
private static final String[] WHITE_LIST_URL = {"/api/v1/auth/**",
"/v2/api-docs",
"/v3/api-docs",
"/v3/api-docs/**",
"/swagger-resources",
"/swagger-resources/**",
"/configuration/ui",
"/configuration/security",
"/swagger-ui/**",
"/webjars/**",
"/swagger-ui.html"};
// Injected dependencies
private final JwtAuthenticationFilter jwtAuthFilter;
private final AuthenticationProvider authenticationProvider;
private final LogoutHandler logoutHandler;
// Configuring the security filter chain
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// Disable CSRF protection
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(req ->
req.requestMatchers(WHITE_LIST_URL)
.permitAll() // Allow access to listed URLs without authentication
.requestMatchers("/api/v1/management/**").hasAnyRole(ADMIN.name(), MANAGER.name())
.requestMatchers(GET, "/api/v1/management/**").hasAnyAuthority(ADMIN_READ.name(), MANAGER_READ.name())
.requestMatchers(POST, "/api/v1/management/**").hasAnyAuthority(ADMIN_CREATE.name(), MANAGER_CREATE.name())
.requestMatchers(PUT, "/api/v1/management/**").hasAnyAuthority(ADMIN_UPDATE.name(), MANAGER_UPDATE.name())
.requestMatchers(DELETE, "/api/v1/management/**").hasAnyAuthority(ADMIN_DELETE.name(), MANAGER_DELETE.name())
.anyRequest()
.authenticated() // Require authentication for any other requests
)
// Configure session management to be stateless
.sessionManagement(session -> session.sessionCreationPolicy(STATELESS))
// Set authentication provider
.authenticationProvider(authenticationProvider)
// Add custom JWT authentication filter before UsernamePasswordAuthenticationFilter
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
// Configure logout
.logout(logout ->
logout.logoutUrl("/api/v1/auth/logout")
.addLogoutHandler(logoutHandler)
.logoutSuccessHandler((request, response, authentication) -> SecurityContextHolder.clearContext())
);
return http.build();
}
}
we still need to configure AuthenticationProvider:
@Configuration
@RequiredArgsConstructor
public class ApplicationConfig {
//....
// Configuration for authentication and password encoding
// Bean for providing AuthenticationProvider
@Bean
public AuthenticationProvider authenticationProvider() {
// Creating a DaoAuthenticationProvider instance
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
// Setting UserDetailsService for the authentication provider
authProvider.setUserDetailsService(userDetailsService());
// Setting password encoder for the authentication provider
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
// Bean for providing AuthenticationManager
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
// Retrieving AuthenticationManager from AuthenticationConfiguration
return config.getAuthenticationManager();
}
//providing PasswordEncoder
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
// I will come back sooner for logout
Now the configuration completed we need to make two APIs, one for authentication and other one for registration.
Let's build an singUp & signIn APIs
Controller
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
public class AuthenticationController {
private final AuthenticationService authService;
@PostMapping("/authenticate")
public ResponseEntity<AuthenticationResponse> authenticate(
@RequestBody AuthenticationRequest request
) {
return ResponseEntity.ok(authService.authenticate(request));
}
@PostMapping("/register")
public ResponseEntity<AuthenticationResponse> register(
@RequestBody RegisterRequest request
) {
return ResponseEntity.ok(authService.register(request));
}
}
- Service:
@Service
@RequiredArgsConstructor
public class AuthenticationService {
private final UserRepository userRepository;
private final TokenRepository tokenRepository;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
private final AuthenticationManager authenticationManager;
// register method
public AuthenticationResponse register(RegisterRequest request) throws DuplicateUsernameException {
var user = User.builder()
.firstname(request.getFirstname())
.lastname(request.getLastname())
.phone(request.getPhone())
.active(request.isActive())
///...
.build();
User savedUser;
try {
//sql can throw duplicate key exception
savedUser = userRepository.save(user);
} catch (DataIntegrityViolationException e) {
throw new DuplicateUsernameException(request.getPhone());
}
var jwtToken = jwtService.generateToken(user);
var refreshToken = jwtService.generateRefreshToken(user);
saveUserToken(savedUser, jwtToken);
return AuthenticationResponse.builder()
.accessToken(jwtToken)
.refreshToken(refreshToken)
.build();
}
// authentication method
public AuthenticationResponse authenticate(AuthenticationRequest request) {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getPhone(),
request.getPassword()
)
);
var user = userRepository.findByPhone(request.getPhone())
.orElseThrow();
var jwtToken = jwtService.generateToken(user);
var refreshToken = jwtService.generateRefreshToken(user);
revokeAllUserTokens(user);
saveUserToken(user, jwtToken);
return AuthenticationResponse.builder()
.accessToken(jwtToken)
.refreshToken(refreshToken)
.fullName(user.getFirstname() + " " + user.getLastname())
.build();
}
}
Code On github:
Subscribe to my newsletter
Read articles from Ismail Harik directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Ismail Harik
Ismail Harik
As a skilled computer science student and software developer, I have a strong passion for building innovative solutions and learning new technologies. With a solid foundation in Java programming, Spring Ecosystem, and Angular, I am well-equipped to design and develop robust and scalable applications that meet business requirements. I have hands-on experience in developing applications based on Microservices Architecture and utilizing technologies such as Spring Cloud, Docker, and Kubernetes.