오랜만에 작성하는 포스팅인 것 같다. 최근에 개인적인 일도 있고, 뭔가 쉬어가고 싶어서 천천히 공부를 하며 어떤 주제를 다뤄보며 좋을까 고민을 했었다. 그러던 와중, 예전부터 들어만보았지, 한 번도 다뤄본적이 없었던 "Refresh-Token"에 대해 NestJS로 구현해보면 좋을것 같다는 생각이 들었다.
NestJS는 물론이고, 흔히 서버 기술과 관련된 프레임워크를 다루다보면 처음으로 "인증(Authentication)"에 관한 내용을 접하게 된다. "인증"을 다루는데 있어서 흔히 "User Authentication(유저 인증)"을 처음 접하게 되고, 동시에 "로그인(Log-in) 구현"을 통해 이를 알아보게 된다.
여기서 우린 "세션(session)", "토큰(token)" 이란 개념을 인증 방법으로써 도입하게 되는데 이번 포스팅에선 토큰 방식의 인증(Token-based Authentication), 그 중에서도 JWT(Json Web Token)을 통해서 인증을 구현해보고자 한다.
단순히 "Access-Token"만으로 다뤄보진 않을 것이다. 해당 방법은 많은 강좌 혹은 블로그, 혹은 공식문서에도 기술되어있고 쉽게 구현해볼 수 있다. 본인 또한 일전에 JWT를 이용하여(오로지 Access-Token 기능을 통해) 유저 인증 기능을 구현해보았었다. (아래 링크 참조)
이번 포스팅에선 "Access-Token"만이 아닌, "Refresh-Token"을 도입함으로써 보안적 측면을 조금 더 강화한 인증을 구현해보고자 한다. JWT를 통해 "Refresh-Token"을 적용시키는 것은 물론이고, NestJS에선 이를 어떻게 다룰 수 있는지에 대해 알아볼 것이다.
본격적 내용에 들어가기에 앞서 언급하자면, 이번 포스팅에선 "어떻게 Refresh-Token을 구현해 내는가?"에 중점을 둘 것이다. "Refresh-Token"을 구현하였다고해서 보안적 측면에서의 모든 문제를 해결하였고, 궁극적인 방법에 이른 것은 절대 아니다. Refresh-Token이라도 허점은 분명히 존재하고, 보완적으로 추가해야할 부분들이 많다. 이러한 내용은 추후 다음 포스팅들에서 다뤄보도록 하겠다.
또한, 지금부터 기술하게 되는 내용 혹은 방법만이 전부는 아닐 것이므로 다양한 생각을 항상 열어둘 필요는 있을 것이다.
Refresh Token
?우리는 이번 글에선 "NestJS에서 Refresh-Token을 어떻게 구현해볼 수 있는가"에 중점을 맞출 것이므로, Refresh-Token에 대한 깊은 내용까진 설명하진 않을 것이다. 간단히 Refresh-Token은 무엇이고, 왜? 사용하는지에 대해서 알아보자.
"Refresh-Token"은 단어 뜻 그대로 "새로 고침 토큰"이다. 정의하는 사람에 따라 다르겠지만, 본인은 이것을 "Access-Token의 새로 고침"으로 생각하면 좋을 거 같았다.
우연히 아래와 같은 문구를 보았다.
"""
It's important to highlight that the access token is a bearer token.
"""
Access-Token은 Bearer-Token임을 강조하는 것이 중요하다는 뜻이다. 이 말이 의미하는게 무엇일까?
"Bearer-Token"은 인증된 사용자가 보호된 자원에 접근하는 데 사용되는 토큰이다. Bearer-Token은 인증된 사용자가 보호된 자원에 접근하는 데 사용되는 토큰이다. (Bearer-Token은 "Bearer"라는 프로토콜 헤더에 포함되어 서버로 전송된다) 즉, Access-Token은 Bearer-Token으로써, 식별 정보(identification artifact)대신에 보호된 자원에 액세스할 수 있는 자격 증명 정보(credential artifact)로 작용한다.
이렇게 Access-Token은 보호된 자원에 액세스하는 데 필요한 인증 수단이지만, 해당 토큰을 가지고 있는 사람이라면 "누구든지" 해당 자원에 접근할 수 있게된다. 이러한 특성 때문에 악의적인 사용자가 시스템을 침해하고 Access-Token을 훔쳐서 보호된 자원에 접근하는 것을 막기 위한 적절한 조치가 필요한 것이다.
이러한 "적절한 조치"중 하나로, 우리는 Access-Token의 "만료시간"을 짧게 설정하는 것이다. 액세스 토큰은 몇 시간 또는 며칠 단위로 정의된 짧은 시간 동안만 유효하게 하는 것이다. 이렇게 하면, 기존의 Access-Token이 탈취당하더라도 새로운 Access-Token만이 자원에 액세스할 수 있으므로, 궁극적인 해결은 아닐지라도 어느 정도 보안을 해준다.
하지만! 위와 같이 Access-Token의 만료시간을 짧게 설정할 경우 보안적 측면에선 좋겠지만, "유저 경험" 즉, UX 측면에선 굉장히 불편한 문제를 야기한다.
새로운 Access-Token을 받기 위해선, 해당 토큰이 만료될때마다 사용자에게 다시 로그인을 요구하게 된다. 만약 자주 사용하는 채팅 서비스 혹은 쇼핑몰 서비스를 이용할 시, 토큰의 만료에 따라 한 시간마다 계속해서 로그인을 해줘야한다고 생각해보자. 이렇게 계속 Acess-Token이 만료될 때마다 사용자에게 다시 로그인을 요구하는 것은 UX 측면에서 좋지 않은 것이 분명하다.
Access-Token의 "UX" 측면에서 이를 개선하기 위해 등장한 것이 "Refresh-Token"이라 할 수 있다. 서버에 저장된 Refresh-Token을 사용하여 새로운 Access-Token을 발급하고, 사용자에게 다시 로그인을 요구하지 않고 보호된 리소스에 액세스할 수 있도록 해준다.
Mechanism of Refresh Token
앞서, Refresh-Token은 왜 등장하게 되었는가를 알아보았으니 이젠 Refresh-Token이 인증(+인가)의 과정에서 어떠한 매커니즘으로 동작하는지에 대해 간단히 알아보자.
아래는 직접 나타내본 Refresh-Token의 동작 매커니즘(진행 순서)이다.
세세한 설명은 굳이 언급하지 않아도 위의 이미지를 통해 진행되는 순서및 동작을 이해할 수 있을것이다.
이제 본격적 NestJS 코드를 통해 Refresh-Token을 구현해보자.
Refresh Token
구현하기코드 설명에 들어가기에 앞서 JWT 토큰 기반의 인증 절차를 진행하는데 있어서 사용하게 될, Guard
, Strategy
및 PassportModule
에 대한 세세한 내용은 생략하고 진행하겠다. (하지만 꼭 필요하고, 해당글을 읽는데 있어서 필요한 내용임은 분명합니다 __ 글이 너무 장황해질 것이므로 생략합니다)
Login
기능 구현하기회원가입 로직은 생략하고 진행해보자. 컨트롤러의 login
메서드를 먼저 확인해봄으로써 Refresh-Token이 login
과정에서 어떻게 생성되는지 확인해보자.
✔ login
핸들러 함수 (AuthController
)
// auth.controller.ts
@Post('login')
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);
// 유저 객체에 refresh-token 데이터 저장
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,
});
return {
message: 'login success',
access_token: access_token,
refresh_token: refresh_token,
};
}
클라이언트에서 입력한 로그인 데이터를 통해 검증한 유저객체 생성
해당 유저객체를 통해 Access-token
과 Refresh-token
생성
해당 id
의 유저에 해당하는 Refresh-token
데이터를 User
테이블의 특정 컬럼에 저장한다.
Bearer
type으로써 토큰을 요청 헤더의 Authorization
필드에 담아 보낸다.
"express"의 response 객체의 cookie
메소드를 사용하여 쿠키를 클라이언트에게 전송한다. 로그인 시엔 access_token에 대한 쿠키와 refresh_token에 대한 쿠키를 둘 다 보내주게 되는데, 이때 옵션으로 httpOnly
를 true
로 설정한다. 이렇게 함으로써 Javascript
코드에서 쿠키에 접근할 수 없도록 보호한다. (Xss
- Cross-site scripting
에 대처)
생성한 access_token
과 함께 refresh_token
을 응답으로 리턴한다.
이렇게 로그인 라우트 핸들러 함수가 어떤 과정을 지니는지 알아보았다. 이젠 해당 함수가 사용한 서비스 모듈의 메서드들을 하나씩 알아보자.
✔ login
에 필요한 메서드 (by AuthService
)
// authService.ts
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import { User } from 'src/users/entities/users.entity';
import { UsersService } from 'src/users/users.service';
import { LoginDto } from './model/login.dto';
import { Payload } from './payload/payload.interface';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class AuthService {
constructor(
private readonly userService: UsersService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
async validateUser(loginDto: LoginDto): Promise<User> {
const user = await this.userService.findUserByEmail(loginDto.email);
if (!user) {
throw new NotFoundException('User not found!')
}
if (!await bcrypt.compare(loginDto.password, user.password)) {
throw new BadRequestException('Invalid credentials!');
}
return user;
}
async generateAccessToken(user: User): Promise<string> {
const payload: Payload = {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
}
return this.jwtService.signAsync(payload);
}
async generateRefreshToken(user: User): Promise<string> {
const payload: Payload = {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
}
return this.jwtService.signAsync({id: payload.id}, {
secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
expiresIn: this.configService.get<string>('JWT_REFRESH_EXPIRATION_TIME'),
});
}
}
Access-Token
을 생성하는데 있어서 필요한 옵션값 (secret-key
, expiresIn
)들은 Refresh-Token
과 다르게 직접 지정해주지 않았다.
Access-Token
에 필요한 정보들은 AuthModule
단에서 JwtModule
설정을 통해서 동적으로 받도록 한다.
// auth.module.ts
@Module({
imports: [
TypeOrmModule.forFeature([User]),
TypeOrmExModule.forCustomRepository([UsersRepository]),
PassportModule.register({}),
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
secret: configService.get<string>('JWT_ACCESS_SECRET'),
signOptions: {
expiresIn: configService.get<string>('JWT_ACCESS_EXPIRATION_TIME'),
}
}),
inject: [ConfigService],
}),
forwardRef(() => UsersModule),
],
controllers: [AuthController],
providers: [AuthService, UsersService, JwtRefreshStrategy, JwtAccessAuthGuard, JwtRefreshGuard],
})
export class AuthModule {}
또한, .env
파일 내부에서 jwt 토큰 생성에 필요한 시크릿 키와 만료시간을 보관해준다.
# JWT Options
JWT_ACCESS_SECRET=myaccesssecretkey
JWT_REFRESH_SECRET=myrefreshsecretkey
JWT_ACCESS_EXPIRATION_TIME=20000
JWT_REFRESH_EXPIRATION_TIME=1800000
우리는 @nestjs/config
에서 제공하는 ConfigModule
의 ConfigService
를 통해서 동적으로 해당 옵션값들을 받아올 수 있다.
※ 해당 ConfigModule
과 ConfigService
를 JwtModule
내부에서 사용하기 위해 꼭 imports
를 통해 불러오고, 서비스를 의존성 주입해주는 것을 기억하자.
또한, JWT 토큰을 생성하는 과정에 있어서 Access-Token
과 Refresh-Token
값의 형태를 다르게 하였다.
// Access-Token 생성
return this.jwtService.signAsync(payload);
// Refresh-Token 생성
return this.jwtService.signAsync({id: payload.id}, {
// ~~
});
Access-Token
생성시엔 Payload
객체를 온전히 받아와주었지만, Refresh-Token
생성시엔 Payload
의 id
값만 받아와주었다. 즉, Payload
와 매핑한 온전한 유저 데이터를 전부 사용하지 않도록 하였다.
※ 참고 - "Payload"
export interface Payload {
id: number;
email: string;
firstName: string;
lastName: string;
}
Refresh-Token
값을 생성하는데있어 위와 같은 룰을 적용한 큰(혹은 정확한) 이유는 없지만, Refresh-Token
엔 굳이 유저의 정보가 담겨져 있을 필요가 없다고 판단하였다.
즉, 해당 Refresh-Token
이 어떠한 유저의 토큰인지를 알 수 있는 "식별자"(여기선 id
값 선택) 정도만 포함시키도록 하였다. Refresh-Token
이 노출되어도 해당 토큰에서 사용자의 정보를 모두 보여주게 되는것 보단, 보안상 유출의 위험이 줄어들 것이다.
✔ Insert Current-Refresh-Token
to Database (UserService) <중요!!>
로그인을 통한 인증(Authentication)시에 생성하게 된 Refresh-Token
을 데이터베이스에 저장시켜줘야한다. 우린 로그인 라우트 함수에서 아래와 같은 작업을 통해 수행해주었다.
// auth.controller.ts
// 첫 번째 인자는 생성한 refresh_token, 두 번째는 해당 유저의 id
await this.userService.setCurrentRefreshToken(refresh_token,user.id);
사용하게 된 setCurrentRefreshToken()
메서드는 유저 정보에 관한 처리이므로 UserService
에서 작성해주었다.
// user.service.ts
async setCurrentRefreshToken(refreshToken: string, userId: number) {
const currentRefreshToken = await this.getCurrentHashedRefreshToken(refreshToken);
const currentRefreshTokenExp = await this.getCurrentRefreshTokenExp();
await this.userRepository.update(userId, {
currentRefreshToken: currentRefreshToken,
currentRefreshTokenExp: currentRefreshTokenExp,
});
}
getCurrentHashedRefreshToken()
, getCurrentRefreshTokenExp()
를 통해 현재 Refresh-Token값과 해당 토큰의 만료시간을 받아온다.
async getCurrentHashedRefreshToken(refreshToken: string) {
// 토큰 값을 그대로 저장하기 보단, 암호화를 거쳐 데이터베이스에 저장한다.
// bcrypt는 단방향 해시 함수이므로 암호화된 값으로 원래 문자열을 유추할 수 없다.
const saltOrRounds = 10;
const currentRefreshToken = await bcrypt.hash(refreshToken, saltOrRounds);
return currentRefreshToken;
}
async getCurrentRefreshTokenExp(): Promise<Date> {
const currentDate = new Date();
// Date 형식으로 데이터베이스에 저장하기 위해 문자열을 숫자 타입으로 변환 (paresInt)
const currentRefreshTokenExp = new Date(currentDate.getTime() + parseInt(this.configService.get<string>('JWT_REFRESH_EXPIRATION_TIME')));
return currentRefreshTokenExp;
}
또 하나 눈여겨 볼 부분은, 토큰을 저장할 때 typeorm
의 save()
를 통해 저장하는것이 아니라 update()
를 해주도록 하였다.
그 이유를 설명하기 전에 먼저 User
테이블을 살펴보자.
// user.entity.ts
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity({name:'users'})
export class User {
@PrimaryGeneratedColumn()
id: number;
// ...
@Column({ nullable: true })
currentRefreshToken: string;
@Column({ type: 'datetime', nullable: true })
currentRefreshTokenExp: Date;
}
보기와 같이, currentRefreshToken
과 currentRefrehTokenExp
의 값은 null
을 허용하도록 해주었다. 즉, default값은 null
일 것이다.
이로 인해, 업데이트를 통해 null
-> hashed token value
-> null(logout)
값을 반복해서 가지게 될 것이다. (만료시간 역시 마찬가지다)
Guard
주입을 통한 유저 권한 확인하기"Refresh-Token"을 통해 "Access-Token"을 재발급 받기에 앞서, 먼저 Access-Token을 통한 인가(Authorization)에 접근하는 코드를 구현해보자. 우린 이를 통해 조금 더 실용적이고 눈에 보이는 상황을 만들어 볼 수 있을 것이다.
✔ authenticate
핸들러 함수 (AuthController)
// auth.controller.ts
@Get('authenticate')
@UseGuards(JwtAccessAuthGuard)
async user(@Req() req: any, @Res() res: Response): Promise<any> {
const userId: number = req.user.id;
const verifiedUser: User = await this.userService.findUserById(userId);
return res.send(verifiedUser);
}
authenticate
url로 접근해 만약 가드를 통과하면 인증된 유저 객체를 반환하고, 그렇지 않을경우 가드에서 설정한 false
반환으로 인한 에러를 띄우도록 한다.
가드는 아래와 같다. 유저의 권한을 확인하는데 있어서, 오로지 Access-Token
만이 사용된다. Refresh-Token
은 무관하다.
✔ JwtAccessAuthGuard
구현
// jwt-access.guard.ts
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
@Injectable()
export class JwtAccessAuthGuard implements CanActivate {
constructor(
private jwtService: JwtService,
) {}
async canActivate(
context: ExecutionContext,
): Promise<any> {
try {
const request = context.switchToHttp().getRequest();
const access_token = request.cookies['access_token'];
const user = await this.jwtService.verify(access_token);
request.user = user;
return user;
} catch(err) {
return false;
}
}
}
일반적으로, 공식문서에서 PassportModule
을 통한 JWT
인증 방법으로 "Guard(가드)"와 가드가 사용하게 되는 "Strategy(전략)"를 통해 구현할 것을 제시한다.
하지만, 너무 해당 방법에 얽매일 필요는 없다고 생각한다. 유저 인증을 하는 로직을 꼭 "Strategy"에서 작성해야할까? 그건 아니다. 우린 진작 authService
에서 이를 구현해주었고, 또 충분히 서비스단에서 구현해줄 수 있다.
본인은, "Access-Token"에 있어서는 굳이 PassportModule
을 사용할 필요가 없다고 생각하여 CanActivate
를 확장하여 가드를 작성해주었다.
클라이언트의 요청 시, 쿠키에 담긴 토큰을 검증해주고 만약 해당 토큰이 유효하다면 해당하는 유저객체를 리턴하도록 한다.
request.user = user;
위에 작성된것 처럼, request의 user 프로퍼티와 우리가 jwtService.verify()
를 통해 검증해준 user 객체를 동일시 해주는 작업이 중요!! 하다. (이를 수행하지 않는다면 컨트롤러의 라우트 핸들러 함수에서 req.user
를 undefined
로 받게 될 것이다...)
이렇게 작성된 JwtAccessAuthGuard
를 통한 권한 인증의 테스트는 추후 포스트맨을 통한 테스트 부분에서 알아보도록 하고 다음 과정으로 넘어가자.
Refresh Token
을 통한 Access Token
재발급바로 위에서 언급하였다시피, 마지막에 포스트맨을 통한 전체 인증과정 진행을 보여주겠지만 일단은 앞선 로그인 과정에서 "Access-Token"과 "Refresh Token"을 둘 다 응답받는다는 것을 알아두자.
JWT_ACCESS_EXPIRATION_TIME=20000
앞서 Access-Token
의 만료시간을 위와 같이 20초(20000ms)로 설정해주었다. 즉, 20초가 지난다면 우린 만료시간 설정으로 인해 해당 Access-Token
을 사용할 수 없게된다.
자, 이제 우리는 로그인 시 응답받은 refresh_token
을 사용할 시간이다.
✔ refresh()
핸들러 함수 (AuthController)
새로운 "access_token"을 받기 위한 라우트 핸들러 함수이다. Body(전문)에 앞서 로그인 시 부여받은 "refresh_token" 값을 전달해 새로운 access_token(newAccessToken
)을 응답받을 것이다. 만약 오류가 생긴다면(잘못된 값, refresh_token의 만료시간 경과) 간단히 catch
문을 통해 예외처리를 시켜준다.
※ 참고 - RefreshTokenDto
// refresh-token.dto.ts
import { IsNotEmpty } from "class-validator";
export class RefreshTokenDto {
@IsNotEmpty()
refresh_token: string;
}
// auth.controller.ts
@Post('refresh')
async refresh(
@Body() refreshTokenDto: RefreshTokenDto,
@Res({ passthrough: true }) res: Response,
) {
try {
const newAccessToken = (await this.authService.refresh(refreshTokenDto)).accessToken;
res.setHeader('Authorization', 'Bearer ' + newAccessToken);
res.cookie('access_token', newAccessToken, {
httpOnly: true,
});
res.send({newAccessToken});
} catch(err) {
throw new UnauthorizedException('Invalid refresh-token');
}
}
AuthService
에서 정의해준 refresh()
함수를 통해 새로운 newAccessToken
을 얻게된다. 그 후, login()
시와 마찬가지로 새로운 토큰을 응답 헤더를 통해 클라이언트에게 Bearer type으로 보내주고, cookie
에도 설정해준다.
✔ refresh()
함수 (AuthService)
// auth.service.ts
async refresh(refreshTokenDto: RefreshTokenDto): Promise<{ accessToken: string }> {
const { refresh_token } = refreshTokenDto;
// Verify refresh token
// JWT Refresh Token 검증 로직
const decodedRefreshToken = this.jwtService.verify(refresh_token, { secret: process.env.JWT_REFRESH_SECRET }) as Payload;
// Check if user exists
const userId = decodedRefreshToken.id;
const user = await this.userService.getUserIfRefreshTokenMatches(refresh_token, userId);
if (!user) {
throw new UnauthorizedException('Invalid user!');
}
// Generate new access token
const accessToken = await this.generateAccessToken(user);
return {accessToken};
}
AuthService
에서 작성한 위의 refresh()
메서드의 경우엔 RefreshGuard
를 통해 나타낼 수도 있겠지만, 서비스의 메서드를 호출하는 방식으로 직접 구현해보았다.
로그인 시 생성해 발급받은 refresh_token
을 jwtService.verify()
를 통해 올바른 JWT 형식인지 검증을 하고, Payload
타입으로써 단언하여준다.
이렇게 함으로써 decodedRefreshToken
이 Payload
객체를 준수한 타입을 가짐을 명시한다.
새로 발급받을 accessToken
을 생성하는데에 있어서 우린 앞서 만들어준 generateAccessToken()
메서드를 그대로 사용할 것인데, 이때 인자로써 User
객체가 필요하다.
해당 user 객체는 단순히 불러오기 보단, 데이터베이스 내부 유저 테이블의refresh_token
값과 / 요청 시 Body(전문)에 실어준 refresh_token
값이 일치하는지의 과정을 거친 user 객체를 불러올 필요가 있다.
이 작업을 우린 UserService
의 getUserIfRefreshTokenMatches()
메서드를 통해 아래와 같이 정의할 수 있다.
// user.service.ts
async getUserIfRefreshTokenMatches(refreshToken: string, userId: number): Promise<User> {
const user: User = await this.findUserById(userId);
// user에 currentRefreshToken이 없다면 null을 반환 (즉, 토큰 값이 null일 경우)
if (!user.currentRefreshToken) {
return null;
}
// 유저 테이블 내에 정의된 암호화된 refresh_token값과 요청 시 body에 담아준 refresh_token값 비교
const isRefreshTokenMatching = await bcrypt.compare(
refreshToken,
user.currentRefreshToken
);
// 만약 isRefreshTokenMatching이 true라면 user 객체를 반환
if (isRefreshTokenMatching) {
return user;
}
}
자, 이제 우린 이렇게 Refresh-Token
을 통해 새로 발급받은 Access-Token
으로 인증이 필요한 권한 (인가)에 접근할 수 있게 된다.
Guard
를 통한 Logout
구현로그아웃은 nestjs에서 제시하는, PassportModule
을 사용해 Guard
와 Strategy
로써 유저 권한을 검증할 것이다.
또한 로그아웃시엔, 로그인 시 생성되어 유저 테이블에 저장된 currentRefreshToken
값을 null
로 수정하고 access-token과 refresh-token에 해당하는 cookie를 모두 삭제하여준다.
✔ logout
핸들러 함수 (AuthController)
// auth.controller.ts
@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'
});
}
✔ JwtRefreshGuard
- Guard
// jwt-refresh.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtRefreshGuard extends AuthGuard('jwt-refresh-token') {}
✔ JwtRefreshStrategy
- Strategy
아래는 위의 JwtRefreshGuard
가 사용하게 될 전략이다.
// jwt-refresh.strategy.ts
import { Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
import { UsersService } from "src/users/users.service";
import { Payload } from "../payload/payload.interface";
import { Request } from "express";
import { User } from "src/users/entities/users.entity";
@Injectable()
export class JwtRefreshStrategy extends PassportStrategy(Strategy, 'jwt-refresh-token') {
constructor(
private readonly userService: UsersService,
) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([
(request) => {
return request?.cookies?.refresh_token;
},
]),
secretOrKey: process.env.JWT_REFRESH_SECRET,
passReqToCallback: true,
})
}
async validate(req: Request, payload: Payload) {
const refreshToken = req.cookies['refresh_token'];
const user: User = await this.userService.getUserIfRefreshTokenMatches(
refreshToken,
payload.id
);
return user;
}
}
JwtRefreshStrategy
는 Passport
의 Strategy
를 상속받은 클래스이다.
super()
내부에서 해당 Strategy
를 상속받는데에 있어서 필요한 설정을 지정할 수 있다.
jwtFromRequest
옵션은 HTTP 요청에서 JWT를 추출할 위치를 지정한다. ExtractJwt.fromExtractos()
를 사용하여 request
객체의 쿠키에서 refresh_token
값을 추출하도록 설정하였다.
다음으로 중요한 부분은 passReqToCallback
을 true
로 설정해야 한다는 것이다.
passReqToCallback
옵션은 validate
함수의 첫 번째 인자로 요청(request)객체를 전달할지 여부를 결정한다. 즉, 이 옵션을 true
로 설정하면 validate
함수의 첫 번째 인자로 request
객체를 전달할 수 있다.
이에 따라, req.cookies['refresh_token']
해당 코드를 사용할 수 있게 된다.
이렇게 요청 시 쿠키를 통해 서버로 전달해준 refreshToken
값이 DB의 테이블내에 저장된 currentRefreshToken
과 일치하는지 검증하기 위해 우린 앞서 refresh()
에서 다뤄준것과 동일하게 getUserIfRefreshTokenMatches()
함수를 사용하여 비교한다.
이처럼 우린 단순히 서비스 내부에서 토큰 검증을 해줄 수도 있지만, 위와 같이 Passport
를 이용해 "Guard"와 "Strategy"를 통해 검증해 줄 수도 있다.
다시, 컨트롤러에서 정의한 logout 함수를 확인해보면
// auth.controller.ts
@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'
});
}
아래와 같이 removeRefreshToken
의 인자로써 req.user.id
를 받는 것을 확인할 수 있다. req
는 any 타입이지만, 우리는 가드와 해당 가드가 사용하는 Strategy를 통해 request
의 프로퍼티로 user
객체를 불러올 수 있게 되었다.
따로 user.id
를 불러오는 함수 혹은 객체를 호출할 필요없이 가드를 통해 깔끔하게 받아오게 된 것이다.
await this.userService.removeRefreshToken(req.user.id);
✔ remove refresh-token from user table - (UserService)
바로 위에서 언급한 removeRefreshToken
함수를 알아보자. 단순하다. 로그아웃 시 refresh-token
과 관련된 데이터 값을 전부 null
로 바꿔준다.
// user.service.ts
async removeRefreshToken(userId: number): Promise<any> {
return await this.userRepository.update(userId, {
currentRefreshToken: null,
currentRefreshTokenExp: null,
});
}
Postman
을 통한 테스트✔ login
로그인 성공 시에 "Access-Token"과 "Refresh-Token` 값을 받게된다.
쿠키에도 잘 저장된 것을 확인할 수 있다.
✔ login 후 유저 테이블 확인
✔ Access-Token을 통한 권한 인증
인증에 성공하였을 시 (가드를 통과하였을 시) 유저 데이터 반환
✔ Access-Token을 통한 권한 인증 - Access-Token의 만료 시간이 지났을 시
지정해준 토큰의 만료시간이 지나게 되면 가드를 통과하지 못하고 권한 접근 에러를 응답한다.
✔ Refresh-Token을 통한 Access-Token 재발급
앞서 로그인 시 부여받은 Refresh-Token
값을 통해 새로운 Access-Token
을 부여받을 수 있다.
✔ 권한에 재접근 - 새로운 Access Token을 통해
새로 부여받은 Access-Token을 통해 다시 권한에 접근할 수 있다. 새로운 Access-Token은 쿠키와 헤더에 담겨 전달된다.
✔ logout - 토큰 삭제
로그아웃 요청이 성공된다면 위와 같이 쿠키가 전부 빈 것을 확인할 수 있다. 동시에 테이블의 토큰 값 및 만료시간 또한 null
로 수정되어 있을 것이다.
이번 포스팅에서 우린 보안적 측면과 UX(유저 경험)를 고려하여 "Refresh-Token"을 통한 인증을 구현해보았다.
Refresh-Token을 구현하는데 있어, 세밀한 작업까진 수행하지 않았지만 어떻게 해당 토큰을 관리하고 인가(Authorization)에 적용할 지에 관해 고민해보았다.
NestJS 공식문서에선 JWT 토큰을 통한 인증 구현법을 설명할 때, PassportModule
을 통한 Guard와 Strategy 패턴으로써 해당 구현을 제시한다. 또한, NestJS를 통해 처음 인증을 구현할 때 찾아보게 될 여러 블로그들에서 해당 구현법을 제시한다.
물론 좋은 방법은 맞지만, 가드와 전략이 어떻게 소통하는지, 그리고 각각의 책임은 무엇인지에대해 모르고 사용할 경우 사용한것만 못하다는 생각이 들었다.
서비스를 통해 토큰 및 유저의 검증로직을 작성할 수 있지만, 조금 더 가독성있고 간결하고 재사용성을 고려한 코드를 만들기 위해 Passport
와 Strategy-Pattern
을 사용하게 된다. 이러한 이유를 확실히 알 필요가 있다.
바로 다음 포스팅이 될 진 모르겠지만, 단순 Refresh-Token
구현에서 그치지 않고 조금 더 심화있게 들어가보고 더 나은 유저 인증을 위한 내용을 다뤄보도록 할 예정이다.
※ 참고자료
Implementing refresh tokens using JWT
엄청 막혔었는데 덕분에 많이 얻어갑니다ㅠㅠ
근데 질문 하나 있는데
refresh_token을 암호화까지 한다음 만료일자랑 같이 디비에 저장하는 이유가 무엇인가요?
어차피 검증은
this.jwtService.verify(refreshToken, {
secret: this.configService.get('JWT_REFRESH_SECRET'),
}) as Payload;
이 함수로 가능하고 refresh_token이 탈취 당했을 경우 디비에서 조회해서 검증하더라도 verify랑 같은 결과라 예상됩니다. 제가 생각했을 때 메리트를 하나 고르자면 bcrypt의 해시함수로 인해 무차별대입공격(이거 맞나?)에 대해 내성이 생긴다 정도인데 제가 생각한게 맞을까요??
안녕하세요. 덕분에 크게 도움이 되었습니다.
한가지 궁금한게 있는데 DB에 저장된 refresh_token의 유효기간이 더 이상 유효하지 않을 경우(만료 일자 < 현재 일자) DB에서 다시 null로 주는 부분은 어떻게 구현할 수 있을까요?
안녕하세요, 정말 자세하게 설명해주신 포스트 덕분에 너무너무 큰 도움 되었습니다..
풀스택 개발을 하고 있는 학생인데 혹시 프론트측이 해야하는 역할과 의문점에 대해서도 질문을 드려도 될까요?
프론트쪽도 같이 하시는 지 몰라서 죄송하지만, 위와 같은 과정으로 작성해야하는 것이 맞는지 여쭈어보고 싶습니다..
안녕하세요. 작성해 주신 글 덕분에 큰 도움을 받을 수 있었습니다. 정말 감사합니다!
작성해 주신 내용에서 한 가지 궁금한 점이 있어 질문 드립니다.
이미 로그인 시 refresh 토큰을 쿠키에 저장을 했다면 refresh API 호출시 Body로 refresh 토큰을 전달하는 것 외에 로그아웃처럼 @UseGuards(JwtRefreshGuard)를 활용하는 방식으로 변경해도 될지 문의드립니다. 만약 가능하다면 Body로 전달하신 이유가 있을까요?
그리고 추가로 해당 블로그 글을 참고하여 제 블로그에도 정리해도 괜찮을까요? 출처는 남겨두겠습니다!
안녕하세요! 글을 너무 유익하게 잘 봐서 처음으로 댓글을 달아봅니다..ㅎㅎ
덕분에 Refresh Token을 NestJS에서 어떤식으로 적용하는지 잘 알 수 있었습니다!
공부 차원에서 제 Velog에 글쓴이님의 내용을 참고해서 정리하고 싶은데 괜찮으실까요?
출처는 포스팅 아래에 남겨놓도록 하겠습니다!