iamkanguk.dev

[맵필로그 + NestJS] Access-Token과 Refresh-Token 전략 정리와 수정해야 할 부분 in NestJS? (약간 장문주의) 본문

사이드 프로젝트/맵필로그

[맵필로그 + NestJS] Access-Token과 Refresh-Token 전략 정리와 수정해야 할 부분 in NestJS? (약간 장문주의)

iamkanguk 2024. 1. 10. 16:48

필자는 맵필로그라는 프로젝트에서 사용자 인증에서 JWT 토큰을 사용하기로 결정했다. 하지만 단순히 Access Token만 사용하는 것이 아닌 Refresh Token 사용을 도입을 하기로 결정했다. 각 개발자분들 마다 어떤식으로 구현을 하는지 각각 다르기 때문에 인터넷에서도 각기 다른 자료들을 볼 수 있다.

 

그래서 필자는 이번 포스팅에서는 해당 프로젝트에서 JWT를 왜 도입했는지, 어떤식으로 Access Token과 Refresh Token 전략을 사용했는지에 대해서 정리를 해보려고 한다.

 

그리고 지금부터 Access Token은 AT로, Refresh Token은 RT로 표기하는 점 참고해주세요! 또한, 이 포스팅에서는 JWT의 자세한 개념에 대해서는 언급을 하지 않을 예정이니 다른 포스팅으로 한번 확인하고 들어오시면 좋을 것 같아요!

 

1.  JWT 방식을 사용한 이유는?

필자는 이전 프로젝트에서도 JWT를 사용한 경험이 있다. 그래서 이번 프로젝트에서는 쿠키 + 세션 조합을 활용한 인증 방식을 적용해볼까 초기 프로젝트에서 고민을 했었다. 하지만 해당 프로젝트는 배포를 목적으로 진행을 하고 있기 때문에 다음과 같은 이유에서 JWT 방식을 선택하게 되었다.

 

  • 모바일 어플리케이션 서비스를 제공할 예정이기 때문에 세션 보다는 JWT가 적합하다고 판단했다. 실제로 몇몇 블로그 포스팅에서 모바일 앱에서는 세션 방식을 사용할 수는 있지만 잘 사용하지는 않는다는 글을 보았다. 그에 대한 이유를 간단하게 적어보면 웹과 정보 유지 방식(쿠키 관련)이 다르고, 앱은 사용자와 1대1 매칭이 되기 때문에 정보 탈취 가능성이 적기 때에 JWT를 사용해도 괜찮다고 판단했다.
  • 세션은 사용자가 늘어나면 늘어날수록 서버 메모리에 부하가 커질 수 있다. 우리는 배포를 해서 실제 운영을 할 목적이기 때문에 비용 측면에서 장기적으로 봤을 때는 토큰 방식이 유리할 것이라고 판단했다. (서버 사양이 낮기 때문 ㅠ)
  • RT 방식은 사용해 본 적이 없기 때문에 단순히 적용해보고 싶다는 호기심 때문에 적용한 것도 있긴 하다..!

 

2.  Refresh Token을 적용한 이유와 Refresh Token 개념 간단 정리?

과거 처음 프로젝트를 진행할 때는 AT의 유효기간을 365일로 그냥 편의상 설정하고 개발을 했었다. 당연히 이렇게 적용하면 안되는 걸 알지만 그랬었던 때가 있었다. 하지만 이번에는 실제로 배포를 목표로 하기 때문에 보안적인 측면도 어느정도 고려를 해야한다고 판단되었다.

 

만약 AT만 사용한다고 가정해보면 AT는 클라이언트 쪽에서 저장이 되고, 서버에서는 누가 토큰을 보낸지 모른채 토큰만 보고 검증을 하게 되는데 만약에 토큰이 외부 공격으로 탈취가 된다면 토큰이 만료되기 전까지 외부 공격자가 접근을 할 수 있다는 문제점이 있다.

 

그래서 AT는 결국 만료시간을 짧게 부여를 해야한다. 왜냐면 토큰이 제 3자에 의해 탈취가 되더라도 만료시간이 매우 짧기 때문에 해킹에 대해서 보호받을 수 있기 때문이다. 그래서 사용자는 계속해서 AT를 발급받아야 하는데 특별한 조치가 없다고 가정한다면 사용자 측면에서 비효율적이고 불편한 경험을 하게 될 것이다. 따라서 결국에는 토큰의 만료시간을 늘려서 사용자의 이용에 불편함이 없게 해야하는데 만료시간을 늘릴 수 있는 방법이 바로 RT이다.

 

그래서 간단하게 정리해보자면!

 

  • RT를 사용한 이유실제로 배포해야 하는 프로젝트이기 때문에 AT만 사용했을 때의 보안적인 문제점을 해결하고자 도입했다.
  • AT는 클라이언트가 갖고 있는 실제 유저의 정보가 담긴 토큰이다. 서버에서 "당신 누군지 명함을 보여줘!" 라고 요청을 할 수 있는데 이 때 필요한 토큰이라고 생각하면 된다.
  • RT는 새로운 AT를 발급받기 위해 사용하는 토큰이다. 그러면 잦은 로그인/로그아웃을 피할 수 있게 된다. 보통 서버측면(대표적으로는 DB나 Redis)에 저장한다. 그리고 Payload에는 아무것도 저장하지 않아도 되고 UUID나 사용자 정보를 담아도 무방하다고 한다.

 

3.  Mappilogue에서는 어떤식으로 AT와 RT를 생성하나요? (이제부터 핵심!)

이번 문단에서는 그냥 이 프로젝트에서는 이런식으로 토큰 인증 방식을 구현했구나~ 라고만 봐주시면 좋을 것 같다. 이후 문단에서 해당 프로젝트에서 어떤 부분을 수정해야 할지도 같이 정리할 예정이다.

 

필자는 NestJS를 사용해봤다고 가정하고 설명을 진행할 계획이다. 코드가 이쁘진 않지만.. 어느정도 이해는 하실 수 있을 것이라고 생각하고 설명을 진행해보겠다!  아래 코드는 사용자의 토큰을 설정해주는 함수이다. 로그인 또는 회원가입 직후, 토큰 재발급 시 해당 메서드를 호출한다고 생각하면 된다.

/**
  * @summary 사용자 토큰 설정
  * @author  Jason
  * @param   { number } userId
  * @returns { Promise<TokenDto> }
*/
async setUserToken(userId: number): Promise<TokenDto> {
	const accessToken = this.jwtHelper.generateAccessToken(userId);
	const refreshToken = this.jwtHelper.generateRefreshToken(userId);
	await this.jwtHelper.setRefreshTokenInRedis(userId, refreshToken);
	return TokenDto.from(accessToken, refreshToken);
}

/**
  * @summary Access-Token 생성하는 함수
  * @author  Jason
  * @param   { number } userId
  * @returns { string }
*/
  generateAccessToken(userId: number): string {
    return this.jwtService.sign(
      { userId },
      {
        secret: this.customConfigService.get<string>(
          ENVIRONMENT_KEY.ACCESS_SECRET_KEY,
        ),
        expiresIn: this.getAccessTokenExpireTime(),
        subject: TokenTypeEnum.ACCESS,
      },
    );
  }

  /**
   * @summary Refresh-Token 생성함수
   * @author  Jason
   * @param   { number } userId
   * @returns { string }
   */
  generateRefreshToken(userId: number): string {
    return this.jwtService.sign(
      { userId },
      {
        secret: this.customConfigService.get<string>(
          ENVIRONMENT_KEY.REFRESH_SECRET_KEY,
        ),
        expiresIn: this.getRefreshTokenExpireTime(),
        subject: TokenTypeEnum.REFRESH,
      },
    );
  }

  /**
   * @summary Access-Token 만료시간 가져오는 함수
   * @author  Jason
   * @returns { string }
   */
  getAccessTokenExpireTime(): string {
    return this.customConfigService.get<string>(ENVIRONMENT_KEY.NODE_ENV) ===
      'development'
      ? '30d'
      : '1h';
  }

  /**
   * @summary Refresh-Token 만료시간 가져오는 함수
   * @author  Jason
   * @returns { string }
   */
  getRefreshTokenExpireTime(): string {
    return this.customConfigService.get<string>(ENVIRONMENT_KEY.NODE_ENV) ===
      'development'
      ? '90d'
      : '14d';
  }

(1) 유효기간

위의 코드에서 setUserToken 메서드를 제외하고 쭉 봐주시면 좋을 것 같다!

 

결론부터 말하면 AT는 1시간, RT는 2주로 설정했다. 그리고 개발 단계에서는 AT를 30일, RT를 90일로 설정했다. 그리고 jwt-sign 할 때 AT와 RT에 대한 SECRET KEY를 각각 설정해주었고 subject로는 AT인지 RT인지 구별할 수 있는 구분자를 넣었다. 추가로 AT와 RT 모두 userId만 넣어주었다. AT에는 userId가 아닌 다른 부가적인 정보들도 넣을지는 아직 제대로 결정하지 않았다.

 

RT는 AT가 만료되었을 때 사용하는 토큰이기 때문에 만료시간을 길게 잡았다. 실제로 AT는 30분-1시간, RT는 보통 2주의 시간을 설정해준다고 해서 똑같이 따라가게 되었다.

(2) RT의 저장소는 DB? Redis? + 선택한 이유

결론을 먼저 말씀드리면 필자는 Redis를 선택하게 되었다. Redis를 선택한 이유는 key-value 형태로 데이터를 저장할 수 있으며 TTL을 적용해서 DB보다 간단하게 토큰을 관리할 수 있을 것이라고 판단되어서 Redis를 선택했다. TTL은 위의 유효기간으로 설정했다.

(3) 생성결과

 

setUserToken 메서드의 결과로 Response를 제공하면 다음과 같다. AT와 RT를 제공하는 것을 확인할 수 있다.

 

<지금까지 정리>

  • 만료시간은 AT는 1시간, RT는 2주
  • RT의 저장소는 Redis
  • setUserToken 메서드가 실행될 때의 Flow는 다음과 같다.
    • AT와 RT를 새로 생성한다. (만료시간을 적용해서)
    • 새로 생성된 RT는 Redis에 사용자PK를 key로 해서 저장해준다. (ex: refresh_userId_PK)
  • RT가 Redis에 저장되었으면 새로 생성된 AT와 RT를 쌍으로 DTO를 생성해서 Return 한다.

 

4.  토큰 재발급은 어떻게 진행되는지 설명해주세요!

코드는 조금 이쁘게.. vscode의 CodeSnap으로 찍었습니다.. 보기 좋으라구...

 

  • 토큰 재발급 API는 Body로 클라이언트에게 넘겨준 RT를 받는다.
  • 이전에 RT의 Payload에 사용자PK를 넣어주었다. RT를 Decode를 해서 사용자PK를 받아오고, 사용자PK를 가지고 DB에서 사용자를 조회해서 유효한 사용자인지 확인한다. 그리고 RT와 Payload 그리고 사용자 조회 결과를 가지고 RT가 유효한지 확인한다. 왜냐면 Malformed된 RT일수도 있을 가능성이 있기 때문이다. RT가 유효한지 확인하는 조건은 다음과 같다.
    • 사용자 조회결과가 있는지
    • RT의 Payload에 userId가 있는지 + subject로 RT가 맞는지? (아까 위에서 subject로 AT인지 RT인지 구분자를 넣어준다고 언급함)
    • 당시 시점에 Redis에 저장되어 있는 RT와 동일한지 확인
    • 위의 3개 조건에 전부 OK한다면 정상 유저로 간주한다.
  • 클라이언트로 받은 토큰이 유효하지 않은 RT라면 에러를 발생시켜주고 그렇지 않다면 위의 setUserToken 메서드를 통해 새롭게 AT와 RT를 발급 + RT는 Redis에 저장해준다 => RTR (Refresh-Token-Rotation) 도입

위의 Flow가 전체적인 토큰 재발급 로직이다. 잘못된 부분이 있을 수 있다고 생각되기는 하는데 최대한 많이 고민하고 분석한 결과는 다음과 같다는 점 양해 부탁드립니다..ㅎㅎ 언제나 날카로운 피드백은 환영입니다 상처 전혀 안받습니다!!

 

<2024-02-14 추가>

현재 로그아웃 및 회원탈퇴 할 때 Access-Token을 클라이언트 쪽에서 삭제는 해주지만, 만료 전이기 때문에 사용이 가능한 이슈가 있었다. 그래서 필자는 Redis를 사용해서 Access-Token을 BlackList에 추가하는 방식을 선택했다.

 

하지만 토큰 재발급 API에는 BlackList에 추가하는 방식은 도입하지 않았다. 왜냐하면 토큰 재발급 같은 경우에는 클라이언트에서 Access-Token의 만료시간이 임박한 경우에 API를 호출하는 방식이기 때문에 Access-Token이 거의 만료된 상태이기 때문에 굳이 BlackList에 추가하지 않아도 된다고 판단했다.

 

위와 관련된 내용은 해당 링크에서 확인할 수 있다.

 

5.  RTR (Refresh-Token-Rotation) 도입 (마지막이에용)

 

만약에 RT까지 탈취당했다고 가정해보자. 그러면 어떤 상황이 발생할 수 있을까? RT는 AT를 재발급 하는 용도로 사용되기 때문에 탈취되면 만료가 될 때 까지 공격자가 계속 사용할 수 있다. 문제는 stateless한 특징 때문에 서버는 토큰이 탈취된지도 모른다. 필자는 이런 경우 때문에 RTR을 도입하게 되었다.

 

Rotation 방법은 토큰을 재발급할 때 AT만 제공하는 것이 아니라 RT까지 같이 제공하는 것이다. 즉, RT를 딱 한 번만 사용할 수 있게 만드는 것이다. 이 방식을 사용하면 이미 사용된 RT를 사용하게 되면 서비스 측에서 탈취를 확인해서 조치할 수 있다.

 

여기서 이미 사용된 RT를 사용했는지 확인하는 것은 이전 문단의 "당시 시점에 Redis에 저장되어 있는 RT와 동일한지 확인" 부분이 될 수 있을 것 같다. 왜냐면 Redis에 저장되어 있는 RT는 가장 최근의 RT이기 때문이다.

 

6.  결론

지금까지 길다면 길고? 짧다면 짧...은? 맵필로그 프로젝트에서 어떤식으로 토큰 제도를 적용했는지 쭉 설명해봤다. 최대한 자세하게 설명해보려고 노력했는데 잘 작성한지는 잘 모르겠다.

 

필자는 이 포스팅을 정리해보면서 좀 수정을 해보거나 고려해보면 좋을 부분을 조금 찾아봤다.

(1) 클라이언트에게 RT를 Response로 제공하는 것이 맞는가?

필자가 Response로 제공한 이유는 모바일 애플리케이션과 협업이기 때문이라고 할 수 있다. 모바일에서는 사실 토큰을 탈취하기 쉽지 않기 때문에 이렇게 제공을 했다고 할 수 있다. 하지만 그래도..? 안전하지는 않지 않나? 이 부분에 있어서는 조금 더 대안을 찾아보던가 해야겠다.  웹 브라우저면 RT를 Cookie에 저장해서 보내주면 그나마 더 보안적인 측면을 가져갈 수 있다고 생각하지만 모바일에서는 잘 모르겠다..ㅎ

 

근데 지금 생각해보면 모바일에서는 Cookie가 안되나? 사용할 수 있을텐뎅~ 한번 더 알아보자!

(2) 토큰 재발급 API에서 Body로 RT를 받는게 맞는지?

이것도 1번과 연결될 수 있을 것 같다. 조금 더 생각해보자!

(3) RTR의 문제점은 어떻게 해결할까?

RTR의 문제점은 공격자가 정상 유저보다 먼저 RT를 Rotation 했을 경우에 발생한다. 기존의 AT와 RT는 새로운 토큰으로 갱신되고 결국 Redis에는 공격자가 재발급 받은 RT가 저장될 것이고, 정상유저는 AT를 재발급 받으려고 해도 정상유저가 가지고 있는 RT는 Redis에 존재하지 않기 때문에 재발급을 받을 수 없다.

 

물론 재로그인을 해서 RT를 받을 수는 있지만 해커는 기존에 탈취한 RT로 재발급을 받을 수 있기 때문에....! 이 부분에 있어서도 많은 고민을 해보자! 고민이 끝도 없다.

(4) RT 유효성 검사가 실패했을 경우에 대한 대처

위의 코드를 보면 아시겠지만 필자는 단순히 에러 발생을 했다. 하지만 RT 유효성 검사가 실패한 것은 물론 개발자가 잘못 로직을 작성한 것일 수도 있지만 공격자가 악의적인 Request를 보냈을 것이라고 판단할 수 있다. 따라서 단순히 에러를 뱉는 것 보다는 해당 유저를 로그아웃 처리를 해서 토큰을 전부 무효화처리를 해줄 수 있을 것 같다. 이 부분은 한번 고려해서 적용해보려고 한다.

 

추가로 방금 참고자료 부분에서 좋은 글귀를 확인했는데, RTR에서 결국 공격자가 새로운 AT를 들고 있을 수 있는데 이 AT에 대해서 BlackList 처리를 해도 괜찮을 것 같다는 것을 봤다. 이 부분도 한번 쫌 알아보고 적용해볼 수 있으면 해보자 ㅎㅎ 끝도없넹~

 

마지막으로 필자는 이 토큰전략에 있어서 많은 시간과 고민을 투자했다. 진짜 항상 문제점이 뭐가 있을까? 고민을 했던 것 같다.

나와 같이 많은 고민이 되는 개발자분들에게 조금이나마 도움이 되었으면 한다 :)


참고자료

 

- https://junior-datalist.tistory.com/352

 

Refresh Token Rotation 과 Redis로 토큰 탈취 시나리오 대응

I. 서론 JWT와 Session 비교 및 JWT의 장점 소개 II. 본론 Access Token과 Refresh Token의 도입 이유 Refresh Token 은 어떻게 Access Token의 재발급을 도와주는 걸까? Refresh Token Rotation Redis 저장 방식 변경 III. 결론

junior-datalist.tistory.com

 

필자가 어쩌다 보니 위의 글을 보게 되었는데 필자가 구현한 토큰 전략 방향성과 거의 동일해서 가져와봤다. 글을 너무 잘 써주셔서 일부분 조금 참고해서 이 포스팅을 잘 마무리 할 수 있었다. 혹시나 필자의 글이 마음에 들지 않는다면... 이 글을 꼭 참고해서 좋은 정보를 받아가실 수 있으면 좋겠습니다 :)