Lets understand the architecture behind Spring Authentication

Spring Authentication is quite complex to implement and understand compare to Laravel (PHP) for me. I wasted some times in tutorial on Youtube. I should have read the Spring Security document carefully instead of only rely on Youtube tutorials.

This blog will cover some main basic ideas and architectures in Spring Authentication and Authorization. First part is a full process of a Spring Authentication. In the second part, i will demonstrate Authentication using Token based Authentication (Jwt).

Spring Authentication

Basic flow of Spring Authentication

  1. User send a request with user email and password to get authentication

  2. The request goes to SecurityFilterChain and meet UsernamePasswordAuthenticationFilter

  3. UsernamePasswordAuthenticationFilter then output a UsernamePasswordAuthenticationToken, which is implemented from Authentication interface

  4. Then the token is passed to AuthenticationManager to be authenticated.

SecurityContextHolder

"This is the heart of Spring Security" by Spring official site. It holds the details of who is authenticated. We can use the SecurityContextHolder in order to retrieve the data of current log in user, for example to get the information of the authenticated user:

Spring Filter and Filter Chain

Each request that comes to a Spring server will first encounter a list of filters known as the Filter Chain:

filterchain

Servlet or Servlet Application is something like a request-reponse model, we will not talk about this in this blog.

What is inside a filter?

Inside of each filter will have its own responsibility like:

  1. Authentication authorization

    A filter for authenticating requests. For example is UsernamePasswordAuthenticationFilter

  2. Cache

    A filter to handle frequently requested response

  3. Data validation

    A filter to validate the JSON structure in the request data payloads.

From Spring document, inside a UsernamePasswordAuthenticationFilter have some functions like:

  • void setPostOnly(boolean postOnly): Defines whether only HTTP POST requests will be allowed by this filter.

  • void setUsernameParameter(String usernameParameter): Sets the parameter name which will be used to obtain the username from the login request.

After that, UsernamePasswordAuthenticationFilter will pass UsernamePasswordAuthenticationToken (this token can be seen as a Authentication Object)to ProviderManager to do authenticate.

ProviderManager and AuthenticationProvider

There are some authentications method like using Username and password (form-based authentication), Jwt authentication, OAuth 2.0 Authentication (google, facebook login,...). Each of them can be called as Provider. To decide which Provider should be used to authenticate for current request, Spring uses the ProviderManager. This is the most commonly used implementation of AuthenticationManager .

providermanager parent

From Servlet Authentication Architecture: AuthenticationManager is the API that defines how Spring Security’s Filters perform authentication.

How does Provider authenticate?

To authenticate from a username and password, Provider will use UserDetailsService to compare user information from the authentication request with user information in the database (username and password for default, this will be much easier to understand in the example section).

If the authentication is successful, user will be store to SecurityContextHolder for future use.

Example of Authentication using JWT

JWT Authentication Best Practices

Above is basic flow of jwt-based authentication, i will not write about this and assume that readers is already know the concept of json web token.

Spring Boot 3 with Spring Security 6 is used for the code

Some basic class that i will not go in detail:

SecurityConfig:


@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
    private final JwtAuthFilter jwtAuthFilter;
   // private final AuthenticationProvider authenticationProvider;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/auth/**").permitAll()
                        .requestMatchers("/api-docs/**").permitAll()
                        .requestMatchers("/swagger-ui/**").permitAll()
                        .requestMatchers("/product/**").permitAll()
                        .requestMatchers("/productItem/**").permitAll()

                        .anyRequest().authenticated())
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.
                .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);

    return http.build();
    }
}

User Entity:

@Getter
@Setter
@Entity
@Builder
@AllArgsConstructor
public class User  {
    @Id
    @Column(name = "id", nullable = false)
    @GeneratedValue(strategy=GenerationType.IDENTITY)
    private Integer id;

    @Column(name = "role", length = 10)
    @Enumerated(EnumType.STRING)
    private Role role;

    @Column(name = "email", length = 350)
    private String email;

    @Column(name = "password", length = 500)
    private String password;

    public User() {

    }
}

User Repository:

public interface UserRepository extends JpaRepository<User, Integer> {
   Optional<User> findByEmail(String email);
   boolean existsUserByEmail(String email);
}

First we will need a Controller to define the path to register and authenticate:

@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {

    public final AuthService authService;
    @PostMapping("/register")
    public ResponseEntity <AuthenResponse> register(  @RequestBody RegisterRequest registerRequest) {
        return authService.register(registerRequest);
    }


    @PostMapping("/login")
    public ResponseEntity <AuthenResponse> authenticate(  @RequestBody AuthRequest authRequest){
        return ResponseEntity.ok( authService.authenticate(authRequest));

    }
}

The Authentication Service:



@Service
@RequiredArgsConstructor
public class AuthService {


    private final UserRepository userRepository;
    private final JwtService jwtService;

    private final AuthenticationManager authenticationManager;
    private final CustomAuthenticationManager customAuthenticationManager;

    public ResponseEntity<AuthenResponse> register(RegisterRequest registerRequest) {

        if (userRepository.existsUserByEmail(registerRequest.getEmail())) {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(null);
        }

        PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();

        User user = User.builder().email(registerRequest.getEmail())
                .password(encoder.encode(registerRequest.getPassword()))
                .role(Role.user)
                .build();

        userRepository.save(user);
        CustomUserDetails userDetails = new CustomUserDetails(user);
        var jwtToken = jwtService.generateToken(userDetails);
        return ResponseEntity.ok(AuthenResponse.builder().token(jwtToken).build());
    }


    public AuthenResponse authenticate(AuthRequest authRequest)  {
        PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();

        Authentication 
 = customAuthenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                        authRequest.getEmail(),
                        authRequest.getPassword()
                )
        );

        SecurityContextHolder.getContext().setAuthentication(authentication);

        CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();

        var jwtToken = jwtService.generateToken(userDetails);
        return AuthenResponse.builder().token(jwtToken).build();
    }
}

We have two functions in this service:

  • register function:

    • CustomUserDetails is created to store some of the user's information.

    • JwtService to generate access token for successful register user.

    • AuthenResopnse object store the data to send to client.

  • authenticate function:

    • CustomAuthenticationManager to authenticate the user

    • If user is authenticated, save the user to SecurityContext

    • Finally return an AuthenResponse

AuthenResponse:

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class AuthenResponse {
    private String token;
}

AuthenRequest:

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class AuthRequest {
    private String email;
    private String password;
}

CustomUserDetails: this class implements the UserDetails of Spring Security. We can use User Entity to implements the UserDetails but separate to CustomUserDetails is more flexible and easier to maintain.


public class CustomUserDetails implements UserDetails {

    private final User user;


    public CustomUserDetails(User user) {
        this.user = user;
    }
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority(user.getRole().name()));
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getEmail();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

CustomUserDetailsService: this class implements UserDetailsService interface which has only 1 function loadUserByUsername(String username) to locate the user by username from the database. We need to custom this Service because we are using email-password login method, so we want to make this Service find for email instead of username. This CustomUserDetailsService will be used by some Spring Boot Security class.

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
    private final UserRepository userRepository;
    @Override
    public CustomUserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        User user = userRepository.findByEmail(email)
                .orElseThrow(() -> new UsernameNotFoundException("No user " +
                        "Found with email : " + email));
        return new CustomUserDetails(user);
    }
}

CustomAuthenticationManager: This class implements the AuthenticationManager so it has to implement the authenticate() method. This method will define how to authenticate the user (This class will handle by email and password authenticate, i will write about combine with google login in next post, also this customManager is likely to be an AuthenticationFilter)


@Service
@RequiredArgsConstructor
public class CustomAuthenticationManager implements AuthenticationManager {
    private final UserRepository userRepository;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();

        String email = authentication.getPrincipal() + "";
        String password = authentication.getCredentials() + "";

        User user = userRepository.findByEmail(email)
                .orElseThrow(() -> new UsernameNotFoundException("No user " +
                        "Found with email : " + email));

        CustomUserDetails userDetails = new CustomUserDetails(user);

        if (!encoder.matches(password, user.getPassword())) {
            throw new BadCredentialsException("1000");
        }
        if (!userDetails.isEnabled()) {
            throw new DisabledException("1001");
        }

        return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
    }
}

As i said earlier, when requests first come to the server, they have to go through a list of filters called Filter Chain. So in order to check the coming requests have the valid token or not, we will create a Jwt filter:

  • If the token is valid, save the user to SecurityContextHolder and move to the next request.

  • If the token is not valid, do nothing and move to the next request


@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
    private final JwtService jwtService;

    private final CustomUserDetailsService customUserDetailsService;


    @Override
    protected void doFilterInternal(
            @NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull FilterChain filterChain)
            throws ServletException, IOException {
        final String authorHeader = request.getHeader("Authorization");
        final String jwt;
        final String userEmail;

        if (authorHeader != null && authorHeader.startsWith("Bearer ")) {
            jwt = authorHeader.substring(7);
            userEmail = jwtService.extractEmail(jwt);

            if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) {

                CustomUserDetails userDetails = this.customUserDetailsService.loadUserByUsername(userEmail);
                if (jwtService.isTokenValid(jwt, userDetails)) {

                    UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
                            userDetails,
                            null,
                            userDetails.getAuthorities());
                    authToken.setDetails(
                            new WebAuthenticationDetailsSource().buildDetails(request)
                    );

                    SecurityContextHolder.getContext().setAuthentication(authToken);
                }

            }
            filterChain.doFilter(request, response);

        } else {
        filterChain.doFilter(request, response);
        }
    }
}

The JwtService class will do things relate to Jwt:

@Service
public class JwtService {

    private static final String SECRECT_KEY = "d80554814d31c4adefa08af13fe1afe5502532b0342d3ab12cd154f1716ee41d";
    public String extractEmail(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);
    }

    public String generateToken(UserDetails userDetails) {
        return generateToken(Map.of(), userDetails);
    }

    public String generateToken(Map<String, Object> extraClaims,
                                UserDetails userDetails) {

        return Jwts
                .builder()
                .setClaims(extraClaims)
                .setSubject(userDetails.getUsername())
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 24 * 24 * 24))
                .signWith(getSignInKey(), SignatureAlgorithm.HS256)
                .compact();
    }

    public Boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }

    private Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }

    public boolean isTokenValid(String token, UserDetails userDetails) {
        final String email = extractEmail(token);
        return (email.equals(userDetails.getUsername()) && !isTokenExpired(token));
    }

    private Claims extractAllClaims(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(getSignInKey())
                .build()
                .parseClaimsJws(token)
                .getBody();
    }

    private Key getSignInKey() {
        byte[] keyBytes = Decoders.BASE64.decode(SECRECT_KEY);
        return Keys.hmacShaKeyFor(keyBytes);
    }


}

Last one is ApplicationConfig to create some necessary Beans:

@Configuration
@RequiredArgsConstructor
public class ApplicationConfig {
    private final CustomUserDetailsService customUserDetailsService;
    private final UserRepository userRepository;


    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }

}

End of a so longgggg topic.

Thank you for reading.

References

Spring Security Document

Stack Overflow