인프런 커뮤니티 질문&답변

철이와미애님의 프로필 이미지

작성한 질문수

호돌맨의 요절복통 개발쇼 (SpringBoot, Vue.JS, AWS)

ExceptionHandler가 AccessDeniedHandler(Http403Handler)를 먹어버리는 현상

작성

·

805

·

수정됨

0

안녕하세요 호돌맨님 강의 항상 잘보고있습니다. 다름이 아니라 실습 중 의도치 않게 동작하는 부분이 있어 질문드립니다.

 

상황

  1. 회원 가입 후 로그인

  2. 이 때 유저의 Role 은 ADMIN

  3. 메소드 시큐리티로 아래와 같이 자원의 권한 제한

@RestController
class HomeController {
    @GetMapping("/user")
    @PreAuthorize("hasRole('ROLE_USER')")
    fun user(): String {
        return "user 접근 가능👁"
    }

    @GetMapping("/admin")
    @PreAuthorize("hasRole('ROLE_ADMIN')")
    fun admin(): String {
        return "admin 접근 가능 👨‍💼"
    }
}

이 때

  • ExceptionHandler로 Runtime예외를 캐치해 응답을 주고 있습니다.

  • 동시에 커스텀한 403핸들러를 HttpSecurity에 끼워 넣어주었습니다.

// ControllerAdvice
    @ExceptionHandler(Exception::class)
    fun handleRuntimeException(ex: Exception): ResponseEntity<ErrorResult> {
        logger.error("ex", ex)
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(
                ErrorResult(
                    code = "500",
                    message = ex.message,
                )
            )
    }

// SecurityConfig
@Bean
    fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
        return http
            // .. other config..
            .exceptionHandling { e ->
                e.accessDeniedHandler(Http403Handler(objectMapper))
                e.authenticationEntryPoint(Http401Handler(objectMapper))
            }
            .build()
    }

기대하는 동작

ADMIN으로 로그인한 유저가 USER 자원에 접근하면 아래와 같이 응답

{
  "code": "403",
  "message": "접근할 수 없습니다.",
  "validation": null
}

실제 동작

{
  "code": "500",
  "message": "Access Denied",
  "validation": null
}

 

ControllerAdvice에서 Runtime예외를 처리하지 않는다면 의도대로 403이 응답되는데, ControllerAdvice에서 예외를 포괄적으로 처리하게 되면 403이 응답되지 않습니다.

 

혹시 이런경우를 겪으신적이 있는지? 따로 해결방법이 있을지 궁금해 여쭙습니다.

감사합니다.

답변 2

1

호돌맨님의 프로필 이미지
호돌맨
지식공유자

안녕하세요. 호돌맨입니다.

질문을 깔끔하게 남겨주셔서 감사합니다.

제가 지금 지하철이라 정확히 파악은 어려운데

그래도 함 살펴보자면..

AdviceController에서 Exception을 잡으시면 모든 예외를 처리하게 될텐뎅..

AccessDeniedException을 AdviceController에서 잡아버려 500이 발생한 게 아닐까용?

디버거로 찍어서 실행했을때

AdviceController에 handleRuntimeException 메서드에서 걸리는지 확인해보면 좋을것 같습니다.

 

혹시 그게 아니라면

AccessDenied Exception쪽 클래스 내용을 보여주시면 도움될것 같습니다.

지하철에서까지 빠른 답변 감사합니다.

 

저도 말씀하신 것처럼 예상이 됩니다. 그렇다면 ControllerAdvice에서 RuntimeExeption을 잡고자한다면 필연적으로 AccessDeniedExption은 AccessDeniedHandler 구현체가 아닌 (HttpSecurity에 끼워넣는 것이 아닌) ControllerAdvice에서 따로 잡아야 할까요?

호돌맨님이라면 어떻게 하실지가 궁금합니다!

0

저만 겪는 경우가 아니라 상황 공유합니다.
해결책은 ControllerAdvice에서 Access Denied Exception를 잡는 방법이라고 합니다.
https://stackoverflow.com/questions/72615257/spring-accessdeniedhandler-interface-dont-get-called-when-i-have-exceptionhandl
https://github.com/spring-projects/spring-security/issues/6908

디버깅을 해보니 아래와 같습니다.

  1. @MethodSecurity는 AOP를 이용하여 권한을 체크합니다.(Spring MVC계층까지 전파)

  2. 이 때 권한 에러가 발생한다면 @ControllerAdvice에서 에러를 캐치하여 응답을 내리기 때문에 SecurityFilterChain에서 AccessDeniedException이 발생함을 인지하지 못하여 그대로 Advice로직이 실행됩니다.

  3. 하지만 @ControllerAdvice를 비활성화한다면 MVC 계층에서 발생한 AccessDeniedException이 그대로 SecurityFilterChain까지 올라가 커스텀하게 구현한 AccessDeniedHandler(Http403Handler)로직이 동작합니다.

앞으로도 좋은 강의 기대하겠습니다. 감사합니다.

더불어 MethodSecurity가 아닌

auth.requestMatchers("/").permitAll()
    .requestMatchers("/user").hasRole("USER")
    .requestMatchers("/admin").hasRole("ADMIN")

형태로 직접 작성해주면 ControllerAdvice가 활성화 되어도

정상적으로 AccessDeniedHandler(Http403Handler)로직이 동작합니다.

호돌맨님의 프로필 이미지
호돌맨
지식공유자

다 찾아내시다니.. 정말 멋지시네요!

그러게요 이게 좀 애매합니다.

AccessDeniedException, AuthenticationException 등의 스프링 시큐리티 Exception들이 어떤 특정 Class를 상속받고 있다면 문제 해결이 쉬운데

모두 RuntimeException을 상속받고 있습니다.

그래서 현재는 아래와같이 분기문을 통해 status Code를 결정하는게 최선 같아보입니다.

아 이러면 코드가 안예쁜데..

    @ResponseBody
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> exception(Exception e) {
        log.error("예외발생", e);

        int errorCode = 500;
        if (e instanceof AccessDeniedException) {
            errorCode = 403;
        } else if (...) { }
        
        ErrorResponse body = ErrorResponse.builder()
                .code(String.valueOf(errorCode))
                .message(e.getMessage())
                .build();

        ResponseEntity<ErrorResponse> response = ResponseEntity.status(errorCode)
                .body(body);

        return response;
    }

 

아니면 아래와같이 하는 방법도 있을것 같습니다.

enum 형태의 SpringSecurityErrorCode.java 를 만들고요

@RequiredArgsConstructor
public enum SpringSecurityErrorCode {

    ACCESS_DENIED(AccessDeniedException.class, 403),
    UNAUTHORIZED(AuthenticationException.class, 401),
    // 기타등등 ...
    ETC(RuntimeException.class, 500);

    private final Class<? extends RuntimeException> exceptionClass;
    private final int statusCode;

    public static int getStatusCode(RuntimeException springException) {
        return Arrays.stream(values())
                .filter(e -> e.exceptionClass.isAssignableFrom(springException.getClass()))
                .findAny()
                .map(e -> e.statusCode)
                .orElse(500);
    }
}

아래처럼 예외에 맞는 status를 찾아 응답하는겁니다.

@ResponseBody
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ErrorResponse> exception(RuntimeException e) {
    log.error("예외발생", e);

    var statusCode = SpringSecurityErrorCode.getStatusCode(e);

    ErrorResponse body = ErrorResponse.builder()
            .code(String.valueOf(statusCode))
            .message(e.getMessage())
            .build();

    ResponseEntity<ErrorResponse> response = ResponseEntity.status(statusCode)
            .body(body);

    return response;
}

 

멋지십니다!

감사합니다.