본문 바로가기

카테고리 없음

Refresh Token 구현하기

1. JWT토큰의 문제점 

 

현재 본인의 프로젝트에는 인증, 인가 처리를 JWT의 accessToken만을 사용해서 하고 있다. 하지만 accessToken에 만료기간을 설정하지 않고 그냥 사용하고 있는데

 

이는 본안적으로 굉장히 치명적일 수 없다. 토큰을 획득한 사람은 누구나 접근 권한이 생기기 때문이다. JWT는 발급한 후 삭제가 불가능하기 떄문에 일반적으로 토큰에 유효시간을 부여하는 식으로 탈취 문제에 대해서 대응을 한다.  

 

토큰의 유효기간을 짧게하면 토큰 남용을 방지하는 것의 해결책이 될 수 있지만 유효기간이 짧은 Token의 경우에 클라이언트가 그 만큼 로그인을 자주 해서 새롭게 Token을 발급받아야 하므로 불편하다는 단점이 있다. 그렇다고 토큰의 유효기간을 늘리면 토큰을 탈취당했을 때 보안에 취약해지게 된다. 

 

그러면 유효기간을 짧게 하면서 뭔가 방법이 있지 않을까 하는 것이 Refresh Token이다.

 

 

2. Refresh Token이란 

Refresh Token 또한 사실 AccessToken과 똑같은 JWT이다. 단지 AccessToken은 실제로 인가에 관여하는 토큰이고 Refresh Token은 AccessToken을 재발행 하기 위한 토큰이므로 역할이 아예 다르다고 할 수 있다. 

 

예를 들어서 로그인을 하게 되면 서버는 로그인에 대한 결과 값으로 AccessToken과 RefreshToken을 발급하게 된다. Refresh Token은 서버의 DB에 저장을 시키고 클라이언트는 AccessToken과 Refresh Token을 쿠키, 로컬 스토리지 , 세션에 저장을 하고 요청이 있을 떄 이 둘을 보낸다. 

 

AccessToken은 header에 담아서 보내고 Refresh Token은 일반적으로 파라미터나 RequestBody로 보낸다

 

그림은 뭔가 복잡해 보이지만 별로 어려운 개념은 아니다. 

로그인 성공(AccessToken, RefreshToken발급, DB에 저장) -> AccessToken의 유효기간이 만료됨 ->

 

서버로 AccessToken의 재발급을 위해 RefreshToken을 전송 ->

 

RefreshToken의 유효기간이 아직 남아 있다면 AccessToken을 발급

RefreshToken의 유효기간이 만료되면 Error를 전송하고 해당 클라이언트에서 로그아웃을 시킴

 

3. Refresh Token 구현하기

SetInterval을 사용하는 것은 일반적으로 권하지 않는 다고 한다. AccessToken의 만료기한을 서버에서 확인을 한 뒤 만료가 됬을 경우 예외를 발생시켜서 다시 AccessToken을 발행하는 식으로 해야 한다고 한다. (다시  정리해서 올리겠습니다.)

 

 

 

가볍게 리프레쉬 토큰을 어떤식으로 하는 지 알아보기 위해서 실험 하는 용도로만 해보시길 바랍니다.   

 

 

본인은 Spring과 React를 사용했다. 

 

JwtUtil.class

private final Algorithm algorithm;

    public JwtUtil(String secret) {
        this.algorithm = Algorithm.HMAC256(secret);
    }
	
    // AccessToken 생성로직
    public String encode(Long userId) {
        long now = System.currentTimeMillis();
        // 유효기간은 30분으로 설정 해 놓았다. 
        long accessTokenExpirationTime = 30 * 60 * 1000;

        return JWT.create()
            .withClaim("userId", userId)
            .withIssuedAt(new Date(now))
            .withExpiresAt(new Date(now + accessTokenExpirationTime))
            .sign(algorithm);
    }

	// RefreshToken 생성로직
    public String encodeRefreshToken(Long userId) {
        long now = System.currentTimeMillis();
        // RefreshToken의 만료기한은 2주로 해놓았다(일반적으로 2주로 많이 사용한다고한다)
        long refreshTokenExpirationTime = 14L * 24 * 60 * 60 * 1000;

        return JWT.create()
            .withClaim("userId", userId)
            .withIssuedAt(new Date(now))
            .withExpiresAt(new Date(now + refreshTokenExpirationTime))
            .sign(algorithm);
    }

    public Long decode(String token) {
        JWTVerifier verifier = JWT.require(algorithm).build();
        DecodedJWT verify = verifier.verify(token);

        return verify.getClaim("userId").asLong();
    }

    public Long decodeRefreshToken(String token) {
        JWTVerifier verifier = JWT.require(algorithm).build();
        DecodedJWT verify = verifier.verify(token);

        return verify.getClaim("userId").asLong();
    }

 

Login에 성공했을 때 AccessToken과 RefreshToken을 발급해야 한다. 

 

LoginService.class

   public LoginResultDto login(String identifier, String password) {
        User user = userRepository.findByIdentifier(identifier)
            .orElseThrow(LoginFailed::new);

        if (!user.authenticate(passwordEncoder, password)) {
            throw new LoginFailed();
        }
		
        // AccessToken생성
        String accessToken = jwtUtil.encode(user.id());
        
        // RefreshToken 생성
        String refreshToken = jwtUtil.encodeRefreshToken(user.id());

        LocalDateTime now = LocalDateTime.now();
        LocalDateTime expirationTime = now.plusDays(14);
        
        // RefreshToken의 만료기한
        Date expirationDate = Date.from(expirationTime.atZone(ZoneId.systemDefault()).toInstant());
		
        // RefreshToken을 DB에 저장해야하기 때문에 Entity로 생성 
        RefreshToken refreshTokenEntity = new RefreshToken(user.id(), refreshToken, expirationDate);
		
        // RefreshToken을 DB에 저장 
        refreshTokenRepository.save(refreshTokenEntity);

        return new LoginResultDto(
            accessToken, refreshToken,  user.name(), user.phoneNumber(), user.address().toDto()
        );
    }

 

 

Login에 성공했을 경우 RefreshToken과 AccessToken을 발급해주는 로직을 만들었다. 

다음으로 해야할 것은 AccessToken을 재발급 해주는 서비스 만들기

 

RefreshAccessTokenService.class

    public AccessTokenDto refreshAccessToken(String refreshToken) {
        RefreshToken refreshTokenEntity = refreshTokenRepository.findByRefreshToken(refreshToken)
            .orElseThrow(RefreshTokenNotFoundException::new);
		
        // 리프레쉬 토큰이 만료되었을 경우 해당 리프레쉬 토큰을 삭제하고 클라이언트에게 예외 처리 해준다. 
        if(refreshTokenEntity.isExpired()) {
            refreshTokenRepository.delete(refreshTokenEntity);
            throw new RefreshTokenExpiredException();
        }

        User user = userRepository.findById(refreshTokenEntity.userId())
            .orElseThrow(UserNotFoundException::new);

        String accessToken = jwtUtil.encode(user.id());

        return new AccessTokenDto(accessToken);
    }

 

RefreshToken의 isExpired 메소드는 현재 날짜에서 field에 있는 expirationDate의 값을 비교해서 날짜가 지났으면 true를 반환하고 아직 기한이 남아있으면 false를 반환하게 만들었다. 

 

    public boolean isExpired() {
        LocalDateTime now = LocalDateTime.now();
        LocalDateTime expirationTime = LocalDateTime.ofInstant(
            this.expirationDate.toInstant(),
            ZoneId.systemDefault()
        );
        return now.isAfter(expirationTime);
    }

백엔드 쪽은 완료 됬고 이제 프론트 엔드 작업을 해야한다. 

 

 

React ApiService.js

 

 async refreshAccessToken() {
    try {
      const accessToken = await userApiService.refreshAccessToken();

      const [setAccessToken] = useLocalStorage('accessToken', '');

      setAccessToken(accessToken);

      const accessTokenRenewInterval = 180000;

      // AccessToken갱신주기 타이머 설정
      const accessTokenRenewTimer = setInterval(this.refreshAccessToken, accessTokenRenewInterval);

      // 페이지가 언로드될 때 타이머 해제  // 로그인 인증 기한이 만료되었습니다
      window.addEventListener('upload', () => {
        clearInterval(accessTokenRenewTimer);
      });
    } catch (e) {
      const error = e.response.data;
      this.refreshAccessTokenErrorMessage = error.message;

      if (this.refreshAccessTokenErrorMessage === '로그인 인증 기간이 만료되었습니다') {
        const navigate = useNavigate();
        const [setAccessToken] = useLocalStorage('accessToken', '');
        const [setRefreshToken] = useLocalStorage('refreshToken', '');

        apiService.setAccessToken('');
        apiService.setRefreshToken('');
        setAccessToken('');
        setRefreshToken('');

        if (confirm('로그인 기한이 만료되었습니다. \n 다시 로그인 하시겠습니까?')) {
          navigate('/login');
        }
      }

      this.publish();
    }
  }

 

setInterval을 사용해서 30분마다 해당 함수를 호출하게 하고 errorMessage가 일치하면 LocalStorage에 저장되어 있는 accessToken과 RefreshToken을 없애 버리고 다시 로그인을 하게 만들었다.