Project/Boilerplate

필터 예외 처리

조용우 2025. 3. 17. 18:03

문제:

OAuth2 인증 인가 도입 후, 필터측에서 잡는 에러에 대해서 200Ok, OAuth2 기본 로그인 html이 response로 응답됨.

 

원인:

Spring Security는 Spring Context 바깥 쪽의 Filter에서 처리되기 때문에 Spring Context 내에서 예외 처리하는 @RestControllerAdivce로 예외 처리할 수 없음.

 

가장 마지막 필터인 AuthorizationFilter에서 인증이나 인가 예외가 터지면 이전 필터인 ExceptionTranslationFilter로 해당 예외들이 넘어가고, 이 필터에서 인증과 인가 오류에 대해 로그인 페이지 리디렉션을 보내기 때문에.

 

 

해결:

authenticationEntryPoint와 accessDeniedHandler를 커스텀함

 

@Component
@RequiredArgsConstructor
@Slf4j
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private final ObjectMapper objectMapper;

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
        AuthenticationException authException) throws IOException {

        log.error("CustomAuthenticationEntryPoint called");

        ErrorCode errorCode = AuthenticationError.UNAUTHORIZED;

        // OAuth2 인증 실패인 경우 별도 처리
        if (authException instanceof OAuth2AuthenticationException) {
            OAuth2Error oauth2Error = ((OAuth2AuthenticationException) authException).getError();
            ErrorResponse errorResponse = ErrorResponse.of(errorCode);
            errorResponse.addDetail("oauth2_error", oauth2Error.getErrorCode());
            errorResponse.addDetail("oauth2_description", oauth2Error.getDescription());
            sendErrorResponse(response, errorResponse);
            return;
        }

        // JWT 토큰 관련 예외 처리
        if (authException.getCause() instanceof JwtException) {
            errorCode = TokenError.INVALID_TOKEN;
        }

        ErrorResponse errorResponse = ErrorResponse.of(errorCode);
        sendErrorResponse(response, errorResponse);
    }

    private void sendErrorResponse(HttpServletResponse response, ErrorResponse errorResponse) throws IOException {
        response.setStatus(errorResponse.getStatus());
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
    }
}

 

@Component
@RequiredArgsConstructor
@Slf4j
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

    private final ObjectMapper objectMapper;

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
        AccessDeniedException accessDeniedException) throws IOException {
        log.error("CustomAccessDeniedHandler called");
        ErrorResponse errorResponse = ErrorResponse.of(AuthenticationError.ACCESS_DENIED);

        // 추가 정보 기록
        errorResponse.addDetail("requiredRole", "ROLE_REQUIRED");  // 필요한 권한 정보
        errorResponse.addDetail("path", request.getRequestURI());  // 요청 경로

        response.setStatus(errorResponse.getStatus());
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
    }
}

 

http
    .exceptionHandling(handling -> handling
        .authenticationEntryPoint(authenticationEntryPoint)  // 인증 실패
        .accessDeniedHandler(accessDeniedHandler)           // 인가 실패
    );

추가적으로,

GlobalExceptionHandler에

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    // BusinessException 하위 모든 예외 처리
    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e) {
        log.error("BusinessException: {}", e.getMessage());
        ErrorResponse response = ErrorResponse.of(e);
        return new ResponseEntity<>(response, e.getErrorCode().getStatus());
    }

    @ExceptionHandler(NoHandlerFoundException.class)
    public ResponseEntity<?> handleNoHandlerFoundException(NoHandlerFoundException ex) {
        ErrorResponse errorResponse = ErrorResponse.of(GlobalError.NOT_FOUND);
        log.error("Not Found = {}", errorResponse.getMessage());
        return new ResponseEntity<>(errorResponse, HttpStatus.NOT_FOUND);
    }

    @ExceptionHandler(AccessDeniedException.class)
    public ResponseEntity<?> handleAccessDeniedException(AccessDeniedException ex) {
        ErrorResponse errorResponse = ErrorResponse.of(AuthenticationError.ACCESS_DENIED);
        log.error("Access Denied = {}", errorResponse.getMessage());
        return new ResponseEntity<>(errorResponse, HttpStatus.FORBIDDEN);
    }

    // 처리되지 않은 모든 예외 처리
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(Exception e) {
        log.error("Unhandled Exception: ", e);
        ErrorResponse response = ErrorResponse.of(GlobalError.INTERNAL_SERVER_ERROR);
        return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR);
    }
}

404 Not Found, @PreAuthorize용 예외 처리 추가.

 

URL 기반 권한 체크 실패 → CustomAccessDeniedHandler
메소드 레벨 권한 체크 실패 (@PreAuthorize 등) → GlobalExceptionHandler
인증되지 않은 사용자 → AuthenticationEntryPoint