로그인시 JWT 토큰을 발행하기로 했다.
JWT는 세션방식으로 로그인을 유지할 때보다 서버가 저장하고 있어야할 데이터가 줄어드는 장점이 있지만
탈취되었을 경우 더 위험하다.
서비스의 성격을 고려했을 때 개인정보를 거의 저장하고 있지 않았다. 개인정보를 많이 저장하고 있는 서비스 보다 보안의 위협이 덜하였기 때문에 JWT가 더 적절하다고 생각했다.
그럼에도 보안은 항상 중요하기 때문에 JWT Access Token를 보완하기위한 Refresh Token을 도입하기로 했다.
발급한 토큰들을 어떻게 저장해야할지 정해야했다.
아래 두 이유로 토큰을 쿠키에 저장하기로 했다.
쿠키는 Http Only 옵션이 있어서 XSS 공격을 예방할 수 있다.
클라이언트가 저장 중이다가 필요할 때 헤더에 넣어서 요청을 보내는 방식은 번거로웠다.
프론트에서 신경써야할 것도 많고, access token이 만료되었을때 다시 refresh token을 요청하고 다시 헤더로 받는 과정들이 추가되었다.
앱 프로젝트라면 선택의 여지가 없지만 웹 프로젝트였기 때문에 할 수 있는 선택이었다.
NestJS에서는 JWTModule로 JWT를 발급, 검증하고 Guard를 이용해서 요청하는 사용자의 권한을 확인한다.
이 두 가지를 이용해서 사용자 인가를 처리하기로 했다.
이 과정에서 Passport는 사용하지 않았다.
Passport를 이용한 PassPortStratgy를 Guard에 적용해서 사용하는 방법이 있었는데, 리프레시 토큰을 도입하자니 문제가 있었다.
Passport 정책을 사용한다고해도 결국 Guard를 사용해야한다.
Passport 정책을 통과하지 못하는 경우에서도 Guard에서 모두 true를 리턴해버리니 권한이 있는 사용자로 처리되었다.
Passport를 활용하려면 access strategy, refresh strategy 가 각각 필요했고 내가 원하는 방식으로 커스텀하는 것이 어려웠다.
이런 이유로 Passport를 걷어 내고 NestJS에서 제공하는 기능만으로 구현했고 충분했다!
생각한 정책은 다음과 같다.
🔒 JWT 정책
1. access token와 refresh token이 유효한지 확인한다.
- 두 토큰이 모두 없는 경우 권한이 없다고 판단한다.
- access token이 유효한 경우
- refresh token이 만료되었다면 재발급한다.
(refresh token 재발급은 쿠키에 저장, Redis에 저장 두 가지 과정을 거친다.)
request에 access token에서 읽어온 사용자 정보를 넣어 요청 핸들러로 보낸다.
- access token이 유효하지 않지만 refresh token이 유효한 경우
(여기서 access token이 유효하지 않은 경우는 값이 없거나 유효기간이 끝난 경우이다. 이외의 경우(변형됨… )에는 바로 401(Unauthorized) 처리를 했다.
Redis에서 해당 유저의 refresh token을 꺼내와 비교한다.
비교 후 값이 같으면 access token을 재발급하고,
request에 refresh token에서 읽어온 사용자 정보를 넣어 요청 핸들러로 보낸다.
데이터베이스에 저장된 값과 서버로 받은 값이 다르면 리프레쉬 토큰이 변형되었다고 판단하고 403(Forbidden) 처리 한다.
이렇게 세운 정책은 문제가 있었다.
항상 사용자가 가지고 있는 두 종류의 토큰을 검증해야한다.
단지 액세스 토큰이 유효한데 리프레시 토큰이 만료되었을 그 찰나를 위해서 매번 사용자의 토큰 검증이 2배가 되어야한다는 것은 비효율적이라고 판단했다.
고민해보고 멘토님께 조언을 구한 결과 이런 답을 얻을 수 있었다.
‘리프레시 토큰 재발급은 재로그인을 통해서만 이루어지는 게 보안상 더 안전하다.’
이런 답을 가지고 변경한 정책은 다음과 같다.
🔒 변경한 JWT 정책
1. access token와 refresh token 두 토큰이 모두 없는 경우 권한이 없다고 판단한다.
2. access token을 검증한다
3. access token이 유효한 경우
request에 access token에서 읽어온 사용자 정보를 넣어 요청 핸들러로 보낸다.
4. access token이 유효하지 않지만 refresh token이 유효한 경우
Redis에서 해당 유저의 refresh token을 꺼내와 비교한다.
비교 후 값이 같으면 access token을 재발급하고,
request에 refresh token에서 읽어온 사용자 정보를 넣어 요청 핸들러로 보낸다.
데이터베이스에 저장된 값과 서버로 받은 값이 다르면 리프레쉬 토큰이 변형되었다고 판단하고 403(Forbidden) 처리 한다.
훨씬 명확한 정책으로 변경할 수 있었다.
구현한 결과는 다음과 같다.
async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest();
const response = context.switchToHttp().getResponse();
const accessToken = request.cookies.access_token;
const refreshToken = request.cookies.refresh_token;
if (!accessToken && !refreshToken) {
throw new JsonWebTokenError(jwtError.NO_TOKEN);
}
const accessResult = await this.verifyAccessToken(accessToken);
if (accessResult) {
request.user = this.serializeUser(accessResult);
return true;
}
const refreshResult = await this.verifyRefreshToken(refreshToken);
const refreshTokenHave = await this.redisService.getRefreshToken(
refreshResult.sub,
);
if (refreshTokenHave !== refreshToken) {
this.logger.warn(
`${refreshResult} 사용자가 변형된 리프레시 토큰을 보유함`,
);
return false;
}
const user = this.serializeUser(refreshResult);
const newAccessToken = await this.authService.getAccessToken(user);
this.authService.setAccessToken(response, newAccessToken);
request.user = user;
return true;
}