javaauthenticationspring-securityauthorizationhttp-response-codes

How to prevent AuthenticationEntryPoint being invoked after AccessDeniedHandler?


My goal is to return 401 if the user provides invalid JWT and 403 if they don't have required roles to access endpoint (using @RolesAllowed).

Initially, Spring returned 403 for both. Then I added exceptionHandling configuration:

WebSecurityConfig.java:

...
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    return http
            .csrf(AbstractHttpConfigurer::disable)
            .formLogin(AbstractHttpConfigurer::disable)
            .rememberMe(AbstractHttpConfigurer::disable)
            .httpBasic(AbstractHttpConfigurer::disable)
            .logout(AbstractHttpConfigurer::disable)
            .cors(c -> c.configurationSource(corsConfigurationSource()))
            .authorizeHttpRequests(c -> c
                    .requestMatchers(HttpMethod.OPTIONS).permitAll()
                    .requestMatchers(UNPROTECTED_PATHS).permitAll()
                    .anyRequest().authenticated()
            )
            .exceptionHandling(c -> c
                    .authenticationEntryPoint((request, response, authException) -> response.sendError(
                            HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage() // this one
                    ))
                    .accessDeniedHandler((request, response, accessDeniedException) -> response.sendError(
                            HttpServletResponse.SC_FORBIDDEN, accessDeniedException.getMessage() // and this
                    ))
            )
            .sessionManagement(c -> c.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class)
            .build();
}
...

JwtRequestFilter.java:

...
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtRequestFilter extends OncePerRequestFilter {
    private static final String HEADER_AUTHORIZATION = "Authorization";
    private static final String AUTHORIZATION_PREFIX = "Bearer";

    private final JwtService jwtService;
    private final UserDao userDao;

    @Override
    protected void doFilterInternal(
            @NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull FilterChain filterChain
    ) throws ServletException, IOException {
        filterRequest(request);
        filterChain.doFilter(request, response);
    }

    private void filterRequest(HttpServletRequest request) {
        Authentication existAuthentication = SecurityContextHolder.getContext().getAuthentication();
        if (existAuthentication != null && existAuthentication.isAuthenticated()) {
            return;
        }

        String token = getToken(request);
        if (token == null) {
            return;
        }

        User user = jwtService.getUser(token).orElse(null);
        if (user == null) {
            return;
        }

        userDao.register(user);
        setAuthentication(request, user);
    }

    @Nullable
    private String getToken(HttpServletRequest request) {
        String authorizationHeader = request.getHeader(HEADER_AUTHORIZATION);
        if (authorizationHeader == null || !authorizationHeader.startsWith(AUTHORIZATION_PREFIX + " ")) {
            return null;
        }
        return authorizationHeader.substring(7);
    }

    private static void setAuthentication(HttpServletRequest request, UserDetails userDetails) {
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
                userDetails,
                null,
                userDetails.getAuthorities()
        );
        authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
    }
}

Now I always get 401. I've figured out that authenticationEntryPoint is being called after accessDeniedHandler when AccessDeniedException is thrown. My guess is that the user becomes anonymous if they fail authorization. On the other hand, when provide an invalid JWT (AuthenticationException is thrown) authenticationEntryPoint is called twice. This doesn't seem to me as a correct behaviour.

How to prevent this?

I'm using Spring Security v.6.2.3.

Currently, I'm using a workaround with a ControllerAdvice:

@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<?> handleException(AccessDeniedException e) {
    return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}

This way both authenticationEntryPoint and accessDeniedHandler are ignored.


Solution

  • Still don't know the reason behind the issue, but I managed to solve it using the following configuration.

    WebSecurityConfig.java:

    @EnableWebSecurity
    @EnableMethodSecurity(jsr250Enabled = true)
    @Configuration
    @RequiredArgsConstructor
    public class WebSecurityConfig {
    
        private final CorsConfigurationSource corsConfigurationSource;
        private final BearerTokenAuthenticationFilter bearerTokenAuthenticationFilter;
    
        @Bean
        public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
            return http
                    .csrf(AbstractHttpConfigurer::disable)
                    .formLogin(AbstractHttpConfigurer::disable)
                    .rememberMe(AbstractHttpConfigurer::disable)
                    .httpBasic(AbstractHttpConfigurer::disable)
                    .logout(AbstractHttpConfigurer::disable)
                    .sessionManagement(c -> c.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                    .cors(c -> c.configurationSource(corsConfigurationSource))
                    .authorizeHttpRequests(c -> c
                            .requestMatchers(AuthenticationFilterConfig.UNPROTECTED_URLS).permitAll()
                            .requestMatchers(HttpMethod.OPTIONS).permitAll()
                            .anyRequest().authenticated()
                    )
                    .addFilterBefore(bearerTokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
                    .build();
        }
    }
    

    AuthenticationFilterConfig.java

    @Configuration
    @RequiredArgsConstructor
    public class AuthenticationFilterConfig {
    
        public static final RequestMatcher UNPROTECTED_URLS = new OrRequestMatcher(
                Stream.of(
                                "/swagger-ui/**",
                                "/v3/api-docs/**",
                                "/actuator/health/**",
                                "/actuator/prometheus/**",
                                "/actuator/loggers/**",
                                "/ws/**"
                        ).map(AntPathRequestMatcher::new)
                        .toArray(AntPathRequestMatcher[]::new)
        );
    
        private final JwtService jwtService;
    
        @Bean
        public BearerTokenAuthenticationFilter bearerTokenAuthenticationFilter(
                BearerTokenResolver bearerTokenResolver,
                AuthenticationManager authenticationManager
        ) {
            var authenticationFilter = new BearerTokenAuthenticationFilter(authenticationManager) {
                @Override
                protected boolean shouldNotFilter(@NonNull HttpServletRequest request) {
                    return UNPROTECTED_URLS.matches(request);
                }
            };
            authenticationFilter.setBearerTokenResolver(bearerTokenResolver);
            return authenticationFilter;
        }
    
        @Bean
        public AuthenticationManager authenticationManager() {
            var detailsManager = inMemoryUserDetailsManager();
            return authentication -> {
                Object principal = authentication.getPrincipal();
                if (principal instanceof String token) {
                    User user = jwtService.getUser(token);
                    if (!detailsManager.userExists(user.getUsername())) {
                        detailsManager.createUser(user);
                    }
                    return new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
                }
                return null;
            };
        }
    
        @Bean
        public InMemoryUserDetailsManager inMemoryUserDetailsManager() {
            return new InMemoryUserDetailsManager();
        }
    
        @Bean
        public BearerTokenResolver bearerTokenResolver() {
            var defaultResolver = new DefaultBearerTokenResolver();
            return request -> {
                String token = defaultResolver.resolve(request);
                if (token == null) {
                    throw new MissingBearerTokenException("Bearer token is missing");
                }
                return token;
            };
        }
    }