본문 바로가기

Proj/Tripot

스프링 시큐리티에서 발생하는 예외 처리하기

1. 개요

해당 프로젝트에서는 ApiExceptionHandler 클래스를 가지고 예외를 처리한다. 해당 클래스에는 @RestControllerAdvice가 있는데 스프링 시큐리티 필터에서 발생하는 오류는 대상이 아니기 때문에 커스텀 예외를 응답으로 보내지 못했다. 이를 해결해보고자 한다.

2. 필터 추가

해당 프로젝트는 JWT 방식을 사용하여 인증 및 인가를 처리한다. 만약 유효하지 않은 JWT가 들어오게 된다면 이를 처리할 필요가 있다.

@Slf4j
@RequiredArgsConstructor
public class JwtValidExceptionHandlerFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;

    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain
    ) throws ServletException, IOException {

        // 헤더에서 access키에 담긴 토큰을 꺼냄
        String preAccessToken = request.getHeader("Authorization");

        // 토큰이 없거나 유효하지 않다면 다음 필터로 넘김
        if (preAccessToken == null || !preAccessToken.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        String accessToken = preAccessToken.split(" ")[1];





        try {
            // 토큰이 access인지 확인 (발급시 페이로드에 명시)
            String category = jwtUtil.getCategory(accessToken);

            if (!category.equals("access")) {
                setErrorResponse(response, StatusCode.NOT_ACCESS_TOKEN);
                return;
            }

            filterChain.doFilter(request, response);
        } catch (ExpiredJwtException e) {
            // 토큰 만료 여부 확인
            setErrorResponse(response, StatusCode.EXPIRED_ACCESS_TOKEN);
        } catch (JwtException | IllegalArgumentException e) {
            //유효하지 않은 토큰
            setErrorResponse(response, StatusCode.NOT_ACCESS_TOKEN);
        }






    }

    private void setErrorResponse(
            HttpServletResponse response,
            StatusCode statusCode
    ) {
        ObjectMapper objectMapper = new ObjectMapper();
        response.setStatus(statusCode.getHttpCode());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("UTF-8");
        CommonResponse errorResponse = CommonResponse.fail(statusCode);
        try {
            log.error("[{}] JWT fail, code: {}", Thread.currentThread().getStackTrace()[1].getMethodName(), statusCode);
            response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

해당 커스텀 응답은 스프링부트가 처리해주지 않으므로 직접 작성해야 한다. 현 프로젝트는 refresh 토큰을 사용하고, 이에 따라 발생할 수 있는 예외는 다음과 같다.

  • 만료된 JWT
  • access token이 아님
  • 유효하지 않은 토큰
        // 헤더에서 access키에 담긴 토큰을 꺼냄
        String preAccessToken = request.getHeader("Authorization");

        // 토큰이 없거나 유효하지 않다면 다음 필터로 넘김
        if (preAccessToken == null || !preAccessToken.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

우선 permitAll로 권한 설정이 되어있는데 토큰이 담기는 경우가 있으므로 위와 같이 토큰이 없는 경우 다음 필터로 넘긴다.

        try {
            // 토큰이 access인지 확인 (발급시 페이로드에 명시)
            String category = jwtUtil.getCategory(accessToken);

            if (!category.equals("access")) {
                setErrorResponse(response, StatusCode.NOT_ACCESS_TOKEN);
                return;
            }

            filterChain.doFilter(request, response);
        } catch (ExpiredJwtException e) {
            // 토큰 만료 여부 확인
            setErrorResponse(response, StatusCode.EXPIRED_ACCESS_TOKEN);
        } catch (JwtException | IllegalArgumentException e) {
            //유효하지 않은 토큰
            setErrorResponse(response, StatusCode.NOT_ACCESS_TOKEN);
        }

토큰에서 토큰 종류를 뽑아 액세스 토큰인지 확인하는데 이 과정에서 토큰이 만료되었거나 유효하지 않다면 해당하는 Exception이 발생할 수 있다. 따라서 이를 try-catch 문으로 잡아주고 각 상황에 맞는 response를 작성해준다.

    private void setErrorResponse(
            HttpServletResponse response,
            StatusCode statusCode
    ) {
        ObjectMapper objectMapper = new ObjectMapper();
        response.setStatus(statusCode.getHttpCode());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("UTF-8");
        CommonResponse errorResponse = CommonResponse.fail(statusCode);
        try {
            log.error("[{}] JWT fail, code: {}", Thread.currentThread().getStackTrace()[1].getMethodName(), statusCode);
            response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

response를 작성하는 코드이다. HttpServletResponse에서 json 형식으로 작성하도록 설정해주고, 실패 응답을 작성, 이를 objectMapper를 통해 문자열로 직렬화하여 작성해준다.

SecurityConfig.class

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
		http
			.addFilterBefore(new JwtValidExceptionHandlerFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class)
    }

그리고 해당 필터를 등록해주면 된다.

만료된 토큰을 작성하였을 때 직접 만든 커스텀 응답이 정상적으로 보내지는 것을 확인할 수 있었다.

3. 스프링 시큐리티에서 발생하는 예외 처리

다음은 authenticated()로 권한 설정이 되어있는 경로에 토큰을 보내지 않았을 경우 발생하는 오류이다.

403 오류가 발생하고 아무런 응답도 주어지지 않는데, 이의 발생경로를 확인해보기 위해 로깅 레벨을 debug로 보내고 같은 요청을 해보았다.

Http403ForbiddenEntryPoint에서 해당 응답을 보내고, 해당 클래스는 AuthenticationEntryPoint를 구현하는 것을 확인하였다. 이에 따라 해당 인터페이스의 구현체를 커스텀해보기로 하자.

@Component
@Slf4j
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        setErrorResponse(response, StatusCode.ACCESS_DENIED);
    }

    private void setErrorResponse(
            HttpServletResponse response,
            StatusCode statusCode
    ) {
        ObjectMapper objectMapper = new ObjectMapper();
        response.setStatus(statusCode.getHttpCode());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("UTF-8");
        CommonResponse errorResponse = CommonResponse.fail(statusCode);
        try {
            log.error("[{}] JWT fail, code: {}", Thread.currentThread().getStackTrace()[1].getMethodName(), statusCode);
            response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
        } catch (IOException e) {
            e.printStackTrace();
        }
    }


}

해당 예외를 처리하여 커스텀 응답을 작성하는 코드이다. 전반적인 로직은 위에서의 필터와 동일하다.


                        
SecurityConfig.class

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
		http
            //403 예외 처리
            .exceptionHandling((authenticationManager) -> authenticationManager
                    .authenticationEntryPoint(customAuthenticationEntryPoint))
    }

위에서 만든 AuthenticationEntryPoint를 등록해준다.

역시 설정한 대로 잘 들어오는 것을 확인할 수 있었다.