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

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

작성한 질문수

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

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

24.03.20 18:30 작성

·

671

·

수정됨

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

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

2024. 03. 20. 18:49

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

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

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

그래도 함 살펴보자면..

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

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

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

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

 

혹시 그게 아니라면

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

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

2024. 03. 20. 19:09

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

 

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

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

0

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

2024. 03. 21. 10:46

저만 겪는 경우가 아니라 상황 공유합니다.
해결책은 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)로직이 동작합니다.

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

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

2024. 03. 21. 11:36

더불어 MethodSecurity가 아닌

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

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

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

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

2024. 03. 23. 00:21

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

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

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;
}

 

멋지십니다!

감사합니다.