내배캠 주요 학습/TIL : Today I Learned

Access Token 만료시 재요청 없이 재발급

chaeyoung- 2023. 8. 25. 10:59

 

 

Access Token과 Refresh Token을 처리할 때 문제점을 발견했습니다.

 

로직을 요약하면,

  1. 클라이언트의 로그인 요청
  2. 서버에서 Access Token과 Refresh Token 생성 및 반환 (Refresh Token Redis에 저장)
  3. 클라이언트에서 Access Token과 Refresh Token을 쿠키에 저장 (HttpOnly설정)

이와 같습니다.

 

먼저, Access Token이 만료되기 이전에 요청은 Spring Security Filter Chain을 잘 거쳐 

Controller 단에 도달하여 해당 요청(재발급)을 잘 처리합니다.

 

 

문제는 만료된 Access Token 으로 요청시 Controller 단에 도달조차 못 한다는 것입니다.

이는 Filter에서 토큰 검증을 거치기 때문에 검증 실패 시 에러를 반환하기 때문입니다.

 

 

 

 

Access Token 만료시, 재요청 과정 개선 방안

이를 해결하기 위해 Filter 단에서 처리 로직을 추가하였습니다.

 

 

 

1. 매 요청마다 Access Token의 유효성 검사를 거칩니다.

 

OncePerRequestFilter 를 상속한 Filter class 내에 doFilterInternal() 메서드 에서 토큰 검증 코드를 추가합니다.

  1. 요청에 따른 헤더에서 토큰을 모두 추출합니다. (Decode 필요)
  2. 추출한 토큰에서 "Bearer "을 삭제합니다.
  3. 해당 토큰을 통해 유효성 검사를 거칩니다.
    • Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException, IOException {
        String AccesstokenValue = jwtUtil.getAccessTokenFromRequest(request);
        String refreshTokenValue = jwtUtil.getRefreshTokenFromRequest(request);

        if (StringUtils.hasText(AccesstokenValue)) {
            // JWT 토큰 substring
            AccesstokenValue = jwtUtil.substringToken(AccesstokenValue);
            refreshTokenValue = jwtUtil.substringToken(refreshTokenValue);

            if (!jwtUtil.validateToken(AccesstokenValue)) {
                log.error("Access Token does not valid.");
                
                --중략--
  public String getRefreshTokenFromRequest(HttpServletRequest req) {
    Cookie[] cookies = req.getCookies();
    if(cookies != null) {
      for (Cookie cookie : cookies) {
        if (cookie.getName().equals(REFRESH_HEADER)) {
          return URLDecoder.decode(cookie.getValue(), StandardCharsets.UTF_8); // Encode 되어 넘어간 Value 다시 Decode
        }
      }
    }
    return null;
  }
  public String substringToken(String tokenValue) {
    if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) {
      return tokenValue.substring(7);
    }
    logger.error("Not Found Token");
    throw new NullPointerException("Not Found Token");
  }
  public boolean validateToken(String token) {
    try {
      Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
      return true;
    } catch (SecurityException | MalformedJwtException | SignatureException e) {
      logger.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.");
    } catch (ExpiredJwtException e) {
      logger.error("Expired JWT token, 만료된 JWT token 입니다.");
    } catch (UnsupportedJwtException e) {
      logger.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.");
    } catch (IllegalArgumentException e) {
      logger.error("JWT claims is empty, 잘못된 JWT 토큰 입니다.");
    }
    return false;
  }

 

 

 

2. Access Token 만료 시, Refresh Token의 유효성 검사를 거친 후 토큰 재발급

 

 

redisServiceImpl 클래스의 generateAccessToken() 메서드를 통해 유효성 검사를 합니다.

  1. 요청에 따른 헤더에서 refresh token을 추출합니다. (Decode 필요)
  2. 추출한 토큰에서 "Bearer "을 삭제합니다.
  3. 해당 토큰을 통해 유효성 검사를 거칩니다.

1. Refresh Token이 만료되었거나 없을 경우에는 에러를 반환합니다.

2. Refresh Token이 유효하다면, Access Token을 재발급합니다.

redisService.generateAccessToken(request, response);
response.sendRedirect("/");
  @Override
  public Boolean generateAccessToken(HttpServletRequest request, HttpServletResponse response) {
    // 클라이언트 쿠키에서 refresh token 추출
    String InputRefreshToken = jwtUtil.getRefreshTokenFromRequest(request);
    String InputRefreshTokenValue = jwtUtil.substringToken(InputRefreshToken);

    // refresh token 없을 경우 예외 처리
    if (!StringUtils.hasText(InputRefreshToken)) {
      log.error("RefreshToken is null. please login");
      return false;
      //throw new IllegalArgumentException("RefreshToken is null. please login");
    }

    // refresh token 유효성 검사 불일치
    if (!jwtUtil.validateToken(InputRefreshTokenValue)) {
      log.error("Refresh Token does not valid.");
      jwtUtil.deleteCookie(request, response);
      return false;
    }

    // 유저 정보 추출
    Claims claims = jwtUtil.getUserInfoFromToken(InputRefreshTokenValue);
    String username = claims.getSubject();
    UserRoleEnum role = jwtUtil.getUserRole(claims);

    // Redis 의 리프레시 토큰과 일치 여부 판단
    RefreshToken refreshToken = refreshTokenRepository.findByUsername(username).get();
    if (InputRefreshToken.equals(refreshToken.getRefreshToken())) {
      // 엑세스 토큰 생성
      createAccessToken(response, username, role);
      return true;
    }
    return false;
  }