소셜로그인 1편에 이어서 바로 2편을 포스팅하려 했는데
refresh_token과 JWT 전략에 대해서 이것저것 머리싸매면서 고민하느라 늦어졌다..
정말 다양한 JWT 전략과 고민할 사항이 많았다.
한 계정으로 여러 디바이스에서 로그인을 할 수도 있고,
access_Token 만료시 refresh-API 실행 타이밍을 백엔드에서 만료를 체크해서 할 지,
아니면 프론트엔드에서 access_Token "exp"로 만료 체크해서 API 요청할지 등등...
바로 본론으로 들어가기전에 소설로그인 1편을 꼭 보고오시길 !
https://ilikezzi.tistory.com/64
refresh_token
소셜로그인 1편을 보고왔으면 알겠지만, 나는 refresh_token을 쓰기로 했다.
그럼 refresh_token은 뭐고 왜 쓰는것일까?
refresh_token은 단어 뜻 그대로 새로고침 토큰이라고 보면 좋을 것 같다.
access_token은 보호된 자원에 액세스 하는데 필요한 인증 수단이지만, 해당 토큰을 가지고 있는 사람이라면 누구든지 해당 자원에 접근할 수 있게 된다.
이러한 특성 때문에 악의적인 사용자가 시스템을 침해하고 access_token을 훔쳐서 접근을 하지 못하도록 막기 위한 적절한 조치가 필요한 것이다.
다양한 적절한 조치가 있지만 그 중 하나인 access_token의 만료시간을 짧게 설정하는 것이다.
이렇게 하면 기존의 access_token이 탈취 당하더라도 새로운 access_token만이 자원에 액세스할 수 있게된다.
하지만 단점도 존재한다.
만료시간을 짧게 설정하면 보안적 측면에서는 좋겠지만,
"유저 경험" 즉 ,UX 측면에선 굉장히 불편할 것이다.
이 단점을 개선하기 위해 등장한 것이 바로 "refresh_token"이다.
JWT 전략
JWT 전략을 사용하면 서버가 세션 상태를 유지할 필요가 없기 때문에 확장성 있는 어플리케이션을 만드는 데 좋고,
여러 시스템 간에 토큰을 전달하기 용이하다는 점을 강조할 수 있다.
하지만, 앞서 얘기한대로 한 번 발급된 토큰은 그 자체로 인증 수단이 되기 때문에, 보안에 더 신경을 써야 하고,
토큰이 탈취되면 악용될 수 있다는 점도 주의해야 한다.
이러한 refresh_token을 가지고 JWT전략을 어떻게 짰는지 살펴보자.
1. 유저가 첫 로그인시 access_token, refresh_token 발급해 줄 것이다.
2. access_token은 클라이언트에서 저장을 하고 refresh_token은 DB에 쌓는다.
3. 각각의 토큰 만료는 access_token: 1시간 , refresh_token: 14일로 정했다.
4. refresh_token은 쿠키에 저장을 하고 만료기간은 동일하게 14일로 정했다.
5. Access token의 payload안에 “exp”정보도 담아서 이 값으로 프론트에서 만료체크를 하고,
만료되었으면 refresh-API로 access_token을 재발급해준다.
6. refresh-API 실행시 refresh_token도 만료되었으면 재로그인을 하게끔 한다.
7. refresh-API실행시 refresh_token이 다르면 재로그인을 하게끔 한다. (여러 디바이스 동시로그인 방지)
8. 로그아웃 API는 DB와 쿠키의 있는 refresh_token을 지워버린다.
여기까지가 내가 세운 JWT 전략이다.
Code
이제 바로 코드로 확인해보자.
// auth.controller.ts
@Get('/user/login/google')
@ApiOperation({
summary: '구글 로그인',
description: '구글 로그인',
})
@UseGuards(AuthGuard('google'))
async googleAuth(@Req() _req: Request) {}
/* Get Google Auth Callback */
@Get('/auth/google/callback')
@UseGuards(AuthGuard('google'))
async googleAuthCallback(
@Req() req: GoogleRequest,
@Res() res: Response, // : Promise<GoogleLoginAuthOutputDto>
) {
const result = await this.authService.googleLogin(req, res);
return res.json(result);
}
// auth.service.ts
async googleLogin(
req: GoogleRequest,
res: Response,
) {
try {
req.user.nickname = req.user.firstName + req.user.lastName;
const { user } = req;
delete user.lastName;
delete user.firstName;
user.type = 'google';
// 유저 중복 검사
let findUser = await this.userQueryRepository.findUser(user);
// 없는 유저면 DB에 유저정보 저장
if (!findUser) {
const uuid = generateUUID();
findUser = await this.userQueryRepository.createUser(user, uuid);
}
// 구글 가입이 되어 있는 경우 accessToken 및 refreshToken 발급
const findUserPayload = {
id: findUser.id,
uuid: findUser.uuid,
nickname: findUser.name,
profile_image: findUser.profile_image,
};
const eid_access_token = jwt.sign(findUserPayload, this.configService.get('JWT_SECRET'), {
expiresIn: this.configService.get('JWT_ACCESS_TOKEN_EXPIRATION_TIME'),
});
const eid_refresh_token = jwt.sign({}, this.configService.get('JWT_REFRESH_KEY'), {
expiresIn: this.configService.get('JWT_REFRESH_TOKEN_EXPIRATION_TIME'),
audience: String(findUser.id),
});
/* refreshToken 필드 업데이트 */
findUser.eid_refresh_token = eid_refresh_token;
await this.userQueryRepository.save(findUser);
// 쿠키 설정
const now = new Date();
now.setDate(
now.getDate() +
parseInt(this.configService.get('JWT_REFRESH_TOKEN_EXPIRATION_DATE')) / 1000,
);
console.log(this.configService.get('JWT_REFRESH_TOKEN_EXPIRATION_DATE'));
res.cookie('eid_refresh_token', eid_refresh_token, {
expires: now,
httpOnly: true,
secure: process.env.NODE_ENV === 'production' ? true : false,
sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
});
return {
ok: true,
eid_access_token,
};
} catch (error) {
return { ok: false, error: '구글 로그인 인증을 실패 하였습니다.' };
}
}
소셜로그인 1편 포스팅에서 DB에 유저정보 쌓는거 까지 다뤘으니 그 다음부터 보자.
findUserPayload에는 access_token에 담을 정보들을 넣어준다.
refresh_token에는 굳이 findUserPayload 이 정보를 다 담을 필요가 없어서
audience만 지정해줬다.
이렇게 설정하면 나중에 백엔드에서 이 audience값을 검증해서,
요청을 보낸 사용자의 ID와 일치하는지를 확인해 해당 토큰이 맞는 사용자에게 발급된 건지 확인할 수 있다.
만료기간은 env파일에서 access_token 1시간, refresh_token 14일로 지정해준다.
쿠키설정의 만료기간도 refresh_token에서 사용하던 14일을 동일하게 넣어준다.
httpOnly: 이 옵션을 true로 설정하면, 쿠키에 자바스크립트로 접근할 수 없다.
XSS 공격을 방지하기 위한 중요한 설정이다.
secure: 이 속성이 true일 때, 쿠키는 HTTPS 프로토콜을 통해서만 전송된다.
개발 환경에서는 굳이 HTTPS가 아니어도 되니까,
process.env.NODE_ENV === 'production' 조건을 통해 프로덕션 환경에서만 true로 설정되게 해놨다.
sameSite: 이 옵션은 CSRF 공격을 방지하는 데 도움을 준다.
이렇게해서 로그인 할 때는 끝이다.
이제 로그인 연장하는 refresh API를 살펴보자.
// auth.controller.ts
@Post('/user/refresh')
@ApiOperation({
summary: '로그인 연장',
description: '로그인 연장',
})
async silentRefresh(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
return await this.authService.silentRefresh(req, res);
}
// auth.service.ts
async silentRefresh(req: Request, res: Response): Promise<SilentRefreshAuthOutputDto> {
try {
// refreshToken 유효성 검사
const getRefreshToken = req.cookies['eid_refresh_token'];
if (isEmpty(getRefreshToken)) {
return { ok: false };
}
let userId: string | string[] | null;
jwt.verify(
getRefreshToken,
this.configService.get('JWT_REFRESH_KEY'),
(err: jwt.VerifyErrors | null, decoded: jwt.JwtPayload | undefined) => {
if (err) {
res.clearCookie('eid_refresh_token');
return { ok: false, error: '토큰이 유효하지 않습니다. 로그인이 필요합니다' };
}
userId = decoded.aud;
},
);
// 로그아웃 후에는 Silent Refresh를 무시
const loginUser = await this.userQueryRepository.findId(+userId);
if (loginUser.eid_refresh_token !== getRefreshToken) {
return { ok: false, error: '토큰이 유효하지 않습니다. 로그인이 필요합니다' };
}
// accessToken 재발급
const payload = {
id: loginUser.id,
uuid: loginUser.uuid,
nickname: loginUser.name,
profile_image: loginUser.profile_image,
};
const eid_access_token = jwt.sign(payload, this.configService.get('JWT_SECRET'), {
expiresIn: this.configService.get('JWT_ACCESS_TOKEN_EXPIRATION_TIME'),
});
return {
ok: true,
eid_access_token,
};
} catch (error) {
console.log(error);
return { ok: false, error: '로그인 연장에 실패하였습니다.' };
}
}
이제 하나씩 무슨코드인지 살펴보자.
해당 API는 Req로 쿠키값만 사용된다.
아까 refresh_token 발급할 때 audience 를 넣은 이유가 이제 나온다.
JWT의 유효성을 검증을 하고 이상이 없으면 userId에 audience를 담아준다.
userId로 DB에서 유저정보를 찾고 쿠키의 refresh_token이랑 DB의 refresh_token이랑 비교를한다.
값이 다를 경우엔 다른 디바이스로 로그인 한 것이므로 로그인이 필요하다고 return 시켜준다.
access_token 재발급을 해줄때는 기존과 동일하게 payload와 만료시간을 설정하고
새 토큰을 return 해준다.
이렇게 refresh API는 끝이다.
이제 마지막으로 로그아웃 API를 살펴보자.
// auth.controller.ts
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('access-token')
@Post('user/logout')
@ApiOperation({
summary: '로그아웃',
description: '로그아웃',
})
async logout(
@Res({ passthrough: true }) res: Response,
@CurrentUser() user,
): Promise<LogoutAuthOutputDto> {
return await this.authService.logout(user, res);
}
// auth.service.ts
async logout(user, res): Promise<LogoutAuthOutputDto> {
try {
if (isEmpty(user.id)) return { ok: false, error: '접근 권한을 가지고 있지 않습니다' };
const loginUser = await this.userQueryRepository.findId(user.id);
loginUser.eid_refresh_token = null;
await this.userQueryRepository.save(loginUser);
res.clearCookie('refreshToken');
return { ok: true };
} catch (error) {
console.log(error);
return { ok: false, error: '로그아웃을 실패하였습니다' };
}
}
로그아웃 API는 @UseGuards(JwtAuthGuard)로 토큰이 필요하다.
정상적인 access_token 인지 체크하고
해당 유저DB의 refresh_token과 쿠키의 refresh_token을 초기화 시켜주면 끝이다.
결과 확인
이제 전부 끝났으니 정상 작동하는지 결과를 확인해보자.
1. 로그인
로그인시 클라이언트엔 access_token, DB엔 refresh_token이 정상적으로 쌓였다.
2. refresh-API
첫번째는 정상적으로 access_token이 정상적으로 재발급 된것이고,
두번째는 만료된 refresh_token을 넣었을 때 case인데 "로그인이 필요합니다" 제대로 출력된다.
3. 로그아웃 API
로그아웃 API를 실행하면 정상적으로 refresh_token이 초기화된 것을 확인할 수 있다.
드디어 소셜 로그인을 전부 완료했다 ㅠㅠ
처음으로 제대로 JWT를 해보는거라 초반에 이해하는데 꽤나 머리를 쥐어뜯었는데
막상 이해를하고 코드를 보니 어려운건 없더라.
포스팅도 하면서 한번 더 정리를 하느라 완벽히 이해를 했다.
얼른 프로젝트를 끝내고 깃헙에 오픈소스로 올려야지...!
'Web > NestJS' 카테고리의 다른 글
[Nest.JS] 구글, 네이버, 카카오 소셜로그인 구현 - 1 (1) | 2023.10.16 |
---|---|
[Nest.JS] Nest.JS에서 cache-manager를 활용한 캐싱 방법 (0) | 2023.09.17 |
[Nest.JS] Slack에 다양한 통계데이터 알림 자동화 (0) | 2023.07.02 |
[Nest.JS] TypeOrm에서 페이지네이션 구현 (last_id, size) (0) | 2023.05.05 |
[Nest.JS] DTO vs Interface (0) | 2023.04.23 |