[NestJS] (+보완) Remove refresh-token data in DB if token has expired (using Task-Scheduling)

DatQueue·2023년 5월 9일
1
post-thumbnail

시작하기에 앞서

이번 포스팅은 온전히 이전 포스팅 "How to implement Refresh-Token using JWT"을 전제로 이어나간다. (아래 링크 참조)


이전 포스팅 (How to implement Refresh-Token using JWT) ✔


물론 더 깊게 들어가면 수정하고 보완해야할 부분이 끝도 없지만 이전 포스팅의 내용에서 기본적인 Refresh-Token 구축에 따른 필요 기능을 구현해보았다.

일반적으로, 클라이언트에서 "로그아웃"을 요청하게 되면 데이터베이스 유저 테이블에 저장된 currentRefreshToken(현재 refresh 토큰값)currentRefreshTokenExp(refresh 토큰의 만료 시간)null로 수정하게끔 하였다. 로그아웃이 된 이상, refresh_token값을 데이터베이스 내에 지니고 있으면 안되기 때문이다.

즉, 우린 이에 따라 아래와 같이 로그아웃 시에, removeRefreshToken() 메서드를 호출해 데이터베이스의 토큰 값을 제거해 null로 만들어 줄 수 있었다.

  @Post('logout')
  @UseGuards(JwtRefreshGuard)
  async logout(@Req() req: any, @Res() res: Response): Promise<any> {
    await this.userService.removeRefreshToken(req.user.id);
    res.clearCookie('access_token');
    res.clearCookie('refresh_token');
    return res.send({
      message: 'logout success'
    });
  }
  
  // ------------------
  
    async removeRefreshToken(userId: number): Promise<any> {
    return await this.userRepository.update(userId, {
      currentRefreshToken: null,
      currentRefreshTokenExp: null,
    });
  }

하지만! 한 가지 처리해주지 않은 부분이 있다.

이렇게 직접적 로그아웃 요청이 아닌, Refresh-Token만료되었을시에는 어떻게 데이터베이스의 해당 토큰 값을 제거해 줄 수 있을까?

refresh-token이 만료가 되면, 클라이언트 역시 jwt 토큰 정보를 알고 있으므로 로그아웃 요청을 보낼 수 있다. 또한, 쿠키 제거와 같은 작업 또한 클라이언트가 처리할 수 있을 것이다.

하지만, api 요청없이 데이터베이스에 접근해 일련의 동작을 수행하는 기능은 서버측에서 자체적으로 처리해주는 것이 바람직하다. 이번 포스팅에선 해당 내용을 다뤄보고자 한다.

(즉, 이전 포스팅의 코드대로 실행한다면 refresh-token이 만료되었음에도 불구하고 여전히 데이터베이스 내부에 해당 토큰 값이 남아있게 됩니다)


💥 Refresh-Token 만료 시 해당 데이터 제거하기

가지 방법을 통해 알아보고자 한다.

> Task Scheduling을 통하여 주기적 수정(갱신)

첫 번째는 "Task Scheduling"을 활용한 방법이다.

Task Scheduling을 사용하면 고정된 날짜/시간, 반복 주기 또는 지정된 주기 후에 특정 코드(메서드/함수)가 실행하게끔 예약할 수 있다.

간단히 말해서 특정시간에 또는 정기적으로 실행해야 하는 비즈니스 로직(Task)을 일련의 조건 및 정의(Trigger)를 기반으로 실행(Scheduling)하는 것이다.

이를 통해 굳이 컨트롤러 레이어에 접근하지 않고도 서비스 레이어에서 반복된 작업을 수행할 수 있게 된다.

nestjs에선 패키지를 통해 불러온 ScheduleModule@Cron() 데코레이터를 사용하여 이를 구현할 수 있다.


✔ 토큰이 만료된 유저 찾기

user 테이블 내의 currentRefreshToken에 접근해 해당 만료시간과 현재시간(currentTime)을 비교하는 쿼리를 작성함으로써 만료된 토큰을 지닌 유저 객체를 얻을 수 있다.

// user.service.ts

  async findUsersWithExpiredTokens(currentTime: number): Promise<User[]> {
    const queryBuilder = this.userRepository.createQueryBuilder('user');
    const usersWithExpiredTokens = await queryBuilder
      .where('user.currentRefreshTokenExp <= :currentTime', { currentTime: new Date(currentTime) })
      .getMany();
    return usersWithExpiredTokens;
  }

✔ Scheduling 구현하기

  @Cron(CronExpression.EVERY_MINUTE) 
  async removeExpiredTokens() {
    const currentTime = new Date().getTime();
    const usersWithExpiredTokens = await this.userService.findUsersWithExpiredTokens(currentTime);
    console.log(usersWithExpiredTokens);
    for (const user of usersWithExpiredTokens) {
      if (user.currentRefreshToken) {
        await this.userService.removeRefreshToken(user.id); 
      }
    }
  }

실제로 refresh_token 의 데이터에 접근하는데, 1분마다 (CronExpression.EVERY_MINUTE) 접근하게되면 상당히 부하가 많이 갈 것이다. 현재는, 단순 테스트 결과를 확인하기 위해 위와 같은 주기(매 분마다)를 설정하였다.

참고로 refresh_token의 유효 기간또한 5분으로 설정된 상태이다. 이것또한 테스트를 위해서이다. 실제로는 몇일에서 몇주(길게 2주)정도가 적당하다.

그럼 위의 코드를 한번 확인해보자.

먼저 findUserWithExpiredTokens()메서드 호출을 위한 currentTime값이 필요하다. 이 때, 해당 메서드 정의 시 currentTime값을 number타입으로써 매개변수에 설정하였으므로 인자에 담을 때도 new Date()로 담는 것이 아닌, new Date().getTime()을 통해 숫자값 형태의 날짜로 담도록 한다.

그렇게 받아온 userWithExpiredTokens 객체를 스케줄링을 통해 호출해보면 어떤 값이 나올까? 우린 console을 통해 확인해 볼 수 있다.

현재 위와 같이 두 유저가 등록되어있고, 로그인을 통해 refresh-token 값 또한 가진 상태이다. 위에서 짤려서 안보이지만, 토큰의 만료시간은 아래와 같이 약 2분정도 차이가 나는 것을 확인할 수 있다.

만약, 이 상황에서 스케쥴링을 구현하면 어떻게 될까? 즉, 우린 어떤 유제 객체 값을 얻을 수 있을까? 스케쥴링의 주기는 "1분" 이므로 (매분마다) 1분마다 일련의 쿼리와 함께 해당 데이터 배열이 출력될 것이다.

console.log(usersWithExpiredTokens);
query: SELECT `User`.`id` AS `User_id`, `User`.`firstname` AS `User_firstname`, `User`.`lastname` AS `User_lastname`, `User`.`email` AS `User_email`, `User`.`password` AS `User_password`, `User`.`currentRefreshToken` AS `User_currentRefreshToken`, `User`.`currentRefreshTokenExp` AS `User_currentRefreshTokenExp` FROM `users` `User` WHERE (`User`.`email` = ?) LIMIT 1 -- PARAMETERS: ["a01032762271@gmail.com"]
query: UPDATE `users` SET `currentRefreshToken` = ?, `currentRefreshTokenExp` = ? WHERE `id` IN (?) -- PARAMETERS: ["$2b$10$BIAaX8heYela1pscf21FauQN.rEXPJh5diP8/WONWpqHxGtwBrQy6","2023-05-09T12:45:04.173Z",1]
query: SELECT `user`.`id` AS `user_id`, `user`.`firstname` AS `user_firstname`, `user`.`lastname` AS `user_lastname`, `user`.`email` AS `user_email`, `user`.`password` AS `user_password`, `user`.`currentRefreshToken` AS `user_currentRefreshToken`, `user`.`currentRefreshTokenExp` AS `user_currentRefreshTokenExp` FROM `users` `user` WHERE `user`.`currentRefreshTokenExp` <= ? AND `user`.`currentRefreshToken` is not null -- PARAMETERS: ["2023-05-09T12:41:00.006Z"]
[]
query: SELECT `User`.`id` AS `User_id`, `User`.`firstname` AS `User_firstname`, `User`.`lastname` AS `User_lastname`, `User`.`email` AS `User_email`, `User`.`password` AS `User_password`, `User`.`currentRefreshToken` AS `User_currentRefreshToken`, `User`.`currentRefreshTokenExp` AS `User_currentRefreshTokenExp` FROM `users` `User` WHERE (`User`.`email` = ?) LIMIT 1 -- PARAMETERS: ["y_hello@gmail.com"]
query: UPDATE `users` SET `currentRefreshToken` = ?, `currentRefreshTokenExp` = ? WHERE `id` IN (?) -- PARAMETERS: ["$2b$10$TV.ek.ieTL9IbXzlsw24w.SkHtQnGWMhbKOXPNDPsCVtMJamBbnh6","2023-05-09T12:46:59.863Z",2]
query: SELECT `user`.`id` AS `user_id`, `user`.`firstname` AS `user_firstname`, `user`.`lastname` AS `user_lastname`, `user`.`email` AS `user_email`, `user`.`password` AS `user_password`, `user`.`currentRefreshToken` AS `user_currentRefreshToken`, `user`.`currentRefreshTokenExp` AS `user_currentRefreshTokenExp` FROM `users` `user` WHERE `user`.`currentRefreshTokenExp` <= ? AND `user`.`currentRefreshToken` is not null -- PARAMETERS: ["2023-05-09T12:42:00.005Z"]
[]
query: SELECT `user`.`id` AS `user_id`, `user`.`firstname` AS `user_firstname`, `user`.`lastname` AS `user_lastname`, `user`.`email` AS `user_email`, `user`.`password` AS `user_password`, `user`.`currentRefreshToken` AS `user_currentRefreshToken`, `user`.`currentRefreshTokenExp` AS `user_currentRefreshTokenExp` FROM `users` `user` WHERE `user`.`currentRefreshTokenExp` <= ? AND `user`.`currentRefreshToken` is not null -- PARAMETERS: ["2023-05-09T12:43:00.012Z"]
[]
query: SELECT `user`.`id` AS `user_id`, `user`.`firstname` AS `user_firstname`, `user`.`lastname` AS `user_lastname`, `user`.`email` AS `user_email`, `user`.`password` AS `user_password`, `user`.`currentRefreshToken` AS `user_currentRefreshToken`, `user`.`currentRefreshTokenExp` AS `user_currentRefreshTokenExp` FROM `users` `user` WHERE `user`.`currentRefreshTokenExp` <= ? AND `user`.`currentRefreshToken` is not null -- PARAMETERS: ["2023-05-09T12:44:00.008Z"]
[]
query: SELECT `user`.`id` AS `user_id`, `user`.`firstname` AS `user_firstname`, `user`.`lastname` AS `user_lastname`, `user`.`email` AS `user_email`, `user`.`password` AS `user_password`, `user`.`currentRefreshToken` AS `user_currentRefreshToken`, `user`.`currentRefreshTokenExp` AS `user_currentRefreshTokenExp` FROM `users` `user` WHERE `user`.`currentRefreshTokenExp` <= ? AND `user`.`currentRefreshToken` is not null -- PARAMETERS: ["2023-05-09T12:45:00.011Z"]
[]

이처럼 usersWithExpiredTokens의 값이 1분 간격으로 [] 즉, 빈 배열로 출력되는 것을 확인할 수 있다. 즉, 우리가 앞서 UserServicefindUsersWithExpiredTokens() 내부에서 객체를 리턴할 때, 토큰이 만료된 유저객체만 반환하도록 쿼리를 수행해주었기 때문이다.

쿼리를 자세히 보면, 마지막 빈 배열이 찍힐 시에 45분임을 알 수 있다. (정확한 시각의 차이는 표준 시간 등의 문제로 다릅니다.. 귀찮아서 따로 수정 생략합니다)

또한, userId=1의 유저의 currentRefreshTokenExp (만료시간)는 2023-05-09 21:45:04인 것을 확인하였다. 즉, "46"분 부터는 해당 유저의 refresh-token이 만료된 상태이므로 유저 객체가 콘솔에 출력될 것으로 기대된다.

[
  User {
    id: 1,
    firstName: '대규',
    lastName: '남',
    email: 'a**********@gmail.com',
    password: '$2b$12$00f9wkeRc1SDnDT7p1uFmOIn8ydMVxJTvZVFZ0ogKlBJeG1fvZ7lG',
    currentRefreshToken: '$2b$10$BIAaX8heYela1pscf21FauQN.rEXPJh5diP8/WONWpqHxGtwBrQy6',
    currentRefreshTokenExp: 2023-05-09T12:45:04.000Z
  }
]

userId=2의 유저 또한 refresh-token의 만료시간이 되면 usersWithExpiredTokens에 반영이 될 것이다.

[
  User {
    id: 1,
    firstName: '대규',
    lastName: '남',
    email: 'a**********@gmail.com',
    password: '$2b$12$00f9wkeRc1SDnDT7p1uFmOIn8ydMVxJTvZVFZ0ogKlBJeG1fvZ7lG',
    currentRefreshToken: '$2b$10$BIAaX8heYela1pscf21FauQN.rEXPJh5diP8/WONWpqHxGtwBrQy6',
    currentRefreshTokenExp: 2023-05-09T12:45:04.000Z
  },
  User {
    id: 2,
    firstName: '**',
    lastName: '예',
    email: 'y_hello@gmail.com',
    password: '$2b$12$FgT6Lgv0m4G1vpFSrPlOROEnVZwD.9ffaSS5fxfK0IiQxL5IvpVG.',
    currentRefreshToken: '$2b$10$TV.ek.ieTL9IbXzlsw24w.SkHtQnGWMhbKOXPNDPsCVtMJamBbnh6',
    currentRefreshTokenExp: 2023-05-09T12:47:00.000Z
  }
]

✔ 토큰 제거 + 검증하기

이렇게 얻게 된, 유저 객체를 통해 우린 아래와 같은 작업으로 각 유저의 refresh-token값과 해당 토큰의 만료시간을 제거해 줄 수 있다.

    for (const user of usersWithExpiredTokens) {
      if (user.currentRefreshToken) {
        await this.userService.removeRefreshToken(user.id); 
      }
    }
query: UPDATE `users` SET `currentRefreshToken` = ?, `currentRefreshTokenExp` = ? WHERE `id` IN (?) -- PARAMETERS: [null,null,1]
query: UPDATE `users` SET `currentRefreshToken` = ?, `currentRefreshTokenExp` = ? WHERE `id` IN (?) -- PARAMETERS: [null,null,2]

실제로 이러한 업데이트 쿼리가 주기적으로 호출됨을 확인할 수 있을 것이다. 또한, 데이터베이스를 직접 확인한다면 아래와 같이 null값으로 수정된 것을 확인할 수 있을 것이다.


이렇게 우린 "Task Scheduling"을 사용하여 api 요청 없이도, Refresh-Token 만료시에 데이터베이스의 값을 수정할 수 있게 되었다.

앞서 언급하였다시피 임의로 빠른 결과 확인을 위해 토큰 유효기간과 스케쥴링 주기를 설정하였지만, 실제로는 유저 경험과 여러 데이터 및 데이터베이스 부하등을 고려하여 적절한 시간값을 설정해주는 것이 중요하다.


> SetTimeout()을 통한 구현

아주 간단하고도 직관적이다. 로그인 시 setTimeout()함수를 사용해 refresh-token의 유효기간이 지난 후, 데이터베이스에서 토큰을 제거하는 작업을 수행해주는 것이다.

  @Post('login')
  @UseFilters(RateLimitFilter)
  async login(
    @Body() loginDto: LoginDto,
    @Res({ passthrough: true }) res: Response,
  ): Promise<any> {
    const user = await this.authService.validateUser(loginDto);
    const access_token = await this.authService.generateAccessToken(user);
    const refresh_token = await this.authService.generateRefreshToken(user);
    
    await this.userService.setCurrentRefreshToken(refresh_token,user.id);
    res.setHeader('Authorization', 'Bearer ' + [access_token, refresh_token]);
    res.cookie('access_token', access_token, {
      httpOnly: true,
    });
    res.cookie('refresh_token', refresh_token, {
      httpOnly: true,
    });
	
	// refresh-token의 유효기간(period)을 불러온다.
    const refreshTokenValidityPeriod: number = await this.authService.getRefreshTokenValidityPeriod();
    
    // 위에서 불러온 유효기간이 지나면 removeRefreshToken() 함수를 실행하도록 한다.
    setTimeout(async() => {
      await this.userService.removeRefreshToken(user.id);  
    }, refreshTokenValidityPeriod)
    
    return {
      message: 'login success',
      access_token: access_token,
      refresh_token: refresh_token,
    };
  }
  
  
  
  // getRefreshTokenValidityPeriod() -- UserService
  
    async getRefreshTokenValidityPeriod() {
    const currentTime: number = Date.now();
    const refreshTokenExpTime: number = (await this.userService.getCurrentRefreshTokenExp()).getTime();
    const refreshTokenValidityPeriod = refreshTokenExpTime - currentTime;
    return refreshTokenValidityPeriod;
  }

로그인 시에 해당 유저의 id 값을 받아와 해당 id를 지닌 유저 데이터에 접근한다. 이 사실은 변하지 않으므로, setTimeout을 사용한다면 정말 간단하게 해당 유저마다의 refresh-token 만료 시간 후, 테이블내 레코드에서 토큰 데이터를 제거해줄 수 있다.

query: UPDATE `users` SET `currentRefreshToken` = ?, `currentRefreshTokenExp` = ? WHERE `id` IN (?) -- PARAMETERS: [null,null,1]
query: UPDATE `users` SET `currentRefreshToken` = ?, `currentRefreshTokenExp` = ? WHERE `id` IN (?) -- PARAMETERS: [null,null,2]

쿼리 또한 잘 수행이 되는 것을 확인 할 수 있다.


확실히 Task-Scheduling을 사용했을때보다 코드의 양과 복잡성은 줄어들었다. 그렇다면 과연 이것이 더 좋은 방법일까?

물론, 간단한 예시 코드에선 더 좋아보일지 모르지만, 나의 대답은 "그렇지 않다"이다.

✔ 어떠한 문제가 존재할까?

가장 큰 이유는 "js의 setTimeout은 코드의 실행 순서를 보장하지 않는다." 이다.

지금이야 로그인 api에서 setTimeout 내부의 코드가 설정된 토큰의 유효 기간에 맞춰서 적절한 시기에 실행이 되지만, 항상 그렇다고 보장할 순 없다. 라우트 핸들러 함수 내부에서 어떠한 복잡한 코드가 실행될지도 모르고, 얼마나 오래걸릴지도 예측할 수 없다. 물론, 로그인 api의 경우니까 괜찮지 않냐고 단언할 수도 있지만, 그렇다고 setTimeout에 이를 맡기기엔 예상치 못한 일이 충분히 벌어질 위험성이 있다고 본다.

반면, Task-Scheduling을 사용하는 방법은 정확한 시간에 작업이 실행되도록 정교한 제어를 가능케한다. setTimeout에 비해 대처와 안정성 측면에서 뛰어난 것임은 분명하다. 또한 컨트롤러의 영향을 받지 않고 서비스 단에서 특정 함수로써 분리 구현을 할 수 있으므로 가독성 또한 좋아질 수 있다.

login() 핸들러 함수 내부에 토큰을 디비에서 제거하는 작업을 넣게되면 굉장히 혼동스러운 코드가 될 것이다.

이러한 일련의 이유들로 setTimeout을 통한 방법보단 앞서 수행해보았던 Task-Scheduling이 조금 더 적합한 방법이지않나 생각해본다.


생각정리

이번 포스팅에선 이전 포스팅에서 미처 구현해보지 못하였던 "refresh-token 만료 시 데이터베이스 내에서 해당 토큰 및 만료시간 데이터 제거하기" 에 대한 방법을 알아보았다.

"인증(Authentication)"은 생각보다 고려해야할 사항이 많다는 것을 새삼 느꼈고, 정말로 좋은 전천후한 방법은 항상 존재하지 않다는 것 또한 깨달았다. 분명 위의 코드또한 깊게 파고 들면 수정하고 보완해야할 것이 많을 것이다. 일단, 어떤 식으로 토큰 만료시 데이터베이스에 접근할 수 있는가를 두 가지 방법을 통해 알아보았고, 이 중 Task-Scheduling 기법은 처음 경험해보아서 더 의미가 있었다.

추가해야할 사항이나, 보완 및 수정 사항은 추후 계속해서 업데이트 할 예정이다.

profile
You better cool it off before you burn it out / 티스토리(Kotlin, Android): https://nemoo-dev.tistory.com

0개의 댓글