[생각정리] 토큰 만료에 대한 반환

jeyong·2024년 1월 24일
0

공부 / 생각 정리  

목록 보기
6/120
post-custom-banner


스프링에 대해서 여러가지 공부를 하던중, 정리 해놓으면 좋을 것 같은 주제를 정리하려고 한다.
이번에 다를 주제는 토큰 만료에 대한 반환 처리이다.

해당 주제에 대해서 작성을 하게 된 이유는 프로젝트를 구현하며, 발생한 문제 때문이다. 문제에 대한 내용을 간단히 설명하자면 아래와 같다.
Access Token과 Refresh Token을 사용하였는데, Refresh Token이 만료되었다면 클라이언트가 해당 사실을 알고 Access Token을 재발급 받아야한다. 하지만 JwtAuthenticationFilter에서 Token의 만료에 대한 예외를 던져주면 Exception Handler에서 처리를 하지 못하기 때문에 Token의 만료에 대한 예외를 클라이언트에게 제공해주지 못하는 문제가 발생했기 때문이다. 평소라면 클라이언트의 문제라며 넘겼을만한 문제이지만 학기중에 프론트 엔드 코드를 직접 구현해보며, 프론트 엔드에 대한 감수성이 생겼기 때문에 최대한 자세한 정보들을 제공해주어야지라고 다짐했기 때문이다.
이제 해당 문제를 알아보고 해결해보자.

1. 기존코드

JwtHandler.java

 public Optional<Claims> parse(String key, String token) {
        try {
            return Optional.of(Jwts.parser().setSigningKey(key.getBytes()).parseClaimsJws(unType(token)).getBody());
        } catch (ExpiredJwtException e) {
            log.warn("Expired JWT token - {}", e.getMessage());
            return Optional.empty();
        } catch (UnsupportedJwtException e) {
            log.warn("Unsupported JWT token - {}", e.getMessage());
            return Optional.empty();
        } catch (MalformedJwtException e) {
            log.warn("Malformed JWT token - {}", e.getMessage());
            return Optional.empty();
        } catch (SignatureException e) {
            log.warn("Invalid JWT signature - {}", e.getMessage());
            return Optional.empty();
        } catch (IllegalArgumentException e) {
            log.warn("Invalid JWT token - {}", e.getMessage());
            return Optional.empty();
        } catch (JwtException e) {
            log.warn("JWT error - {}", e.getMessage());
            return Optional.empty();
        }
    }

코드에서 볼 수 있듯이 토큰 만료 토큰 정보 불일치 서명 불일치 에 대한 예외 모두 로그에만 내용을 남기지 클라이언트로 반환할때는 어떤 예외인지에 대한 내용을 전달해주지는 않는 모습이다.

2. ExceptionHandler 사용

// ExceptionAdvice.java

@ExceptionHandler(ExpiredJwtException.class)
    public ResponseEntity<Response> handleExpiredJwtException(ExpiredJwtException e) {
        log.warn("Expired JWT token - {}", e.getMessage());
        return ResponseEntity
                .status(HttpStatus.UNAUTHORIZED)
                .body(getFailureResponse("expiredJwtException.code", "expiredJwtException.msg"));
    }

앞에서도 간단히 이야기하였지만 해당 코드와 같이 ExceptionHandler를 통해 이를 해결하려고 하는 사람은 많을 것이다. 하지만 해결할 수 없다. 이유는 아래와 같다.

  • Filter는 Dispatcher Servlet 보다 앞단에 존재한다.
  • 즉, Handler Intercepter는 뒷단에 존재하기 때문에 Filter 에서 보낸 예외는 Exception Handler로 처리를 못한다.

3. JwtExceptionFilter 사용

해결 방법은 JwtExceptionFilter를 사용하는 것이다. 해당 내용을 간단하게 설명하자면 아래와 같다.

  • 현재 필터보다 앞단에 예외 처리를 위한 필터를 하나 둔다.
  • 다음 차례 필터의 로직 수행 중 던져진 예외가 앞서 거쳤던 필터로 넘어가서 처리가 가능하다.
  • 즉, 아래와 같이 JwtExceptionFilter -> JwtAuthenticationFilter로 필터를 구성하는 것이다.

SecurityConfig.java

.exceptionHandling((exceptionConfig) ->
                        exceptionConfig.authenticationEntryPoint(new CustomAuthenticationEntryPoint()).accessDeniedHandler(new CustomAccessDeniedHandler())
                )
                .addFilterBefore(new JwtAuthenticationFilter(userDetailsService), UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(new ExpiredJwtExceptionFilter(messageSource), JwtAuthenticationFilter.class)

addFilterBefore 를 이용하여 Filter들 간의 순서를 지정해준다.

JwtHandler.java

public Optional<Claims> parse(String key, String token) {
        try {
            return Optional.of(Jwts.parser().setSigningKey(key.getBytes()).parseClaimsJws(unType(token)).getBody());
        } catch (ExpiredJwtException e) {  // ExpiredJwtException 정보만 클라이언트에게 전달. 나머지 예외는 보안상에 이유르 전달하지 않음.
            log.warn("Expired JWT token - {}", e.getMessage());
            throw e;
        } catch (UnsupportedJwtException e) {
            log.warn("Unsupported JWT token - {}", e.getMessage());
            return Optional.empty();
        } catch (MalformedJwtException e) {
            log.warn("Malformed JWT token - {}", e.getMessage());
            return Optional.empty();
        } catch (SignatureException e) {
            log.warn("Invalid JWT signature - {}", e.getMessage());
            return Optional.empty();
        } catch (IllegalArgumentException e) {
            log.warn("Invalid JWT token - {}", e.getMessage());
            return Optional.empty();
        } catch (JwtException e) {
            log.warn("JWT error - {}", e.getMessage());
            return Optional.empty();
        }
    }

수정된 JwtHandler.java이다. 정보만 클라이언트에게 전달 하도록 구현했다. 왜냐하면 나머지 예외는 보안상에 이유로 문제가 생길 것 같았기 때문이다.

ExpiredJwtExceptionFilter

@RequiredArgsConstructor
public class ExpiredJwtExceptionFilter extends OncePerRequestFilter {

    private final MessageSource messageSource;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        try {
            filterChain.doFilter(request, response);
        } catch (ExpiredJwtException e) {
            handleExpiredJwtException(response, e);
        }
    }

    private void handleExpiredJwtException(HttpServletResponse response, ExpiredJwtException e) throws IOException {
        int errorCode = Integer.valueOf(messageSource.getMessage("expiredJwtException.code", null, null));
        String errorMessage = messageSource.getMessage("expiredJwtException.msg", null, LocaleContextHolder.getLocale());

        Response failureResponse = Response.failure(errorCode, errorMessage);

        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");
        response.getWriter().write(convertToJson(failureResponse));
    }
    private String convertToJson(Response response) throws IOException {
        ObjectMapper objectMapper = new ObjectMapper();
        return objectMapper.writeValueAsString(response);
    }
}

해결책으로 제시한 JwtExceptionFilter에 대한 구현이다. 현재 코드는 국제화를 사용하여서 클라이언트에게 반환하도록 구현하였기 때문에 어려워 보일 뿐이지, 간단하게 설명하자면 ExpiredJwtException 예외를 잡아서 handleExpiredJwtException 메서드를 통해 클라이언트에게 해당 정보를 알려주는 것 뿐이다.

4. 마무리

프로젝트를 진행하며, 토큰 만료에 대한 정보 반환에 대해서 고민을 해보았고, 많은 자료들을 찾아본 결과 JwtExceptionFilter를 통해 문제를 해결하는 것이 제일 깔끔하다고 생각하였고 해당 방법을 프로젝트에 적용하는 것에 성공하였다. 만약 더 좋은 방법이 있거나 현재 나의 생각이 틀릴 경우 다음에 다시 찾아오겠다.

profile
노를 젓다 보면 언젠가는 물이 들어오겠지.
post-custom-banner

0개의 댓글