Kunal Nk
Kunal Nk

Reputation: 129

How to Customise Error Handling in JWT Authentication with Spring Security 6?

I have implemented a simple resource server with Spring Boot using the following dependency.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
    <version>2.4.12</version>
</dependency>

Most of my requirements are satisfied with the built-in, default workflow. However, it would be nice if I could customise the error handling part.

Currently, when I make an unauthorised request, I just get a blank response with a 401 status code.

enter image description here

Instead of an empty response with just a 401 code, I want to send custom error messages like "Invalid access token", "Access token missing", "Access token expired", etc. It would help a lot while debugging.

I have tried implementing the interfaces AuthenticationEntryPoint and AuthenticationFailureHandler. But, I haven't been successful.

I have referred the following article from the Spring Security docs:

https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html

Also, this is my Web Security Configuration:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                .authorizeHttpRequests((authz) -> authz
                        .requestMatchers("/public/**").permitAll()
                        .anyRequest().authenticated()
                )
                .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt);
        return http.build();
    }

}

Can someone provide a way to customise error messages in the JWT Authentication workflow?

Upvotes: 3

Views: 2903

Answers (6)

ikhvjs
ikhvjs

Reputation: 5927

From Spring Security Doc. The error response is handled in th BearerTokenAuthenticationFilter and the BearerTokenAuthenticationEntryPoint is responsible to write the response.

You can write your own YourBearerTokenAuthenticationEntryPoint and add as below in your config.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                .authorizeHttpRequests((authz) -> authz
                        .requestMatchers("/public/**").permitAll()
                        .anyRequest().authenticated()
                )
                .oauth2ResourceServer(resourceServerConfigurer ->
                         resourceServerConfigurer.authenticationEntryPoint(new YourBearerTokenAuthenticationEntryPoint()))

        return http.build();
    }

}

PS: This will overwrite the default behavior of BearerTokenAuthenticationEntryPoint, like write the error message in www-authenticate header based on RFC6750. So, you may copy those behaviour from BearerTokenAuthenticationEntryPoint to your custom one.

Also, you may want to add your own AccessDeniedHandler as below

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                .authorizeHttpRequests((authz) -> authz
                        .requestMatchers("/public/**").permitAll()
                        .anyRequest().authenticated()
                )
                .oauth2ResourceServer(resourceServerConfigurer ->
                         resourceServerConfigurer
                .authenticationEntryPoint(new YourBearerTokenAuthenticationEntryPoint()))
                .accessDeniedHandler(new YourAccessDeniedHandler()).build();
    }

}

Upvotes: 3

Muratbek Bauyrzhan
Muratbek Bauyrzhan

Reputation: 11

I stumbled upon the same issue at one of my job tasks and this is a solution I came so far and in your case code might look like the following:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
            .csrf().disable()
            .authorizeHttpRequests((authz) -> authz
                    .requestMatchers("/public/**").permitAll()
                    .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt
                    .jwtAuthenticationConverter(new 
                        CustomAuthenticationConverter())
                    .withObjectPostProcessor(new ObjectPostProcessor<BearerTokenAuthenticationFilter>() {
                      @Override
                      public <O extends BearerTokenAuthenticationFilter> O postProcess(O filter) {
                        filter.setAuthenticationFailureHandler((request, response, exception) -> {
                            BearerTokenAuthenticationEntryPoint delegate = new BearerTokenAuthenticationEntryPoint();
                            authenticationErrorHandler(response, exception, HttpStatus.UNAUTHORIZED);
                            delegate.commence(request, response, exception);
                        });
                        return filter;
                      }
                    })
                 )
            )
            .exceptionHandling(exceptionHandler -> exceptionHandler
               .accessDeniedHandler((request, response, accessDeniedException) ->
                    authenticationErrorHandler(response, accessDeniedException, HttpStatus.FORBIDDEN))
               .authenticationEntryPoint((request, response, accessDeniedException) ->
                    authenticationErrorHandler(response, accessDeniedException, HttpStatus.UNAUTHORIZED))
            );
    return http.build();
}

private void authenticationErrorHandler(HttpServletResponse response, Exception exception, HttpStatus status) throws IOException {
    ApiErrorResponse error = new ApiErrorResponse(status.value());
    error.setError(status.name());
    error.setTimestamp(LocalDateTime.now());
    error.addMessage(exception.getMessage());
    error.setStackTrace(Arrays.toString(exception.getStackTrace()));

    log.error(error.toString());

    response.sendError(status.value(), error.getError());
}

withObjectPostProcessor will handle the token validity, it will thrown an error if your token is invalid or expired

The authenticationErrorHandler formats error message

2 separate cases when token is not present or the URL is fobidden is handled in exceptionHandler

In the response body, however the error message will still appear as "unauthorized" due to UNAUTHORIZED code you send for error, but there logging works in order to provide detailed information regarding error.

Upvotes: 1

Rustam
Rustam

Reputation: 158

I had the same issue and solved it allowing all to /error/**. In your case, you can try to change authorizeHttpRequests part as follows:

.authorizeHttpRequests((authz) -> authz
    .requestMatchers("/public/**").permitAll()
    .requestMatchers("/error/**").permitAll()
    .anyRequest().authenticated()
)

Upvotes: 0

ezer
ezer

Reputation: 1172

If you wish to customize only auth failure response you can do it using the following code

 @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
// your configuration 
                .exceptionHandling(exceptionHandling -> exceptionHandling
                        .accessDeniedHandler(new CustomAccessDeniedHandler())
                        .authenticationEntryPoint(new CustomAuthenticationEntryPoint()))

the above code adds custom exception handlers for authorization and authentication respectively

you should implement CustomAuthenticationEntryPoint (AuthenticationEntryPoint) and CustomAccessDeniedHandler (AccessDeniedHandler, see AccessDeniedHandlerImpl )

example for implementation of AuthenticationEntryPoint and return any message that you desire in case of auth failure

@Slf4j
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest httpServletRequest,
                         HttpServletResponse httpServletResponse,
                         AuthenticationException e) throws IOException, ServletException {
        log.info("Responding with unauthorized error. Message - {}", e.getMessage());
    enter code here
        httpServletResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED,
                e.getLocalizedMessage()); //or replace with response.getWriter() and return anything you desire
    }

One thing to keep in mind: it is considered a good practice to return a uniform response and error code for both authentication (401) and authorization (403) failures.

This will make attacker work harder since it would be harder to determine the cause of failure. server log prints should be more informative and specify the exact issue.

Upvotes: 3

Vikrant
Vikrant

Reputation: 417

I have implemented the similar functionality with AuthenticationEntryPoint SpringDoc. Hopefully this is what you are looking.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

private final RestAuthenticationEntryPoint restAuthEntryPoint;

public SecurityConfig (RestAuthenticationEntryPoint restAuthEntryPoint){
    this.restAuthEntryPoint = restAuthEntryPoint;
}
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                .authorizeHttpRequests((authz) -> authz
                        .requestMatchers("/public/**").permitAll()
                        .anyRequest().authenticated()
                )
                .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt).and()
        .authenticationEntryPoint(restAuthEntryPoint)
        .accessDeniedHandler(accessDeniedHandler()).and()
        .sessionManagement(sesionManagement -> sessionManagement.sessionCreationPolicy(sessionCreationPolicy.STATELESS));
        return http.build();
    }

}

@Component
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint{
    @Override 
    public void commerce(HttpServletRequest request, HttpServletRequestResponse response, AuthenticationException e){
        
        // here you can parse error message in return in customse way
        Error error = new Error();// Custom model class for you exception
        error.setErrors("Token expired", HttpStatus.FORBIDDEN.toString());

        response.setStatus(HttpServletRequestResponse.SC_FORBIDDEN);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);

        ServletOutputStream out = response.getOutputStream();
        new ObjectMapper().writeValue(out, error);
        out.flush();
    }
} 

 

Upvotes: 1

K.Nicholas
K.Nicholas

Reputation: 11551

Maybe not what you're looking for but the error response is in the headers.

See Bearer Token Failure.

enter image description here

Upvotes: 4

Related Questions