not-a-gardener 개발기 8) Refresh Token으로 Access Token 재발급 받기

메밀·2023년 7월 24일
0

not-a-gardener

목록 보기
8/13

1. refresh token은 왜 사용하는가?

1) 로그인 유지: 액세스 토큰은 일정 기간 동안만 유효, 사용자는 액세스 토큰이 만료돼도 refresh token으로 새로운 액세스 토큰을 발급받아 로그인 상태를 유지
2) 보안 강화: 액세스 토큰을 탈취당하더라도 장기적으로 액세스를 유지하는 것을 어렵게
3) 자동 갱신: 액세스 토큰이 만료되었을 때 자동으로 refresh token을 사용하여 새로운 액세스 토큰을 발급받아 사용자의 작업을 중단하지 X
4) 사용자 경험 향상: 유저가 로그인 과정을 반복하지 않아도 되므로 편리한 사용자 경험을 제공


2. refresh token을 사용한 access token 재발급

access token은 request 마다 서버로 전송된다. 이때 access token이 부적절하거나 만료되었을 경우, 나는 오류 메시지를 리턴한다. 대강의 코드는 다음과 같다.

// JwtFilter 클래스: 로그인 이후 토큰 검증 ⭐️
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String token = resolveToken(request);

        // 디코딩할만한 토큰이 왔으면
        if (token != null) {
            // header의 token로 token, key를 포함하는 새로운 JwtAuthToken 만들기
            AccessToken accessToken = tokenProvider.convertAuthToken(token);

            // boolean validate() -> getData(): claims or null
            // 정상 토큰이면 해당 토큰으로 Authentication을 가져와서 SecurityContext에 저장
            if (accessToken.validate()) {
                // UsernamePasswordAuthenticationToken(유저, authToken, 권한)
                // ⭐️⭐️⭐️⭐️⭐️
                Authentication authentication = tokenProvider.getAuthentication(accessToken);
                SecurityContextHolder.getContext().setAuthentication(authentication);
                log.debug("인증 성공");
            }
        }

        filterChain.doFilter(request, response);
    }



// tokenProvider.getAuthentication(accessToken) ⭐️
	@Override
    public Authentication getAuthentication(AccessToken authToken) {
        if (authToken.validate()) {
            // authToken에 담긴 데이터를 받아온다
            Claims claims = authToken.getData();

			// ⭐️⭐️⭐️⭐️⭐️
            UserPrincipal user = (UserPrincipal) userDetailsService.loadUserByUsername(claims.getSubject());

            // 권한 없으면 authenticate false => too many redirect 오류 발생
            // principal, credential, role 다 쓰는 생성자 써야 super.setAuthenticated(true); 호출됨!
            return new UsernamePasswordAuthenticationToken(
                    user,
                    null,
                    Collections.singleton(new SimpleGrantedAuthority("USER")));
        } else {
            throw new JwtException("token error!");
        }
    }



// userDetailsService.loadUserByUsername ⭐️
	@Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        ActiveGardener gardener = redisRepository.findById(Long.parseLong(username))
                .orElseThrow(() -> new UsernameNotFoundException(ExceptionCode.NO_TOKEN_IN_REDIS.getCode()));

        return new UserPrincipal(gardener.getGardenerId(), gardener.getName());
    }
    
// JwtExceptionFilter
@Component
@Slf4j
public class JwtExceptionFilter extends OncePerRequestFilter {
    private final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        try {
            filterChain.doFilter(request, response); // go to 'JwtAuthenticationFilter'
        } catch (ExpiredJwtException e) {
            setErrorResponse(response, ErrorResponse.from(ExceptionCode.ACCESS_TOKEN_EXPIRED));
        } catch (MalformedJwtException e) {
            log.info("Invalid JWT token");
            setErrorResponse(response, ErrorResponse.from(ExceptionCode.INVALID_JWT_TOKEN));
        } catch (JwtException | SecurityException | IllegalArgumentException e){
            setErrorResponse(response, ErrorResponse.from(ExceptionCode.INVALID_JWT_TOKEN));
        }
    }

    public void setErrorResponse(HttpServletResponse response, ErrorResponse errorResponse) throws IOException {
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.setContentType("application/json; charset=UTF-8");

        response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
    }
}

이 흐름에서 access token 만료 메시지를 받은 경우, refresh token을 사용해 access token을 재발급 받는다. 이를 위해 나는 axios interceptor를 사용했다.

2) interceptor.js

// axios 인스턴스 생성 ... 생략

// access token 재발급
const reissueAccessToken = async () => {
  const data = {
    gardenerId: localStorage.getItem("gardenerId"),
    refreshToken: localStorage.getItem("refreshToken")
  };

  const response = await axios.post(`${process.env.REACT_APP_API_URL}/token`, data)
  
  return response.data;
}

authAxios.interceptors.response.use(
  (response) => response,
  // error 발생 시
  async (error) => {
    const errorCode = error.response.data.code;

    if (errorCode === "B001") {
      // 1) 거절당한 request 저장 ⭐️
      const originRequest = error?.config;

      // 2) refresh token으로 access token 재발급
      console.log("토큰 만료 -> reissue access token");
      const accessToken = await reissueAccessToken();
      localStorage.setItem("accessToken", accessToken);

      // 3) 진행 중인 요청 이어하기 
      return authAxios({
        ...originRequest,
        headers: {...originRequest.headers, Authorization: `Bearer ${accessToken}`},
        sent: true
      })

    return Promise.reject(error.response.data);
  }
);

export default authAxios;

3) AuthenticationService.refreshAccessToken

access token 재발급 로직이 담긴 서비스의 메소드다.

@Override
    public GardenerDto.Token refreshAccessToken(GardenerDto.Refresh token) {
        // 클라이언트가 전달한 refresh token
        String reqRefreshToken = token.getRefreshToken();
        
        // redis의 refresh token
        ActiveGardener activeGardener = redisRepository.findById(token.getGardenerId())
                .orElseThrow(NoSuchElementException::new);
        RefreshToken savedRefreshToken = activeGardener.getRefreshToken();

		// refresh token 검증
        if (!reqRefreshToken.equals(savedRefreshToken.getToken())) {
            // redis에 사용자 정보 없음 -- B009
            throw new BadCredentialsException(ExceptionCode.NO_TOKEN_IN_REDIS.getCode());
        } else if (savedRefreshToken.getExpiredAt().isBefore(LocalDateTime.now())) {
            // refresh token 만료 -- B002
            throw new BadCredentialsException(ExceptionCode.REFRESH_TOKEN_EXPIRED.getCode());
        }

        // 새 access token 만들기
        AccessToken accessToken = tokenProvider.createAccessToken(activeGardener.getGardenerId(), activeGardener.getName());

        return new GardenerDto.Token(accessToken.getToken(), null);
    }

이렇게 다시 전달된 새로운 access token을 사용하여 클라이언트가 직전 요청을 다시 요청한다.
만일 refresh token도 만료되었다면, 사용자는 로그아웃된다.

0개의 댓글