https://github.com/haajinkim/nest_user_test
다시 정리해보면서 정리하는 JWT!
JWT를 여러번 구현해봤지만, 그동안 refresh 토큰 까지는 제대로 구현은 안한거 같다.
그래서 이번에 회원관련 조각 기능을 테스트 하고 만들면서 jwt token 에 대해서 더 깊게 생각해보고 기능을 구현 해봤다.
우선 유저 crud 기능은 넘어가고 바로 로그인 기능으로 넘어 가도록 하자.
우선 JWT 모듈을 임포트 해야한다.
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
RedisModule.forRoot({
config: {
url: config.get('REDIS_URL'),
},
}),
UserModules,
SignModules,
],
controllers: [AppController],
providers: [AppService, PrismaService, JwtStrategy],
})
export class AppModule {}
이건 Root 격인 APP에서 쓸 모듈이고, SignModules 안에 JWT 모듈이 import 되어 있다.
@Module({
imports: [
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: async (configService: ConfigService) => ({
secret: configService.get('JWT_SECRET'),
signOptions: {
expiresIn: `${configService.get('JWT_EXPIRE')}`,
},
}),
}),
],
controllers: [SignController],
providers: [PrismaService, SignService, RedisService],
})
export class SignModules {}
이런 식으로 정의를 하면된다. expire 시간이나, secret 값은 .env 파일을 통해서 관리해야 보안상 좋다. secret 코드가 털리면 해킹에 취약해지기 때문이다.
우선 로그인 코드!
async signIn(body: SignInDto): Promise<{
accessToken: string;
refreshToken: string;
}> {
const user = await this.prisma.user.findUnique({
where: {
userId: body.userId,
},
});
if (!user) throw new BadRequestException(ErrorMsg.userIdNotFound);
const hashValid = await isHashValid(body.password, user.password);
if (!hashValid) throw new BadRequestException(ErrorMsg.passWordNotFound);
const refreshToken = await this.getRefreshToken(user.id, user.name);
const accessToken = await this.setAccessToken(user.id, user.name);
return { accessToken, refreshToken};
}
controller 를 통해서 service 로직으로 들어온다. Dto나, prisma 에 관해서는 다음에 정리를 하도록 하고 오늘은 jwt관련 로직만 살펴보겠다.
우선 전략은
로그인 요청 -> refresh 토큰이 있으면 refresh 토큰과 access 토큰을 발행해서 리턴
refresh 토큰이 없으면, 토큰을 발행해서 리턴 하는 간단한 방식이다.
예를 들어 access 토큰은 30분, refresh는 1주로 시간을 두고, refresh 토큰은 안전한 곳에 보관, 유저가 로그인을 하면 refresh토큰을 통해서 access 토큰을 발행하고 관리한다. refresh 토큰 또한 1주의 expire를 두어 서버의 리소스를 관리하도록 한다.
유저가 사이트를 이용하다가 access 토큰의 유효시간이 끝나면, 프론트에서 요청 refresh 토큰으로 백엔드에 요청을 해서 access 토큰을 재발행 해서 보내주면 되는 방식의 전략이다.
생각을 하다 mysql 이나 rdbms 를 통해서, user 테이블에 토큰을 저장하고 관리하는게 리소스 낭비라는 생각이 들어 redis 를 이용해서 토큰을 관리하고 있다.
async setAccessToken(userId: number, userName: string): Promise<string> {
const payload = { userId, name: userName };
const accessToken = this.jwtService.sign(payload,{expiresIn: this.configService.get('JWT_ACCESS_TOKEN_EXPIRE')})
return accessToken
}
async setRefreshToken(userId: number, userName: string) {
const payload = { userId, name: userName };
const refreshToken = this.jwtService.sign(payload, {
expiresIn: this.configService.get('JWT_REFRESH_TOKEN_EXPIRE'),
});
await this.redisService.setEx(
`jwt:${userId}`,
this.configService.get('JWT_REF_REDIS_EXPIRE'),
refreshToken,
);
return refreshToken ;
}
async getRefreshToken(userId: number, userName: string): Promise<string> {
const refToken = await this.redisService.get(`jwt:${userId}`);
if (refToken) return refToken;
else return await this.setRefreshToken(userId, userName);
}
이렇게 크게 3개의 걸쳐서 로직을 작성하고 jwt 를 관리하는 로직을 작성하였다.
이렇게 관리된 jwt 토큰 값을 어떻게 사용하냐, middleware를 이용하여 사용하면 된다. 저번에 nest middleware 관련글에서 사용한 것 처럼 사용하면 된다.
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: config.get('JWT_SECRET'),
});
}
async validate(payload: any) {
return { userId: payload.userId, username: payload.name };
}
}
이런식으로 사용하면 contrller 에서 useGarde 같은 것을 통해서 auth 를 검증할 수 있는 기능이 완성이 된다!
이번에 다시 한번 구현과 테스트를 해보면서, 기존 session, cookie 방식을 다시한번 이해하게 되고, jwt 장단점도 뚜렷하게 보이는 것 같다. 어떠한 방식이 더 좋은지에 대한 고민은 개발자의 숙명.. 이라고 생각한다.