서론
지난글에 이어, Nest에서 JWT를 사용한 인증을 진행해보려한다.
[NestJS] JWT Token 인증 - (1) JWT 토큰이란?
NestJS에서 제공하는 공식 문서의 인증 관련 페이지를 참조했다.
설치한 모듈
passport (@nestjs/passport, passport, passport-local, @types/passport-local)
jwt (@nestjs/jwt, passport-jwt, @types/passport-jwt)
Passport
Node에서 인증을 적용할 때 사용할 수 있는 미들웨어로, 클라이언트가 서버에 권한을 요청할 자격이 있는지 인증할 때 passport 미들웨어를 사용한다. Nest에서는 토큰 인증에 있어서, passport의 사용을 권장하고 있다.
간단히 살펴보면, 유저의 자격 증명을 통해 사용자를 인증하며, 인증된 상태관리를 JWT와 같은 토큰을 통해 수행하며, Request에서 추가로 사용할 수 있게 사용자의 추가 정보를 첨부할 수 있다고 되어있다.
패키지 설치 시 @nestjs/passport 패키지를 반드시 요한다고 공식문서에 나타나 있다.
Nest에서는, PassportStrategy 클래스를 확장하여 구성하며, 하위 클래스에서 super() 메서드를 통해 파라미터를 전달하며 하위 클래스에서 validate() 메서드를 구현하여 인증에 대한 콜백을 수행한다고 명시되어 있다.
즉, 사용자가 존재하는지, 존재한다면 validate되는지를 따져 원하는 리턴값을 반환시킬 수 있고, 에러도 만들어 낼 수 있다.
Passport Strategy
이제 이 미들웨어의 전략을 구상(??)해야하는데, 기본적으로 공식 문서나 기타 개발 블로그 등 자료들에서 볼 수 있듯 기본 전략에서 내가 사용하는 서비스에 맞게 변경해주었다.
또한 들어가기에 앞서, @UseGuards 데코레이터를 사용하여 어떤 전략을 사용할 지 명시해주면 @nestjs/passport가 자동으로 명시된 전략을 호출한다.
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}
예를들어, 위 AuthGuard는 local이라는 파라미터를 Passport에게 전달하여, 해당 Guard사용 시 LocalStrategy를 사용하게 한다.
그리하여 세가지 strategy를 만들 수 있었다.
Strategy
1. 로그인 시 사용하는 LocalStrategy
2. 로그인 후 인증 전반을 담당하는 JwtStrategy
3. Access Token 만료 시 Refresh Token을 검증하는 RefreshStrategy
로그인 (LocalStrategy)
로그인 시 수행하는 전략은 id와 pw를 가지고, 흔히 구현된 로그인 로직으로 보내어 유저 정보가 일치하는지 validate하는 전략이다.
import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from '../auth.service';
import { UserService } from 'src/user/user.service';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private authService: AuthService, private userService: UserService) {
super({ usernameField: 'id', passwordField: 'pw' });
}
async validate(id: string, password: string): Promise<any> {
const user = await this.authService.validateUser(id, password);
if (!user) {
throw new UnauthorizedException('아이디 혹은 비밀번호를 확인해주세요.');
}
return user;
}
}
유저 검증을 위한 서비스의 validateUser 코드이며, 유저의 비밀번호는 암호화되어 있기 때문에 복호화 메서드를 통해 검증을 추가로 진행하고, 유저 정보를 리턴할 것이기 때문에 유저의 비밀번호는 삭제해주었다.
유저 객체 자체를 클라이언트로 반환하는 것은 아닌데, 굳이 비밀번호 삭제가 필요한 지는 의문이긴 하다. 토큰 생성 시에도 user의 PK와 id만 담긴다. 확실한 지식이 없어 혹여 불필요한 비밀번호가 유출 될까 삭제하긴 했는데.. 이 부분을 좀 더 알아봐야겠다. (조언좀 부탁드립니다!!)
//validate for stragety
async validateUser(id: string, password: string): Promise<any> {
const user = await this.userService.findById(id);
if (!user) {
throw new UnauthorizedException('가입된 유저가 아닙니다.');
}
if (user && (await this.isMatch(password, user.pw))) {
delete user.pw;
return user;
} else {
throw new BadRequestException('패스워드를 확인해주세요');
}
}
LocalStrategy에서, 올바르게 유저를 검증하는데 성공했다면, user객체를 반환하게 코드를 만들어놓았기 때문에, 로그인 유저 검증에 성공한다면 Request객체에 user가 담기게되고, 우리는 이를 이용해서 로그인 처리(토큰 생성)를 수행할 수 있게 된다.
@Post('/signin')
@UseGuards(LocalAuthGuard)
@ApiOperation({ description: '로그인' })
@ApiResponse({ status: 200, description: 'JWT_TOKEN을 발급합니다.', type: JwtTokenDto })
signin(@Request() req) {
return this.authService.signin(req.user);
}
async signin(user: UserEntity) {
//1. user validate with hashed pw
if (this.validateUser) {
user.last_join_date = new Date();
//1-1. 유저의 마지막 로그인 시점 업데이트
const signinDateUpdate = this.userService.updateSignin(user);
//1-2. when validated >> token created
if (signinDateUpdate) return this.createToken(user);
}
}
//access token 발급
createToken(user: UserEntity) {
const no = user.no;
const tokenDto = new JwtTokenDto(this.createAccessToken(user), this.createRefreshToken(no));
this.setCurrentRefreshToken(tokenDto.refresh_token, no);
return tokenDto;
}
createAccessToken(user: UserEntity) {
const cu = { no: user.no, id: user.id };
return this.jwtService.sign(cu, {
secret: this.configService.get('JWT_SECRET'),
expiresIn: this.configService.get('expiresIn')
});
}
createRefreshToken(no: number) {
return this.jwtService.sign({ no }, {
secret: this.configService.get('JWT_SECERT'),
expiresIn: this.configService.get('expiresInRefresh')
});
}
토큰 생성시, payload에는 민감정보를 전달하면 안되기 때문에, pk와 id만 담았다. (pk는 좀 애매할 수 있을것 같다.)
생성 시 서명(signature)으로 설정해둔 시크릿 키와, 유효기간을 담았다.
createAccessToken(user: UserEntity) {
const cu = { no: user.no, id: user.id };
return this.jwtService.sign(cu, {
secret: this.configService.get('JWT_SECRET'),
expiresIn: this.configService.get('expiresIn')
});
}
createRefreshToken(no: number) {
return this.jwtService.sign({ no }, {
secret: this.configService.get('JWT_SECERT'),
expiresIn: this.configService.get('expiresInRefresh')
});
}
정상적으로 로그인이 되면 아래처럼, access_token과 refresh_token이 생성되고 리턴된다.
토큰 검증(JwtStrategy)
토큰 검증을 수행하기로 한 Strategy로, PassportStrategy에 JWT 전략에 필요한 옵션들을 보낸다.
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
algorithms: ['HS256'],
secretOrKey: configService.get('JWT_SECRET'),
});
}
async validate(payload: any) {
return { ...payload };
}
}
//JWT의 추출방법을 지정한다.
//fromAuthHeaderAsBearerToken()을 사용하여 헤더에서
//Bearer Token을 Authorization 헤더에서 추출한다.
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
//토큰 만료 기간을 무시할지? false는 만료된 토큰 자체를 거부한다.
ignoreExpiration: false,
//서명에 사용할 알고리즘을 지정한다.
algorithms: ['HS256'],
//서명에 사용되는 비밀키로 환경변수에서 가져왔다.
secretOrKey: configService.get('JWT_SECRET'),
이제, 헤더에서 토큰을 추출하여 토큰이 있을 경우, 디코딩된 payload를 validate로 가져올 수 있다.
여기서는, 유저의 PK와 ID가 담겨있고, iat와 exp가 추가로 담겨있다 (iat : 발급시간, exp : 만료시간)
마찬가지로, @UseGuard를 이용해서 사용할 수 있으며, 컨트롤러 전역에 토큰 인증이 필요할 경우, 전역으로도 사용할 수 있다. 아래는 토이 프로젝트의 유저 컨트롤러 일부이다.
import { Controller, Get, Post, Body, Patch, Param, Delete, Query, Req, UseGuards, Request, Put } from '@nestjs/common';
import { ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger';
import { UserService } from './user.service';
import { Jwt } from 'src/decorator/CurrentUserDecorator';
import CurrentUser from 'src/auth/dto/currentUser.dto';
import { JwtAuthGuard } from 'src/auth/guard/jwt-auth.guard';
import UserInvestmentDataPutDto from './dto/user-investmentData.dto';
@ApiTags('user')
@Controller('/api/v1/user')
@UseGuards(JwtAuthGuard)
export class UserController {
constructor(private userService: UserService) {}
@Post('/check')
@ApiOperation({ description: '토큰 검증' })
async checkToken(@Request() req) {
return req.user;
}
@Get('/myInvestmentData')
@ApiOperation({ description: '투자내역 조회' })
async getInvestmentDataByYyyyMm(@Jwt() cu: CurrentUser, @Query('yyyymm') yyyymm: string) {
return await this.userService.getInvestmentDataByYyyyMm(cu.no, yyyymm);
}
@Put('/myInvestmentData')
@ApiOperation({ description: '투자내역 입력(by day) '})
async putInvestmentData(@Jwt() cu: CurrentUser, @Body() userInvDataPutDto: UserInvestmentDataPutDto) {
return await this.userService.putInvestmentData(cu.no, userInvDataPutDto);
}
@Delete('/myInvestmentData/:yyyymm/:day')
@ApiOperation({ description: '투자내역 초기화(day)' })
async deleteInvestmentData(@Jwt() cu: CurrentUser, @Param() params) {
const { yyyymm, day } = params;
return await this.userService.deleteInvestmentData(cu.no, yyyymm, day)
}
}
마무리
Refresh Token관련 내용은 다음 포스팅에서..
2023.04 ~ 백엔드 개발자의 기록
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!