빙응의 공부 블로그

[UniP]JWT 검증 필터 예외처리는 어떻게 처리할까? 본문

Project/UniP

[UniP]JWT 검증 필터 예외처리는 어떻게 처리할까?

빙응이 2024. 10. 15. 16:04

📝 1. 문제정의

JWT 구현 중에 문제가 생겼다. 왜 JwtFilter에 대한 예외처리가 안먹지??

 

📌 기존의 JwtFilter 

  • JWT를 진행하면서 필터를 통해 유효성 검사를 진행하였다.
  • 검사에 맞지 않으면 ExpiredJwtException가 발생해서 ExcetionHandler를 통해 잡을 예정이였다. 
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        // 요청 URL을 가져와서 로깅
        String requestUri = request.getRequestURI();

        if (requestUri.matches("^\\/login(?:\\/.*)?$")) {
            filterChain.doFilter(request, response);
            return;
        }
        if (requestUri.matches("^\\/oauth2(?:\\/.*)?$")) {
            filterChain.doFilter(request, response);
            return;
        }

        String accessToken = request.getHeader("access");

        // AccessToken이 없거나, 만료된 경우
        if (accessToken == null) {
            filterChain.doFilter(request, response);
            return;
        }
        if (jwtUtil.isExpired(accessToken)) {
            filterChain.doFilter(request, response);
            return;
        }

        // 카테고리가 Access인지 확인
        if (!"access".equals(jwtUtil.getCategory(accessToken))) {
            log.warn("Invalid token category");
            filterChain.doFilter(request, response);
            return;
        }

        // 유효한 JWT 토큰이 존재하는 경우
        String token = accessToken;

        // JWT 토큰에서 사용자 정보를 추출하여 MemberDTO를 생성
        MemberDTO memberDTO = MemberDTO.builder()
            .username(jwtUtil.getUsername(token))
            .role(jwtUtil.getRole(token))
            .build();

        // CustomOAuth2User 객체를 생성하여 사용자 정보를 설정
        CustomUserDetails customOAuth2User = new CustomUserDetails(memberDTO);

        // 사용자 정보를 기반으로 Spring Security의 Authentication 객체를 생성
        Authentication authToken = new UsernamePasswordAuthenticationToken(
            customOAuth2User,
            null,
            customOAuth2User.getAuthorities()
        );

        // SecurityContext에 Authentication을 설정하여 현재 사용자를 인증 상태로 설정
        SecurityContextHolder.getContext().setAuthentication(authToken);

        log.info("User authenticated: {}", memberDTO.getUsername());

        filterChain.doFilter(request, response);
    }

}

📌필터의 jwt 인증 실패 Exception을 받는 Handler

    @ExceptionHandler(ExpiredJwtException.class)
    @ResponseBody
    public ResponseEntity<String> handleExpiredJwtException(ExpiredJwtException ex) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
            .body("JWT Token has expired");
    }

 

📝 2. 원인 추론

사실 해당 문제의 답은 해커톤 진행 중에
다른 팀 코드를 리뷰해보면서 알게 되었다...
바로 JwtFilter는 Spring Security의 필터라는 것이다.

📌현재 @ControllerAdvice의 문제점

현재 문제는 JwtException이 발생했을 때 @ControllerAdvice의 전역 예외 처리기가 이를 처리하지 못하고 있다는 점이다.

 

 

📌@ControllerAdvice와 JwtFilter의 범위 차이

  • JwtFilter에서 나오는 JwtException이 Spring Security 필터 단계에서 발생된다는 것을 알았다.
  • 그렇기에 ControllerAdvice는 컨트롤러에서 발생한 예외만 처리할 수 있어, 필터 단계에서 발생한 예외를 포착하지 못하는 것이다.

 

즉!
JwtFilter의 Exception은 Security 단계에서만 발생하기 때문에 Handler가 잡지 못하는 것이다.

 

 

📝 3. 해결방안


 

Handling JWT Exception in Spring MVC

I am trying to implement Token Authentication on our REST Api, and currently I am referring to this article. On the article it discusses that on creating the token JWT was used, but my current prob...

stackoverflow.com

 

📌 1. AuthenticationEntryPoint를 통한 인증 실패 처리 

  • AuthenticationEntryPoint는 Spring Security에서 인증이 필요한 요청을 처리하기 위한 인터페이스이다.
  • 다음과 같은 역할을 한다.
    1. 인증 실패 처리 : 인증이 유효하지 않을 경우 AuthenticationEntryPoint를 통해 응답을 반환
    2. 클라이언트에게 적절한 피드백 제공 : 인증이 실패했을 때, 클라이언트에게 인증이 필요하다는 것을 명확히 알린다.

📌 2. 직접 JWT 검증 및 응답 설정

  • AuthenticationEntryPoint는 인증 실패에 대한 예외처리를 합니다. 
  • 그렇기에 JWT 검증 과정에서 발생하는 오류를 처리하기 위해 필터에서도 직접 예외를 던지는 것이 좋습니다.

📝 4. 문제 해결

JwtExceptionFilter
  • 이 필터는 요청 처리 중 발생하는 JwtException을 포착하여 적절한 에러 응답을 설정합니다.
  • 중요한 점은 Jwt 토큰 유효성 검사 전에 나와 chain.doFilter(request, response) 호출 후 예외가 발생하면 setErrorResponse 메서드를 호출하여 JSON 형식으로 응답을 작성합니다.
@Component
public class JwtExceptionFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
        try {
            chain.doFilter(request, response); // go to 'JwtAuthorizationFilter'
        } catch (JwtException exception) {
            setErrorResponse(HttpStatus.UNAUTHORIZED, response, exception);
        }
    }

    public void setErrorResponse(HttpStatus status, HttpServletResponse res, Throwable ex) throws IOException {
        res.setStatus(status.value());
        res.setContentType("application/json; charset=UTF-8");

        ObjectMapper objectMapper = new ObjectMapper();
        String jsonResponse = objectMapper.writeValueAsString(ResponseDto.fail(HttpStatus.UNAUTHORIZED.value(), ex.getMessage()));
        res.getWriter().write(jsonResponse);
    }
}

 

JwtAuthorizationFilter
  • 이 필터는 JWT를 검증하고 사용자 인증을 수행합니다.
  • 유효한 JWT가 있는 경우 사용자 정보를 로드하여 SecurityContextHolder에 인증 정보를 설정합니다.
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtAuthorizationFilter extends OncePerRequestFilter {
    private final JwtUtil jwtUtil;
    private final CustomUserDetailsService userDetailsService;
    private static final String AUTHORIZATION_HEADER = "access";

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String accessToken = resolveToken(request);
        if (StringUtils.hasText(accessToken) && jwtUtil.validateToken(accessToken)) {
            String username = jwtUtil.getUsername(accessToken);
            CustomUserDetails customUserDetails = userDetailsService.loadUserByUsername(username);

            Authentication authToken = new UsernamePasswordAuthenticationToken(
                customUserDetails, null, customUserDetails.getAuthorities()
            );
            SecurityContextHolder.getContext().setAuthentication(authToken);
            log.info("User authenticated: {}", username);
        }

        filterChain.doFilter(request, response);  // 필터 체인 계속 진행
    }

    private String resolveToken(HttpServletRequest request) {
        // "access" 헤더에서 토큰 추출
        return request.getHeader(AUTHORIZATION_HEADER);
    }
}

 

JwtAuthenticationEntryPoint
  • 인증이 실패할 경우 사용됩니다. 클라이언트에게 401 Unauthorized 응답을 반환합니다.
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException authException) throws IOException {
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized");
    }
}

 

 

📝 결론

Spring Security의 필터에서 나오는 오류는 Handler로 잡을 수 없다.

  • 그렇기에 AuthenticationEntryPoint 이용하여 인증 실패를 잡아야 한다.
  • 또한 필터로 직접 예외를 던져 추가적인 검증도 진행하는게 좋다.

조합하여 사용:

 

  • 필터: JWT 검증 및 예외 발생 처리.
  • AuthenticationEntryPoint: 인증 실패 시 일관된 응답 제공.

 

 

✔ 이렇게하면 장점이 뭔데?

 

  • 일관된 예외 처리: AuthenticationEntryPoint를 통해 모든 인증 실패를 일관되게 처리할 수 있다.
  • 유연한 오류 응답: 필터에서 직접 예외를 처리하여 클라이언트에게 필요한 정보를 제공할 수 있다.
  • 클린한 코드: 각 구성 요소의 책임이 명확해져 코드 유지 보수가 용이하다.