[TIG] Refresh Token을 이용한 Access Token 재발급

JEONG KI MIN·2024년 7월 24일

TIG

목록 보기
3/12

Access Token 확인

우리 서비스에서는 다른 서비스들도 그러하듯이 JWT 방식의 access token(AT)와 refresh token을 통해 사용자를 관리한다.
따라서 다음의 로직을 사용한다.

@Override
protected void doFilterInternal(HttpServletRequest servletRequest,
                                HttpServletResponse servletResponse,
                                FilterChain filterChain) throws ServletException, IOException {
    try {
        String jwt = resolveToken(servletRequest);
        //만약 토큰에 이상이 있다면 오류가 발생한다.
        if (StringUtils.hasText(jwt) && tokenProvider.validateAccessToken(jwt)) {
            //tokenProvider에서 jwt를 가져가 Authentication 객체생성
            Authentication authentication = this.tokenProvider.getAuthentication(jwt);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        //이상이 없다면 계속 진행
        filterChain.doFilter(servletRequest, servletResponse);
    } catch (JwtException e) {
        //토큰에 오류가 있다면 401에러를 응답한다.
        log.error("[JWTExceptionHandlerFilter] "+e.getMessage());
        servletResponse.setStatus(401);
        servletResponse.setContentType("application/json;charset=UTF-8");
    }
}

해당 메서드는 OncePerRequestFilter 클래스를 extend 한 클래스 내부에 있다. 따라서 매번 요청이 있을때마다 토큰을 확인하여 이를 검증한다.

RT를 사용한 AT 재발급

refresh token의 존재 이유는 access token을 재발급 받기 위해서이다. 원래 AT의 만료시간은 30분~1시간으로 짧으므로 RT를 통해 이를 재발급 받을 수 있어야 한다. 따라서 다음의 코드를 사용했다.

public RefreshTokenResponseDto reissueAccessToken(Member member, String refreshToken) {
    // TODO : 레디스 반영시 access token 을 블랙리스트에 넣는? && 기존의 RT는 만료시켜야하거나 폐기
    String uniqueId = tokenProvider.getUniqueId(refreshToken);
    if (member.getUniqueId().equals(uniqueId)) {
        String accessToken = tokenProvider.createAccessToken(member.getName(), member.getUniqueId());
        String newRefreshToken = tokenProvider.createRefreshToken(member.getName(), member.getUniqueId());
        member.updateRefreshToken(newRefreshToken);
        Member member = getMemberByUniqueId(uniqueId);

        return RefreshTokenResponseDto.fromRefreshToken(accessToken, newRefreshToken);
    } else {
        throw new BusinessExceptionHandler("사용자와 토큰이 일치하지 않음", ErrorCode.BAD_REQUEST_ERROR);
    }
    return RefreshTokenResponseDto.fromRefreshToken(accessToken, newRefreshToken);
}

단순히 사용자가 refresh token값을 담아서 요청하면 해당 토큰으로부터 uniqueId(우리 서비스에서 사용하는 고유 아이디) 를 추출하고 사용자를 찾은 다음, 사용자와 토큰이 일치한다면 AT와 RT를 모두 재발급해주는 로직이다.

문제점

생각해보면, 사용자의 AT는 이미 만료가 돼서 더이상 쓸 수 없는 상태이다. 따라서 refresh token 발급 요청을 보내더라도 위의 OncePerRequest 클래스를 extend한 코드에 의해 AT는 만료되었다는 에러를 발생시킨다. 이를 해결하기 위한 방법은 두개가 있었다.

  1. OncePerRequest 를 extend 한 클래스 내에서 토큰의 유무를 확인 하는 방법.
  2. refresh token을 사용한 재발급 로직은 OncePerRequest 를 그냥 통과하도록 하는 방법.

결론적으로 2번의 방법을 사용했다.

첫번째 방법

@Override
protected void doFilterInternal(HttpServletRequest servletRequest,
                                HttpServletResponse servletResponse,
                                FilterChain filterChain) throws ServletException, IOException {
    try {
        String jwt = resolveToken(servletRequest);

        // 액세스 토큰이 있는 경우
        if (StringUtils.hasText(jwt) && tokenProvider.validateAccessToken(jwt)) {
            // 유효한 액세스 토큰인 경우 Authentication 객체 생성
            Authentication authentication = this.tokenProvider.getAuthentication(jwt);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            filterChain.doFilter(servletRequest, servletResponse);
        } else {
            // 액세스 토큰이 없거나 만료된 경우 리프레시 토큰 처리
            String refreshToken = getRefreshTokenFromCookie(servletRequest);
            if (StringUtils.hasText(refreshToken) && tokenProvider.validateRefreshToken(refreshToken)) {
                // 유효한 리프레시 토큰인 경우 새 액세스 토큰 발급
                RefreshTokenResponseDto newTokens = memberService.reissueAccessToken(refreshToken);
                
                // 새 리프레시 토큰을 쿠키에 설정
                ResponseCookie refreshTokenCookie = ResponseCookie.from("refreshToken", newTokens.getRefreshToken())
                        .httpOnly(true)
                        .path("/")
                        .secure(true) // HTTPS를 사용할 경우에만 true로 설정
                        .maxAge(14 * 24 * 60 * 60) // 2주
                        .sameSite("None")
                        .build();
                servletResponse.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString());

                // 새 액세스 토큰을 응답 본문에 설정
                servletResponse.setContentType("application/json;charset=UTF-8");
                servletResponse.getWriter().write("{\"accessToken\": \"" + newTokens.getAccessToken() + "\"}");
                servletResponse.setStatus(HttpServletResponse.SC_OK);
            } else {
                // 리프레시 토큰도 유효하지 않은 경우 401 응답
                throw new JwtException("Invalid refresh token");
            }
        }
    } catch (JwtException e) {
        // 토큰에 오류가 있다면 401 에러를 응답
        log.error("[JWTExceptionHandlerFilter] " + e.getMessage());
        servletResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        servletResponse.setContentType("application/json;charset=UTF-8");
        servletResponse.getWriter().write("{\"error\": \"" + e.getMessage() + "\"}");
    }
}

요청이 들어왔을때 jwt 토큰이 없다면 바로 재발급해주는 로직을 추가하는 것이다. 이렇게 되면 프론트에서 만료되었다는 에러 메세지를 통해 다시 재발급 요청을 보낼 필요 없이 백엔드에서 바로 처리할 수 있게 된다.
실제로 이 방법을 사용하고 싶었으나, 프론트 상의 코드 변화가 클 것 같아 두번째 방법을 쓰기로 했다.

두번째 방법

@Transactional
public RefreshTokenResponseDto reissueAccessToken(String refreshToken) {
    // TODO : 레디스 반영시 access token 을 블랙리스트에 넣는? && 기존의 RT는 만료시켜야하거나 폐기
    String uniqueId = tokenProvider.getUniqueId(refreshToken);
    Member member = getMemberByUniqueId(uniqueId);

    String accessToken = tokenProvider.createAccessToken(member.getName(), member.getUniqueId());
    String newRefreshToken = tokenProvider.createRefreshToken(member.getName(), member.getUniqueId());
    member.updateRefreshToken(newRefreshToken);

    // 새로운 액세스 토큰으로 인증 객체 생성 및 설정
    Authentication authentication = tokenProvider.getAuthentication(accessToken);
    SecurityContextHolder.getContext().setAuthentication(authentication);

    return RefreshTokenResponseDto.fromRefreshToken(accessToken, newRefreshToken);
}

일단 사용자의 AT를 받지 않는다. 당연히 만료 되었으므로 쓸 수가 없다. 그래서 기존의 RT만으로 재발급을 진행했다.
그런데 얘도 request 인데 OncePerRequest 를 어떻게 피할까?
다음과 같이 편법을 썼다.

private static final List<String> WHITELIST = Arrays.asList(
        "/api/v1/member/reissue"
);

@Override
protected void doFilterInternal(HttpServletRequest servletRequest,
                                HttpServletResponse servletResponse,
                                FilterChain filterChain) throws ServletException, IOException {
    String requestURI = servletRequest.getRequestURI();

    // 화이트리스트에 포함된 요청은 필터를 통과시키지 않고 계속 진행
    if (WHITELIST.contains(requestURI)) {
        System.out.println("requestURI = " + requestURI);
        filterChain.doFilter(servletRequest, servletResponse);
        return;
    }
    try {
        String jwt = resolveToken(servletRequest);
        //만약 토큰에 이상이 있다면 오류가 발생한다.
        if (StringUtils.hasText(jwt) && tokenProvider.validateAccessToken(jwt)) {
            //tokenProvider에서 jwt를 가져가 Authentication 객체생성
            Authentication authentication = this.tokenProvider.getAuthentication(jwt);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        //이상이 없다면 계속 진행
        filterChain.doFilter(servletRequest, servletResponse);
    } catch (JwtException e) {
        //토큰에 오류가 있다면 401에러를 응답한다.
        log.error("[JWTExceptionHandlerFilter] "+e.getMessage());
        servletResponse.setStatus(401);
        servletResponse.setContentType("application/json;charset=UTF-8");
    }
}

이렇게 WHITE_LIST 를 통해 AT가 없어도 통과시켜줄 엔드포인트들을 정의해두고 통과하도록 사용했다.

결론

사실 1번의 방법이 제대로 된 방법인 것 같지만 프로젝트가 진행되는데 방해가 되면 안되므로 2번 방법도 괜찮은 방법 인 것 같다.
두가지 방법을 모두 알아두는게 좋을 듯 하다.

profile
열심히 해볼게요

0개의 댓글