개발도구/Spring, Spring Boot

테스트는 성공하는데 실제 요청에서 403 Forbidden 응답이 발생하는 경우

redsiwon 2023. 12. 1. 17:35

문제 발견

JWT Bearer 토큰을 이용하여 인증을 전제하는 기초적인 Get 요청에 대하여 테스트는 성공하는데 Postman을 활용한 실제 응답에서 403 Forbidden 응답이 발생했다.

Postman 403 Forbidden

아래는 import 부분만 제거한 실제 테스트 코드다

@DisplayName("예산 시나리오 테스트")
@Transactional
@AutoConfigureMockMvc
@SpringBootTest
class BudgetControllerTest extends AbstractControllerWithAuthTest {

    private static final String BASE_URI = "/api/budgets";

    @Autowired
    public BudgetControllerTest(MockMvc mockMvc, ObjectMapper mapper) {
        super(mockMvc, mapper);
    }

    @DisplayName("카테고리 조회")
    @Nested
    class GetCategories {

        private static final String URI = BASE_URI + "/categories";

        @DisplayName("성공")
        @Test
        void success() throws Exception {
            // given
            String accessToken = joinAndLogin(EMAIL, PASSWORD);
            String bearerToken = JwtAuthenticationFilter.BEARER_TOKEN_PREFIX + accessToken;

            // when
            MvcResult result = mockMvc.perform(get(URI)
                            .header(HttpHeaders.AUTHORIZATION, bearerToken)
                            .accept(MediaType.APPLICATION_JSON)
                    )
                    .andExpect(status().isOk())
                    .andExpect(content().contentType(MediaType.APPLICATION_JSON))
                    .andExpect(jsonPath("$").isArray())
                    .andReturn();

            // then
            String responseBody = result.getResponse().getContentAsString(StandardCharsets.UTF_8);
            List<String> actualCategories = mapper.readValue(responseBody, new TypeReference<List<String>>() {});
            List<String> expectedCategories = Arrays.stream(BudgetCategoryType.values())
                    .map(BudgetCategoryType::getDesc)
                    .toList();
            assertThat(actualCategories).isEqualTo(expectedCategories);
        }

    }

}
테스트 성공

문제 해결

원인 분석이 아니라 그냥 바로 해결책부터 공개한다. 정상적인 문제해결 과정으로는 원인을 알아내기 어렵고 번거롭기 때문이다.

포스트맨으로는 보이지 않는 특수문자가 삽입되어있었다. 포스트맨에서 budgets 앞에서 백스페이스를 눌러보니 제자리에 있었다.. 아마 특수문자가 삭제된 것 같다. 아스키코드 8번은 찾아보니까 BS인데 백스페이스..? 왠지 모르겠다;;

 

Postman 200 OK

보이지 않는 특수문자를 지워주고 재요청했더니 성공!

 

문제 원인 분석

403 Forbidden 발생 지점 탐색

검색해보니 403 Forbidden은 리소스는 존재하지만 인가되지 않았을 때 발생한다고 한다.

AuthorizationFilter 브레이크포인트 설정 및 디버깅

이 지점에서 추적해본다. 확인해본 결과 이때 이미 request URI는 /error로, 디스패처 타입은 ERROR로 변경되어있었다.

 

ExceptionTranslationFilter에 catch된 모습

한 줄씩 실행해보면 별도의 AuthenticationException은 발생하지 않았기 때문에 136번 조건문에서 진입 후에 securityException 변수가 초기화되고 147번줄로 진행한다.

 

403 Fobidden 발생 시점

결과적으로 Http403ForbiddenEntryPoint에서 응답이 생성되는 것으로 확인되었다.

 

최초 예외 발생 지점 탐색

인가 필터 인가 검증부터 다시 확인해본다. 그런데 정말 웃기게도 처음은 100번 줄로 바로 넘어간다. 계속 확인해보자.

 

404 Not Found 발생 시점

파고파고 들어가다보면 필터를 무사히 지나서 디스패처서블릿까지 진입한다. 그리고 최초에 404 Not Found 응답이 발생해서 톰캣까지 다시 내려갔다가 톰캣에서 /error로 재요청해서 AuthorizationFilter에 걸리고 최종적으로 403 Forbidden으로 변환된 것으로 보인다.

 

그런데 여기서 디스패처 서블릿이 획득한 핸들러가 RequestMappingHandlerMapping이 아닌 ResourceHttpRequestHandler인 것이 문제 해결의 키포인트인데, 이는 요청 URI에 대응되는 RequestMapping URI가 없기 때문인 것으로 유추할 수 있고, 이 지점에서 요청 URI가 잘못된 것은 아닌지 의심해볼 여지가 생긴다.

 

끝..