Spring Boot 헤더 검증 고도화 (with Filter)

최민길(Gale)·2023년 6월 8일
1

Spring Boot 적용기

목록 보기
21/46

안녕하세요 오늘은 Spring Boot에서 헤더 검증을 위한 필터 작업에 대해서 포스팅하도록 하겠습니다.

위의 사진은 스프링 부트 공식 문서에서 발췌한 HttpServletRequest 인증 과정입니다. 검증 과정을 보시면 다음의 흐름으로 진행됩니다.

  1. SecurityFilterChain을 통해 1차적으로 걸러지고 유효한 토큰이라면 SecurityContextHolder에 Authentication 객체를 저장한다.
  2. RequestMatcherDelegatingAuthorizationManager에서 검증을 진행하여 통과하면 이어서 작업이 진행되고 통과하지 못하면 AccessDeniedException을 리턴한다.

여기서 주의할 점은 SecurityFilterChain에서 발생한 예외는 AccessDeniedException으로 처리가 되지 않는다는 점입니다. 즉 필터에서 에러가 발생할 경우 자동으로 AccessDeniedException을 리턴하는 것이 아니라 에러에 관련된 Exception 메시지를 호출합니다.

        http
//                 토큰이 없는 상태에서 요청이 들어오는 API들은 permitAll
                .authorizeHttpRequests((authorizeHttpRequests) -> authorizeHttpRequests
                        .requestMatchers("/api").permitAll()
                        .anyRequest().authenticated()
                )
                .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)

위의 코드는 permitAll() 옵션을 통해 토큰이 없는 상태에서 요청이 들어오는 API들의 검증을 진행하지 않고 넘기게 됩니다. 하지만 밑에 별도의 jwtFilter를 추가한 상태에서 필터 level에서 토큰이 존재하지 않을 경우 발생하는 예외를 처리하게 될 경우 authorizeHttpRequests 에서 permitAll() 처리한 내용대로 넘어가지 않고 그대로 Exception이 catch되게 됩니다. 즉 AccessDeniedException을 리턴하는 것이 아니라 catch된 Exception이 호출되기 때문에 검증을 주의해야 합니다.

이 경우 2가지 선택지가 존재합니다.
1. header 검증을 필터에서 진행하지 않고 RequestMatcherDelegatingAuthorizationManager Level에서 검증을 진행하는 것
2. 필터에서 검증을 진행하되 별도의 Exception을 catch하여 예외 처리를 진행하는 것

저는 이왕 커스텀한 필터를 만든 김에 추가적으로 커스텀 객체를 만들지 않기 위해 2번째 방법으로 선택했습니다. 또한 기존의 401,403 에러를 처리하는 로직 역시 존재하기 때문에 필터 단계에서 놓친 부분에 대해서 추가 검증이 가능하다는 장점이 있습니다. 이 방식을 진행하기 위해선 다음의 세팅이 필요합니다.

                .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(uriFilter, JwtFilter.class);

위의 코드는 필터 삽입 순서를 나타냅니다. addFilterBefore(A,B)의 경우 A를 B 실행 이전에 실행, 즉 A를 B보다 먼저 필터링하는 것을 의미합니다. 따라서 위의 경우에는 uriFilter, jwtFilter. UsernamePasswordAuthenticationFilter 순으로 필터링이 진행되며 가장 먼저 마주치는 uriFilter에서는 유효한 uri인지 검증, 그 다음 단계에서는 해당 uri의 토큰 필요 유무와 토큰이 존재할 경우 토큰 유효성 검증을 진행하게 됩니다.

    @Override
    protected void doFilterInternal(
            @NonNull HttpServletRequest httpServletRequest,
            @NonNull HttpServletResponse httpServletResponse,
            @NonNull FilterChain filterChain
    ) throws IOException, ServletException {
        try{
            // 검증 로직
            ...

            filterChain.doFilter(httpServletRequest,httpServletResponse);
        }
        catch(ValidationException e){
            logger.error(e.getMessage());
            setErrorResponse(e.getCode(),e.getMessage(),httpServletResponse);
        }
    }
    
        @Override
    public void setErrorResponse(int code, String message, HttpServletResponse response) throws IOException {
        String json = new ObjectMapper().writeValueAsString(new DefaultResponseDTO(code,message));
        response.getWriter().write(json);
    }

예외 처리를 위해 ValidationException을 catch하여 검증 로직 과정에서 유효한 접근이 아닐 경우 ValidationException을 던지는 구조로 필터를 만들었습니다. 이 때 setErrorResponse에서 Exception을 json 형태로 전송하는 형태로 오류 여부를 확인할 수 있습니다.

        mvc = MockMvcBuilders
                .webAppContextSetup(context)
                .addFilter(uriFilter)
                .addFilter(jwtFilter)
                .build();

여기서 또 하나 주의할 점은, MockMvc로 Controller 테스트를 진행할 경우 작성한 필터를 기입하지 않으면 필터가 적용되지 않는다는 점입니다. MockMvc는 가상화된 서블릿 컨테이너를 이용하기 때문에 생성한 필터가 기본적으로 붙어 있지 않은 상태입니다. 먼저 추가한 필터가 먼저 실행되는 구조로 이루어져 있습니다. 즉 위에서 http.addFilterBefore(uriFilter, JwtFilter.class); 코드와 위의 코드는 같은 필터 순서로 구성됩니다.

profile
저는 상황에 맞는 최적의 솔루션을 깊고 정확한 개념의 이해를 통한 다양한 방식으로 해결해오면서 지난 3년 동안 신규 서비스를 20만 회원 서비스로 성장시킨 Software Developer 최민길입니다.

0개의 댓글