MyB

[Security] 오류 발생 시점에 따른 필터처리 및 체인구조 파악

2024.08.02

프로젝트 진행중 SecurityConfig의 내용을 다 이해하면서 진행하고 싶었어서 어떤식으로 작동하는지 궁금해서 이것저것 찾아봤는데 명확하게 잘 안나와있는것 같아서 내가 찾아보고 이것저것 테스트하면서 알게된 내용을 정리해보려 한다.

우선 진행한 프로젝트의 SecurityConfig코드는 아래와 같다.

@Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        permitSwaggerUri(http);
        return http
                .csrf(AbstractHttpConfigurer::disable)
                .formLogin(AbstractHttpConfigurer::disable)
                .httpBasic(AbstractHttpConfigurer::disable)
                .sessionManagement(sessionManagementConfigurer ->
                        sessionManagementConfigurer
                                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .exceptionHandling(exceptionHandlingConfigurer ->
                        exceptionHandlingConfigurer
                                .authenticationEntryPoint(customAuthenticationEntryPointHandler))
                .authorizeHttpRequests(request ->
                        request
                                .requestMatchers("/api/v1/auth/sign-in").permitAll()
                                .requestMatchers("/api/v1/auth/reissue").permitAll()
                                .requestMatchers("/api/v1/members/password").permitAll()
                                .requestMatchers("/api/v1/members").permitAll()
                                .requestMatchers("/api/v1/mail/**").permitAll()
                                .requestMatchers("/actuator/health").permitAll()
                                .anyRequest()
                                .authenticated())
                .addFilterBefore(
                        jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class
                )
                .addFilterBefore(
                        exceptionHandlerFilter, JwtAuthenticationFilter.class
                )
                .build();
    }

해당 코드는 현재 내가 속해있는 SOPT라는 동아리에서 레퍼를 따갖고 왔다.

코드중 궁금했던 부분이 authenticationEntryPoint부분과 .addFilterBefore에 들어있는 각 필터들의 역할이였다.

Security 필터 구조

위 두가지 그림은 필터를 그림으로 나타낸 것이다.

첫번째 그림에서 클라이언트가 신호를 보내면 서블릿 컨테이너로 들어오기전 다양한 서블릿 필터를 거치게 되는데 여기서 시큐리티에게 위임받은 필터가 작동하게 된다.  그러면 SecurityFilterChain이 작동하게 된다.

이제 두번째 그림으로 가보면 SecurityFilterChain 동작중 Exception이 발생하면 인증오류인지 인가 오류인지에 따라 오류 핸들러가 달라진다. 여기서 인증 오류의 경우 AuthrnticationEntruPoint 로 예외가 전달되고 인가 오류의 경우 AccessDeniedHandler로 가게 된다.

우리 프로젝트의 경우 시큐리티의 인가 기능을 활용하지 않아 AuthenticationEntryPoint만 추가해줬다.

AuthenticationEntryPoint

이 부분은 시큐리티가 인증에 대한 오류 처리를 해주는 필터라고 생각하면 된다.

해당 부분으로 예외가 전달되는 상황은 아래와 같다.

  • 로그인 방식에 formLogin과 HttpBasic 방식이 있는데 이 과정에서 오류가 생길때
  • requestMatcheers로 엔드포인트에 대한 화이트 리스트 설정을 해주고 있는데 이 이외의 엔드포인트로 인증되지 않은 유저가 요청할 때

이 두 상황에서 AuthenticationEntrypPoint로 예외가 전달된다. 원래는 Default로 처리해주는 EntryPoint가 formLogin방식과 HttpBasic방식 각각 나뉘어져 있는데 우리가 Custom한 AuthenticationEntryPoint가 있을 경우 여기로 우선적으로 전달된다.

그림을 보면 FilterChainProxy에서 발생된 인증 인가에 대한 오류들이 AthenticationEntrypoint와 AccessDeniedHandler로 전달되는 것 이다.

추가로 알게된 점

이것저것 해보다가 알게된 점이 있는데 서블릿 컨테이너에서 잡아주지 못한 에러가 AthenticationEntrypoint로 와서 처리가 된다는 것이다.

우리 프로젝트의 경우 초기에 에러 처리가 부실했어서 잡아주지 못한 에러가 있었는데 여기서 발생한 에러가 AthenticationEntrypoint로 가서 처리가 된다는 점이었다. 이를 통해서 스프링이 동작하는 전체적인 구조에 대해서 생각을 해봤다.

블로그 본문의 첫번째 그림을 보면 클라이언트가 신호를 보내고 **doFilter( )**라는 메서드를 사용해서 다양한 필터를 거쳐 서블릿으로 들어가게 되는데  서블릿에서 발생된 에러를 따로 잡아주지 못하니 이 에러가 다시 역순으로 전이되면서 최종적으로 AthenticationEntrypoint 에 잡혀서 AthenticationEntrypoint에서 처리를 해주는 것 이였다.

이런 경험을 해보니까 내가 얼마나 스프링의 구조를 잘 모르고 쓰고 있는지에 대해서 좀 느꼈던것 같다. 단순히 프로젝트로 코드를 짜는 것이 아니라 전체적으로 스프링이 어떻게 돌아가는지 더 이해해보는 시간을 갖으면 좋을 것 같다.

JwtAuthenticationFilter & ExceptionHandlerFilter

다음 두 필터는 커스텀 필터이다. JwtAuthenticationFilter는 우리 프로젝트가 JWT를 사용했기 때문에 이를 위한 필터고, ExceptionHandlerFilter는 필터 인증과정에서 ExceptionHandler를 추가적으로 넣어 JWT에 대한 예외처리도 해주었다.

각각 addFilterBefore라는 메서드 안에 필터들을 넣어줬는데 이 메서드는 필터의 순서를 지정해준다. 메서드 이름 그대로 오른쪽 필터 이전에 왼쪽 필터를 실행해 준다.

코드를 보면 UsernamePasswordAuthenticationFilter가 있는데 구조는 아래와 같다.

본격적인 아이디와 비밀번호 인증과정이라고 보면 된다. UsernamePasswordAuthenticationFilter이야기를 한 이유는 JWTAuthenticationFilter에서 한 과정 때문이다. 아래는 해당 코드다.

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtProvider jwtProvider;
    private final JwtValidator jwtValidator;

    @Override
    protected void doFilterInternal(
            @NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull FilterChain filterChain
    ) throws IOException, ServletException {
        val token = jwtProvider.getTokenFromRequest(request);
        if (StringUtils.hasText(token)) {
            jwtValidator.validateToken(token);
            setAuthenticationContextHolder(jwtProvider.getUserFromJwt((token)), request);
        }
        filterChain.doFilter(request, response);
    }

    private void setAuthenticationContextHolder(long memberId, HttpServletRequest request) {
        val authentication = new UserAuthentication(memberId, null, null);
        authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }
}

다음 코드를 보면 우리 프로젝트는 토큰을 통해 유저의 접근 권한을 확인하기 때문에 매 요청마다 토큰이 들어가 있고 들어온 요청은 해당필터를 통과하면서 권한이 있는지 확인하게 된다. 토큰이 성공적으로  Validate됐다면 memberId로 Authentication 객체를 생성하여 SecurityContextHolder에 접근하고자 하는 아이디 정보가 담긴 Authentication 객체를 set한다. 이렇게해서 JWTAuthenticationFilter가 끝이나고 UsernameAuthenticationFilter로 들어가게 된다. 여기서 우리가 set한 Authentication정보를 갖고 검증을 하게 된다.

이 이후 과정에서 발생한 오류들은 AuthenticationEntryPoint나 AccessDeniedHandler가 오류를 처리한다.

그리고 이 이전으로 ExceptionHandlerFilter가 존재한다. 해당 코드는 다음과 같다.

@Slf4j
@Component
@RequiredArgsConstructor
public class ExceptionHandlerFilter extends OncePerRequestFilter {

    private final ObjectMapper objectMapper;

    @Override
    protected void doFilterInternal(
            @NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull FilterChain filterChain
    ) throws IOException {
        try {
            System.out.println("EHF");
            filterChain.doFilter(request, response);
        } catch (AuthException e) {
            log.info("ExceptionHandlerFilter: AuthException - " + e);
            handleAuthException(response, e);
        } catch (JwtException e) {
            log.info("ExceptionHandlerFilter: JWTException - " + e);
            handleJwtException(response);
        } catch (IllegalArgumentException e) {
            log.info("ExceptionHandlerFilter: IllegalArgumentException - " + e);
            handleIllegalArgumentException(response);
        } catch (ServletException e) {
            log.info("ExceptionHandlerFilter: Exception - " + e);
            throw new RuntimeException(e);
        }
    }

    private void handleAuthException(HttpServletResponse response, AuthException e) throws IOException {
        val errorMessage = e.getErrorCode().getMessage();
        val httpStatus = e.getErrorCode().getHttpStatus();
        setResponse(response, httpStatus, errorMessage);
    }

    private void handleJwtException(HttpServletResponse response) throws IOException {
        val jwtException = ErrorCode.INVALID_JWT_TOKEN;
        setResponse(response, jwtException.getHttpStatus(), jwtException.getMessage());
    }

    private void handleIllegalArgumentException(HttpServletResponse response) throws IOException {
        val uncaughtException = ErrorCode.EMPTY_JWT;
        setResponse(response, uncaughtException.getHttpStatus(), uncaughtException.getMessage());
    }

    private void handleUncaughtException(HttpServletResponse response) throws IOException {
        val uncaughtException = ErrorCode.UNCAUGHT_EXCEPTION;
        setResponse(response, uncaughtException.getHttpStatus(), uncaughtException.getMessage());
    }

    private void setResponse(HttpServletResponse response, HttpStatus httpStatus, String errorMessage) throws IOException {
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("UTF-8");
        response.setStatus(httpStatus.value());
        val writer = response.getWriter();
        writer.write(objectMapper.writeValueAsString(ErrorResponse.of(errorMessage)));
    }
}

이 부분은 아직 리팩토링이 덜 되어서 제대로 적혀있지는 않다. 실험적인 코드들도 있다.

우선 ExceptionHandlerFilter는 JwtFilter이전에 존재하는데 이 필터가 존재하는 이유는 JWT 검증 과정에서 오류가 발생할 시 이 ExceptionHandlerFilter가 예외를 처리해 준다.

해당 코드들은 AuthException이나 IllegalException도 처리해주고 있는데 실험해본 결과 필요가 없다. 왜냐하면 JwtFilter에서 잡힌 예외들만 ExceptionHandlerFilter가 처리하고 그 외에 것들은 AuthenticationEntryPoint나 AccessDeniedHandler에서 처리하기 때문이다. 따라서 이 부분에서 JwtException말고는 전부 지워줄 예정이다.

또한 이 부분 코드를 보면서 doFilter는 왜 들어갔는지 궁금했는데 앞에서 잠깐 언급했다 싶이 Security는 체인 구조로 이루어져 있고 이런 다음 체인으로 넘어가기 위해서는 doFilter를 해줘야 다음 Filter로 넘어가는 것 이다.

총정리

전체적인 구조와 어떤 부분에서 에러 발생시 어디에서 처리되는지에 대해서 정리를 해보겠다.

클라이언트가 신호를 보낼시 아래와 같은 순서로 통과하게 된다.

  1. ExceptionHandlerFiler : JwtAuthenticationFilter에서 발생한 예외들을 처리한다.
  2. JwtAuthenticationFilter: JWT 토큰 검증 과정을 거친다.
  3. UsernamePasswordAuthenticationFilter: 이전 필터에서 등롞된 Authentication객체를 활용하여 유저 검증 과정을 거친다.
  4. 다른 시큐리티 체인들
  5. 서블릿 컨테이너

우리 프로젝트의 시큐리티단 구조는 대략 위와 같다. 이번에는 역순으로 위치별로 에러가 터질시 어디로 전달되는지 살펴보겠다.

  1. 서블릿 컨테이너: 해당 부분에서 GlobalExceptionHandler로 예외를 잡는다면 해당 부분에서 예외처리가 되겠지만 여기서 잡지 못한다면 다시 순서가 역순으로 올라가면 예외가 시큐리티 체인 단으로 가면서 AuthenticationEntryPoint에서 예외가 처리된다.
  2. 다른 시큐리티 체인들: 시큐리티에서 설정해놓은 체인단에서 에러가 발생하면 AuthenticationEntrypoint혹은 AccessDeniedHandler로 간다.
  3. UsernamePasswordAuthenticationFilter: 2번과 마찬가지
  4. JwtAuthenticationFilter: ExceptionHandlerFiler 로 가게 된다.
  5. ExceptionHandlerFiler : 여기서도 잡지 못한 에러는 결국 AuthenticationEntryPoint로 가게 된다

느낀점

지금 구조에서만 해도 예외처리를 해주는 부분이 총 3곳이다. 서블릿, AuthenticationEntryPoint, ExceptionHandlerFiler  예외처리가 너무나도 복잡해진다. 이상적인 예외처리를 위해서는 메인에서 발생하는 에러들을 다 잡아주고 JWT 검증 과정에서 발생하는 에러들은 ExceptionHandlerFiler에서 그 외 오류들은 AuthenticationEntryPoint에서 해줘야 하는데, 다른 멘토님에게 물어보니 원래 예외의 경우 한번에 다 잡기 어렵기 때문에 점진적으로 잡아간다고 했다. 하지만 이럴 경우 클라이언트와 서버간에 불필요한 이슈 소통이 너무 빈번하게 일어날 것 같은데 이런점을 어떻게 해결하면 좋을지 생각해보면, 에러가 난 로그들을 슬랙으로 보내고 그걸 클라이언트 분들도 볼 수 있게 하면 불필요한 소통을 조금 줄일 수 있지 않을까라고 간단하게 생각해 봤고, 또한 시큐리티를 굳이 사용해야하나 라는 의문이 들었다. 따로 우리가 인가 과정이 필요한 것도 아닌데 시큐리티를 사용하여 이런 복잡한 로직들을 구성해줘야 하나 싶다. 일단은 어떻게 하면 클라이언트 분들이 헷갈리지 않게 에러메세지를 구성할 지에 대해 고민해봐야 겠다

이런식으로 글을써본게 처음이라 너무 두서없이 난잡하게 쓴 것 같은 점점 더 글쓰기 실력이 나아지길..

  • 추가적으로 글을 쓰면서 Security 체인 과정에서 오류가 나면 AuthenticationEntryPoint나 AccessDeniedHandler로 가서 에러가 처리된다고 했는데, hasRole()에서 인가에 대한 권한을 확인하는 과정을 제외하곤 AuthenticationEntryPoint에서 예외가 처리되는 것으로 파악된다.

최근 글