본문 바로가기
  • 개발하는 곰돌이
Development/Spring & Spring Boot

[Spring Security] 스프링 시큐리티를 적용하고 예외가 발생했을 때 403 Forbidden이 발생하는 원인과 처리 방안

by 개발하는 곰돌이 2023. 8. 5.

목차

    들어가기 전에

    스프링부트 프로젝트에 스프링 시큐리티를 처음 적용해보면 여러 난관에 부딪히게 된다. 그 중 하나가 컨트롤러나 서비스 계층에서 발생한 예외의 종류에 상관없이 403 Forbidden 응답이 반환되는 것이다.

    스프링 시큐리티에서 별도의 설정을 하지 않으면 아무런 내용도 담기지 않은 403 에러가 발생한다.

    기본적으로 스프링에서는 따로 예외 처리를 하지 않았다면 예외 발생 시 500 에러가 발생한다. 그런데 스프링 시큐리티를 적용하면 메소드에서 예외가 발생했을 때 403 에러가 발생한다. 심지어 존재하지 않는 URL로 접속하여 404 Not Found가 발생해야 하는 상황에서도 403 Forbidden이 발생한다.

    존재하지 않는 URL인데 404가 아닌 403이 발생한다.

    그 이유와 해결 방법에 대해서 정리하려고 한다. 다만 아직 스프링 시큐리티의 동작 과정을 완전히 이해하진 못했기 때문에 틀린 내용이 있을 수도 있다.

    403 에러가 발생하는 원인

    우선 이 현상의 원인을 파악하기 위해선 스프링부트에서 에러가 발생했을 때 나타나는 Whitelabel Error Page가 어떻게 나타나는지 알아야한다.

    스프링부트에서 에러가 발생하면 기본적으로 등장하는 Whitelabel Error Page

    스프링 공식 블로그에 따르면 스프링부트에서는 에러가 발생하면 /error라는 URI로 매핑을 시도한다. 실제로 해당 URI로 이동하면 아래와 같은 페이지가 나타난다.

    따로 에러가 발생하여 자동으로 나타난 것이 아니라 강제로 이동했기 때문에 status가 999로 나타났지만 굉장히 익숙한 Whitelabel Error Page가 나타난다.

    WhiteLabel Error Page가 403이랑 무슨 연관이 있는데?

    그런데 이 페이지와 스프링 시큐리티를 설정한 상태에서 에러 발생 시 403 에러만 발생하는 것이 무슨 연관이 있는 것일까?

     

    Whitelabel Error Page 자체는 403 에러와 관련이 없지만 에러가 발생하면 /error로 매핑을 시도한다는 것이 핵심이다.

     

    일반적으로 스프링 시큐리티 설정을 할 땐 아래와 같은 형태로 설정하게 될 것이다.(JWT 기준)

    @Bean
    fun filterChain(http: HttpSecurity) = http
        .csrf().disable()
        .headers { it.frameOptions().sameOrigin() }
        .authorizeHttpRequests {
            it.requestMatchers(*allowedUris).permitAll()
                .anyRequest().authenticated()
        }
        .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
        .addFilterBefore(jwtAuthenticationFilter, BasicAuthenticationFilter::class.java)
        .build()!!

    일반적으로 permitAll()을 통해 모든 사용자의 접근을 허용할 URI에는 권한 검증이 필요하지 않은 URI만 추가한다. 그리고 위에서 언급했듯이 스프링부트 프로젝트에서 에러가 발생하면 /error로 매핑한다. 그런데 /error는 모두에게 허용된 URI에 포함되지 않는다.

     

    이때문에 에러 페이지에도 인증 절차가 요구되어 403 에러가 발생하는 것이다.

    403 에러가 발생하는 과정

    스프링 시큐리티의 예외 처리 과정은 다음과 같다.

    1. 예외가 발생하면 ExceptionTranslationFilter에서 요청 프로세스를 계속 진행한다.
    2. 사용자 인증되지 않았거나 AuthenticationException이 발생했다면 사용자 인증을 시작한다. 이 과정에서 기존의 SecurityContextHolder가 비워지고, AuthenticationEntryPoint는 클라이언트에게 자격 증명을 요청하기 위한 절차를 수행한다.
    3. 그 외에는 AccessDeniedHandler가 AccessDeniedException을 발생시킨다.

    이 과정을 자바 의사코드로 나타내면 다음과 같다.

    try {
        filterChain.doFilter(request, response);
    } catch (AccessDeniedException | AuthenticationException ex) {
        if (!authenticated || ex instanceof AuthenticationException) {
            startAuthentication();
        } else {
            accessDenied();
        }
    }

    startAuthentication()을 진행하는 과정에서 AuthenticationEntryPoint에 자격 증명 요청을 위임하게 되는데, 스프링 시큐리티는 별도로 설정하지 않으면 여기서 Http403ForbiddenEntryPoint를 사용한다.

    그리고 Http403ForbiddenEntryPoint를 살펴보면 아래와 같이 아무것도 하지 않고 단순히 403 에러를 반환하는 것을 볼 수 있다.

    그리고 그 외의 경우에는 어쨌든 AccessDeniedException을 발생시켜서 403 에러가 발생한다.

    어떻게 해결할 수 있을까?

    이 문제는 여러 방법으로 해결할 수 있다. 차근차근 살펴보자.

    permitAll() URI 목록에 /error 추가

    일단 이 문제는 anyRequest().authenticated()로 인해 /error도 인증이 필요한 것으로 간주되어 발생한 것이기 때문에 모두에게 허용할 URI 목록에 /error를 추가하면 단순하게 해결할 수 있다.

    private val allowedUris = arrayOf(..., "/error")
    
    @Bean
    fun filterChain(http: HttpSecurity) = http
        .csrf().disable()
        .headers { it.frameOptions().sameOrigin() }
        .authorizeHttpRequests {
            it.requestMatchers(*allowedUris).permitAll()
                .anyRequest().authenticated()
        }
        .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
        .addFilterBefore(jwtAuthenticationFilter, BasicAuthenticationFilter::class.java)
        .build()!!

    이렇게 하면 정상적으로 에러 코드가 담긴 Whitelabel Error Page나 스프링 기본 에러 응답 JSON을 받을 수 있다.

    정말 간단한 해결책이지만 이렇게 하면 적절한 응답 메시지를 전달할 수 없기 때문에 좋은 해결책은 아니다.

    적절한 예외 처리

    컨트롤러나 서비스 등의 메소드에서 발생하는 예외는 적절한 예외처리를 통해 아무 내용도 없는 403 에러가 반환되는 것을 막을 수 있다. 예를 들어 @ControllerAdvice@RestControllerAdvice를 통해 예외가 발생했을 때의 응답을 지정한다면 메소드에서 예외가 발생하더라도 정해놓은 응답을 반환하게 된다.

    @RestControllerAdvice
    class ExceptionHandler {
        ...
        
        @ExceptionHandler(Exception::class)
        fun handleUnexpectedException() = 
            ResponseEntity.internalServerError().body(ApiResponse.error("서버에 문제가 발생했습니다."))
        
        ...
    }

    이 방법은 컨트롤러 계층에서 발생하는 모든 예외에 대해 적절한 응답을 할 수는 있지만 404 에러처럼 컨트롤러를 거치지 않고 발생하는 에러 코드에는 대응할 수 없기 때문에 아래의 방법과 병행하는 것이 좋다.

    AuthenticationEntryPoint 구현체 등록

    위에서 이유를 불문하고 403 에러가 발생하는 원인은 스프링 시큐리티에 기본으로 등록된 AuthenticationEntryPoint인 Http403ForbiddenEntryPoint 때문이라고 했다. 그렇다면 별도의 엔트리포인트를 작성하여 스프링 시큐리티에 등록한다면 원하는 결과를 얻을 수 있다.

    @Component
    class CustomAuthenticationEntryPoint : AuthenticationEntryPoint {
        override fun commence(request: HttpServletRequest?, response: HttpServletResponse?, authException: AuthenticationException?) {
            response!!.contentType = MediaType.APPLICATION_JSON_VALUE
            response.characterEncoding = "UTF-8"
            val writer = response.writer
            val objectMapper = ObjectMapper()
            writer.write(objectMapper.writeValueAsString(ApiResponse.error("페이지를 찾을 수 없습니다.")))
            writer.flush()
        }
    }
    @Configuration
    class SecurityConfig(
        private val jwtAuthenticationFilter: JwtAuthenticationFilter,
        private val entryPoint: AuthenticationEntryPoint
    ) {
        private val allowedUris = arrayOf(..., "/error")
    
        @Bean
        fun filterChain(http: HttpSecurity) = http
            .csrf().disable()
            .headers { it.frameOptions().sameOrigin() }
            .authorizeHttpRequests {
                it.requestMatchers(*allowedUris).permitAll()
                    .anyRequest().authenticated()
            }
            .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
            .addFilterBefore(jwtAuthenticationFilter, BasicAuthenticationFilter::class.java)
            .exceptionHandling { it.authenticationEntryPoint(entryPoint) }
            .build()!!
    }

    이렇게 설정하고 다시 존재하지 않는 페이지로 이동하면 의도했던 결과를 얻을 수 있다.

    엔트리포인트 구현체에 처리 로직을 일일이 작성하지 않고 [Spring Security] Spring Security 예외를 @ControllerAdvice와 @ExceptionHandler를 사용하여 전역으로 처리해보자에서 사용했던 방식처럼 HandlerExceptionResolver에 예외를 넘겨버리고, @ControllerAdvice@RestControllerAdvice에서 예외에 따라 적절한 응답을 반환하게 할 수도 있다. 아래 코드에서 NotFoundException은 별도로 작성한 커스텀 예외이다.

    @Component
    class CustomAuthenticationEntryPoint(
        @Qualifier("handlerExceptionResolver")
        private val resolver: HandlerExceptionResolver
    ) : AuthenticationEntryPoint {
        override fun commence(request: HttpServletRequest?, response: HttpServletResponse?, authException: AuthenticationException?) {
            val exception = when (response!!.status) {
                200 -> return
                401, 403 -> request!!.getAttribute("exception") as Exception
                404 -> NotFoundException("요청하신 리소스를 찾을 수 없습니다.")
                408 -> TimeoutException("요청 시간이 초과되었습니다.")
                else -> Exception("서버에 문제가 발생했습니다.")
            }
            resolver.resolveException(request!!, response, null, exception)
        }
    }

    이 방법은 컨트롤러나 서비스 계층에서 발생하는 일반적인 예외를 잡을 수는 없기 때문에 적절한 예외 처리와 병행하는 것이 좋다.

    마치며

    스프링 시큐리티를 처음 사용할 때 예외 종류에 상관없이 내용이 비어있는 403 Forbidden 응답이 반환되는 것에 굉장히 의문이 들어서 상당히 많이 애를 먹었는데 역시나 이유 없는 결과는 없다고, 기본 설정이 그렇게 잡혀있어서 발생하는 문제라는 것을 알 수 있었다.

     

    스프링 시큐리티에 대해 파고들수록 상당히 편하고 좋은 기능을 제공하는 기술이지만, 원하는대로 사용하려면 별도로 설정해야 할 것이 적지 않다는 생각이 든다.

    참조 링크

     

    Spring Security의 Unauthorized, Forbidden 처리

    Spring Security를 적용해서 HTTP 요청에 대해 인증 및 인가를 적용할 경우 시큐리티 필터 체인에 의해 인증 여부나 인가 여부에 따라 요청이 수락되거나 거절된다. 그런데 필터 체인 특성상 이런 Sprin

    velog.io

     

    Architecture :: Spring Security

    The Security Filters are inserted into the FilterChainProxy with the SecurityFilterChain API. Those filters can be used for a number of different purposes, like authentication, authorization, exploit protection, and more. The filters are executed in a spec

    docs.spring.io

     

    Web

    Graceful shutdown is supported with all four embedded web servers (Jetty, Reactor Netty, Tomcat, and Undertow) and with both reactive and servlet-based web applications. It occurs as part of closing the application context and is performed in the earliest

    docs.spring.io

     

    Exception Handling in Spring MVC

    NOTE: Revised April 2018 Spring MVC provides several complimentary approaches to exception handling but, when teaching Spring MVC, I often find that my students are confused or not comfortable with them. Today I'm going to show you the various options avai

    spring.io

     

    댓글