Post

Spring) Spring Security Exception Handling (AuthenticationEntryPoint, AccessDeniedHandler, unsuccessfulAuthentication)

Spring) Spring Security Exception Handling (AuthenticationEntryPoint, AccessDeniedHandler, unsuccessfulAuthentication)

This post was migrated from Tistory. You can find the original here.

Related posts

2023.07.13 - [Spring/Security] - Spring) Spring Security Authentication/Authorization (23-06-29)

Spring Security Exception Handling

Authentication/authorization flow

This is the authentication/authorization flow I’ve currently set up. The front end asked for error messages, so I identified the parts of the current flow that could handle that and dealt with them.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    ....
        http.addFilterBefore(jwtAuthorizationFilter(), JwtAuthenticationFilter.class);
        http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

        http.exceptionHandling((exceptionHandling) ->
                exceptionHandling
                        .authenticationEntryPoint(myAuthenticationEntryPoint())
                        .accessDeniedHandler(myAccessDeniedHandler())
        );
    ...
    	return http.build();
    }

The version is boot-start-security:3.1.2, and the filter and error handling setup looks like the above. Let’s go through the exception handling one piece at a time.

(a)

1
2
3
4
5
6
7
    switch (jwtUtil.validateToken(tokenValue)) {
        case "fail" -> {
            log.info("case fail");
            responseUtil.responseToExceptionResponseDto(response, HttpStatus.FORBIDDEN, "토큰 유효성 검증에 실패했습니다.");
        }
        ...
    }

If validation throws “fail”, the HttpServletResponse gets handled and processing ends there without calling filterChain.doFilter(request, response) to continue the filter chain. In other words, error handling here happens inside JwtAuthorizationFilter, not in Security itself.

(b)

1
2
3
4
5
6
7
8
9
10
@Slf4j(topic = "로그인 및 JWT 생성")
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
	...
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
        log.info(failed.getMessage());
        responseUtil.responseToExceptionResponseDto(response, HttpStatus.UNAUTHORIZED, failed.getMessage());
    }
    ...
}

For requests coming in through the login route, exceptions are handled by UsernamePasswordAuthenticationFilter’s unsuccessfulAuthentication. In this case it doesn’t propagate to AuthenticationEntryPoint — but if you don’t override unsuccessfulAuthentication, it’s handled by AuthenticationEntryPoint instead.

(c)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint {
    private final ResponseUtil responseUtil;

    @Autowired
    public MyAuthenticationEntryPoint(ResponseUtil responseUtil) {
        this.responseUtil = responseUtil;
    }

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        //Authentication failure, non-existent route, or otherwise a bad request
        responseUtil.responseToExceptionResponseDto(response, HttpStatus.FORBIDDEN, "잘못된 요청입니다.");
    }
}

You can think of AuthenticationEntryPoint as the final handler for requests that fail to pass .anyRequest().authenticated().

Cases where a request wouldn’t pass include:

  • When there’s an issue with setContext during authorization
  • When the route isn’t permitAll and the Context has no authentication info
  • When the route doesn’t exist or the request format is invalid

These are roughly the cases I’d expect.

(d)

1
2
3
4
5
6
7
8
9
10
11
12
13
public class MyAccessDeniedHandler implements AccessDeniedHandler {
    private final ResponseUtil responseUtil;

    @Autowired
    public MyAccessDeniedHandler(ResponseUtil responseUtil) {
        this.responseUtil = responseUtil;
    }

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        responseUtil.responseToExceptionResponseDto(response, HttpStatus.FORBIDDEN, "인가 실패");
    }
}

This handles the case where setContext is in a normal state but authorization is still denied — for example, a ROLE_USER trying to access a ROLE_ADMIN route.


The Problem

The problem with the current setup is (a). If a token exists, the authorization filter handles the error and stops there — regardless of whether the route is permitAll or requires authentication. Even if a token exists, I want permitAll routes and the login route to proceed independent of token validity.

The Fix

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
    public static final String[] permitAllRouteArray = {
            "/api/user/signup", "/api/user/cors", "/api/user/kakao/login", "/swagger-ui/**", "/webjars/**",
            "/v3/api-docs/**", "/swagger-resources/**", "/api/file/**"
    };
    
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    	....
        http.authorizeHttpRequests((authorizeHttpRequest) ->
                        authorizeHttpRequest
                                .requestMatchers(permitAllRouteArray).permitAll()
                                .anyRequest().authenticated()
        );
        ....
	}

It seems like Security should already have something holding the permitAll routes, but for now I declared a static array of permitAll routes in Security as shown above and configured requestMatchers against it.

1
2
3
4
5
6
7
8
9
10
11
12
@Slf4j(topic = "로그인 및 JWT 생성")
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    public static final String loginUrl = "/api/user/login";
    ...

    @Autowired
    public JwtAuthenticationFilter(JwtUtil jwtUtil, ResponseUtil responseUtil) {
    	...
        setFilterProcessesUrl(JwtAuthenticationFilter.loginUrl);
    }
    ...
}

I also declared the login route as a static variable.

1
2
3
4
5
6
7
8
9
10
11
12
13
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String tokenValue = jwtUtil.getTokenFromRequest(request);
        boolean loginPass = JwtAuthenticationFilter.loginUrl.equals(request.getServletPath());
        boolean permitPass = Arrays.stream(WebSecurityConfig.permitAllRouteArray)
                .anyMatch(str -> str.equals(request.getServletPath()));

        if (loginPass || permitPass || !StringUtils.hasText(tokenValue)) {
            filterChain.doFilter(request, response);
        } else if (StringUtils.hasText(tokenValue)) {
        ...
    	}
    }

In JwtAuthorizationFilter, which extends OncePerRequestFilter, the code above checks whether the request route (request.getServletPath) matches either the loginUrl or one of the routes in permitAllRouteArray, and if it does, JwtAuthorizationFilter skips validation and skips setting the Context.

This post is licensed under CC BY-NC 4.0 by the author.