Project/Distance

[Trouble Shooting] Refresh Token 구현

EJUN 2024. 6. 22. 23:19

 

이번 포스팅에서는 디스턴스 로그인 구현 중 사용자의 불편함을 덜기 위해 Refresh Token을 구현하면서 겪었던 문제에 대해 이야기를 해보겠습니다.

 

저는 다른 프로젝트에서는 JWT의 Access Token만을 사용해서 로그인 기능을 구현하였습니다.

물론 토큰의 만료시간은 하루(24시간)으로 두고  하였습니다.

 

물론 그 전의 프로젝트의 실제 유저 유입이 많이 없어서 이런 부분을 크게 신경쓰지 안하고 안일하게 생각을 했었습니다..

하지만 이번 디스턴스 프로젝트는 유저 유입이 많을거라고 생각하기도 했고, Access Token의 유효기간을 너무 오래잡으면 좋지 않다는 것을 알게 되었습니다.

제가 생각한 단점은

  1. Token이 탈취당하면 막을 방법이 없다.
  2. 그럼 유효기간을 짧게 잡아서 Token을 자주 변경한다.
  3. 그럼 사용자 관점에서 로그인을 자주 해야하는 번거로움이 발생한다

이러한 단계로 Refresh Token의 필요성을 느꼈고, 디스턴스 프로젝트에 적용해야 했다.

 

제가 생각한 방식은 Access Token의 만료 기간을 30초 정도로 짧게 잡고, 매 요청마다 Security Context 파일에서 Token의 유효성 검증을 통해 만약 유효기간이 만료가 되었다면 Refresh Token의 유효성을 검증하고, 유효하다면 Access Token을 발급해주는 방식을 생각했다.

 

하지만 여기서 문제가 생겼다.

public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.error(MALFORMED_JWT);
        } catch (ExpiredJwtException e) {
            log.error(EXPIRED_JWT);
        } catch (UnsupportedJwtException e) {
            log.error(UNSUPPORT_JWT);
        } catch (IllegalArgumentException e) {
            log.error(WRONG_JWT);
        }
        return false;
    }

위의 코드가 기존 AccessToken을 사용한 코드이다.

여기서 ExpiredJwtException을 통해 Token의 만료기간을 확인하고 있었다.

 

하지만 프론트에서 Access Token이 만료가 되었다는 걸 인지하고 Refresh를 통해 다시 Access Token을 재발급하는 API를 호출해주기 위해 구분이 필요하다고 생각했다.

 

그래서 생각을 한게 validateToken메소드에 인자로 String type을 추가해서 "ACCESS", "REFRESH"를 넘겨 구분을 지어주는 방식을 생각했고 아래와 같이 코드를 작성했다.

public boolean validateToken(String token,String type) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.error(MALFORMED_JWT);
        } catch (UnsupportedJwtException e) {
            log.error(UNSUPPORTED_JWT);
        } catch (IllegalArgumentException e) {
            log.error(WRONG_JWT);
        } catch (ExpiredJwtException e) {
            log.error(EXPIRED_JWT);
            if(type.equals("REFRESH")){
                log.info(token);
               if(refreshRepository.existsByRefreshToken(token)) {
                    refreshRepository.deleteByRefreshToken(token);
                    log.info("Refresh token delete success!!");
                } else {
                    log.info("No refresh token found to delete.");
                }
            }
        }
        return false;
    }

하지만 여기서 문제가 또 발생했다,,,,

ExpiredJwtException 이 에러를 프론트에서 잡아서 해결을 할 수가 없었고 난 내가 직접 커스텀한 DistanceException()을 적용해서

public boolean validateToken(String token,String type) {
       ...
        } catch (ExpiredJwtException e) {
            log.error(EXPIRED_JWT);
            if(type.equals("REFRESH")){
               ...
            }
            throw new DistanceException(ErrorCode.EXPIRED_JWT);
        }
        return false;
    }

프론트에서는 내가 발생시킨 DistanceException을 통해 다시 AccessToken을 발급받도록 하려고했다.

 

근데 어찌된건지 내가 커스텀한 에러를 잡지 못하고 계속 ExpiredJwtException을 터트렸다..

출처 :https://velog.io/@soyeon207/Spring-Filter-Interceptor-AOP

구조를 보니 내가 등록한 DistanceException은 Filter단이 아닌 Dispatcher Servlet이후부터 빈에 등록이 되기 때문에 
지금 제가 하려던 작업들은 모두 Filter단에서 작업이 이루어졌기 때문에 적용이 안됐다는 것을 알 수 있었습니다.

그래서 저는 DistanceException() 잡을 수 있는 Filter를 하나 더 만들어서 가장 앞단에 두어서 해결하는 방법을 생각하였습니다.

@Override
    public void configure(HttpSecurity http) {
        http
            .addFilterBefore(
                new JwtFilter(tokenProvider),
                UsernamePasswordAuthenticationFilter.class
            )
            .addFilterBefore(
                new ExceptionHandlerFilter(),
                JwtFilter.class
            );
    }
    
    public class ExceptionHandlerFilter extends OncePerRequestFilter {
    private static final String IN_CODING_TYPE = "UTF-8";
    @Override
    protected void doFilterInternal(
        HttpServletRequest request,
        HttpServletResponse response,
        FilterChain filterChain
    ) throws ServletException, IOException {
        try {
            filterChain.doFilter(request, response);
        } catch (DistanceException e) {
            setErrorResponse(response, e.getErrorCode());
        }
    }

    private void setErrorResponse(
        HttpServletResponse response,
        ErrorCode errorCode
    ) {
      ...
        }
    }

    @Data
    public static class ErrorResponse {

        private final Integer code;
        private final String message;
    }
}

위의 코드를 보면 addFilterBefore()를 통해 new ExceptionHandlerFilter를 가장 Filter 앞단에 두어서 DIstanceException을 catch문에서 잡아서 프론트로 ErrorResponse를 통해 반환해주는 Filter를 적용해주었다.

 

그래서 최종적으로 완성된 Filter는

 public boolean validateToken(String token,String type) {
        try {
        ...
        } catch (ExpiredJwtException e) {
            log.error(EXPIRED_JWT);
            if(type.equals("REFRESH")){
                log.info(token);
               if(refreshRepository.existsByRefreshToken(token)) {
                    refreshRepository.deleteByRefreshToken(token);
                    log.info("Refresh token delete success!!");
                } else {
                    log.info("No refresh token found to delete.");
                }
            }
            throw new DistanceException(ErrorCode.EXPIRED_JWT);
        }
        return false;
    }

이렇게 커스텀하는데 성공을 했고,

Postman Test

위와 같이 Access Token이 만료되면 원하는 Error로그가 나오는 것을 확인할 수 있었다..!

 

Refresh Token을 통한 AccessToken 재발급

또한 Refresh Token을 통해 AccessToken 또한 정상적으로 발급이 되는 것을 확인할 수 있다.

 

 

마무리 하며...

이번 기회로 Spring Boot가 어떻게 흘러가는지에 대해 알아보고, 구조가 어떻게 생긴지에 대해서 새롭게 알게 되었고, Filter와 DispatcherServlet의 차이에 대해 알게 되는 계기가 되었습니다.

무작정 구글링을 해서 하는거보단 직접 찾아보고 공부해보면서 더 재밌고 신기한 경험을 할 수 있었던 것 같습니다.

 

하지만 현재 Refresh Token을 DB에 저장하고 있는데 이 부분을 Redis를 사용해서 좀 더 안전하고 성능에 좋게 리팩토링을 진행하도록 해보겠습니다..!!