이번 포스팅은 온전히 이전 포스팅 "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분 간격으로 []
즉, 빈 배열로 출력되는 것을 확인할 수 있다. 즉, 우리가 앞서 UserService
의 findUsersWithExpiredTokens()
내부에서 객체를 리턴할 때, 토큰이 만료된 유저객체만 반환하도록 쿼리를 수행해주었기 때문이다.
쿼리를 자세히 보면, 마지막 빈 배열이 찍힐 시에 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
기법은 처음 경험해보아서 더 의미가 있었다.
추가해야할 사항이나, 보완 및 수정 사항은 추후 계속해서 업데이트 할 예정이다.