이번 포스팅을 시작으로 앞으로 진행될 포스팅에선 회원가입 인증을 위한 JWT(JSON Web Token) 생성부터 "Guard"를 이용한 인증과 인가, 그리고 역할에(Roles)따른 권한관리까지에 대해서 알아보고자 한다. 긴 내용과 코드구조를 담을 예정인 관계로 각 파트별로 나눠서 포스팅을 진행할 것이다.
이번 포스팅에선 먼저 암호화(bcrypt를 이용함 __ 해당 포스팅에선 다루지 않습니다.)된 패스워드 생성까지의 회원가입 인증 로직을 베이스로하여 JWT를 생성하고 적용시켜보는 과정을 담고자 한다.
참고로 전체적 코드 진행을 중점으로 하는만큼, JWT가 무엇인지 Guard가 무엇인지 등등 이러한 원천적 개념에 대한 설명은 다루지 않을 예정이다. (공식 사이트나 여러 블로그에서 참조할 수 있으니 꼭 미리 보고 오길 바랍니다.)
또한 이전의 코드에 대해 알고싶고, 함께 진행하고 싶다면 "코드 기어"님께서 작성해 놓으신 해당 깃허브 코드를 참조바랍니다.
https://github.com/CodeGearGit/nestjs-05-jwt
패스워드 암호화 전까지의 코드 설명에 대해 알고싶다면 제가 작성한 포스팅인
@CustomRepository를 활용한 회원가입 인증 구현을 참조 바랍니다. 해당 포스팅과 코드가 이어지는 구조이므로 꼭 보고 오시면 좋습니다 !!!
npm i --save @nestjs/jwt
위의 npm 명령어를 통해 nestjs에서 제공하는 jwt 패키지를 설치함과 동시에 모듈로 불러올 수 있게끔 한다.
// auth.module.ts
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { TypeOrmExModule } from './repository/typeorm-ex.module';
import { UserRepository } from './repository/user.repository';
import { UserService } from './user.service';
@Module({
imports: [
TypeOrmModule.forFeature([User]),
TypeOrmExModule.forCustomRepository([UserRepository]),
// JWT 모듈 등록
JwtModule.register({
secret: 'SECRET_KEY', // JWT Signature의 Secret 값 입력
signOptions: {expiresIn: '300s'}, // JWT 토큰의 만료시간 입력
}),
],
exports: [TypeOrmModule, TypeOrmExModule],
controllers: [AuthController],
providers: [AuthService, UserService],
})
export class AuthModule {}
export interface Payload {
id: number;
username: string;
}
위와 같이 interface를 이용해 payload를 만들어준다. 조금 부가적 설명을 하자면 "payload"는 알다시피 JWT의 인코딩된 값(Header + Payload + Verify Signature) 중 두 번째에 해당하는 부분이다.
payload에는 토큰에 담을 정보들을 기입할 수 있고, name / value의 쌍으로써 나타낼 수 있다. 아마 해당 payload 인터페이스를 보고 기존에 우리가 작성해보았던 (이전 포스팅 참조) dto 객체와 어떤 차이가 있을까 생각해 볼 수도 있다.
export class UserDto {
username: string;
password: string;
}
사실 payload를 PayloadDto라는 객체로 만들어도 상관없지만 코드명에 따라 분리시키는 것이 나중에 좋을 것이다. 하지만 이런 것과 별개로 "가장 중요한" payload만의 특징이라 함은 payload는 "암호화 되어있지 않다"는 것이다.
즉, 토큰에는 사용자 비밀번호, 계좌번호 등등의 보안상 위험한 개인정보는 절대 넣어선 안된다. 해킹의 위험성이 있기 때문이다. 즉, 우리가 작성한 Payload 인터페이스도 이를 준수해 password는 기입해주지 않는다.
// auth.service.ts
import { HttpException, HttpStatus, Injectable, UnauthorizedException } from '@nestjs/common';
import { UserDto } from './dto/user.dto';
import { UserService } from './user.service';
import * as bcrypt from 'bcrypt';
import { Payload } from './security/payload.interface';
import { User } from './entity/user.entity';
import { JwtService } from '@nestjs/jwt';
@Injectable()
export class AuthService {
constructor(
private userService: UserService,
private jwtService: JwtService,
){}
async registerUser(newUser: UserDto): Promise<UserDto> {
let userFind: UserDto = await this.userService.findByFields({
where: { username: newUser.username }
})
if(userFind) {
throw new HttpException("Username already used!", HttpStatus.BAD_REQUEST);
}
return await this.userService.save(newUser);
}
async validateUser(userDto: UserDto): Promise<{accesstoken:string} | undefined> {
let userFind: User = await this.userService.findByFields({
where: { username: userDto.username}
});
const validatePassword = await bcrypt.compare(userDto.password, userFind.password);
if(!userFind || !validatePassword) {
throw new UnauthorizedException();
}
// payload 등록해주기 !!
// don't give the password, it's not good way to authorize with JWT!
const payload: Payload = { id: userFind.id, username: userFind.username };
return {
accessToken: this.jwtService.sign(payload),
}
}
AuthService
모듈에서 우린 토큰을 생성할 수 있다. 기존에 로그인 인증을 하는 메서드인 validateUser()
에서 진행할 수 있다.
async validateUser(userDto: UserDto): Promise<{accesstoken:string} | undefined> {
// userFind가 UserDto가 아닌 User를 타입으로 가지도록 변경함
let userFind: User = await this.userService.findByFields({
where: { username: userDto.username}
});
const validatePassword = await bcrypt.compare(userDto.password, userFind.password);
if(!userFind || !validatePassword) {
throw new UnauthorizedException();
}
// payload 등록해주기 !!
// don't give the password, it's not good way to authorize with JWT!
const payload: Payload = { id: userFind.id, username: userFind.username };
return {
accessToken: this.jwtService.sign(payload),
}
}
Payload
인터페이스를 타입으로 가지는 payload
객체를 생성할 때, 우린 dto객체를 통해서 데이터 값을 얻는 userFind
객체를 통해 id와 username값을 얻어올 수 있다. 하지만 우리는 UserDto
객체에서 id
값을 일전에 기입해 주지 않았다.
즉, 기존의 UserDto
객체에 id를 추가해주어야하는 방법으로 진행해야 한다. 하지만 우리는 편의상 UserDto
객체가 아닌 id값이 포함되어있는 User
엔티티 자체를 타입으로 참조하도록 진행한다.
이는 UserService
모듈에서 수정 가능하다.
@Injectable()
export class UserService {
constructor(
//@InjectRepository(UserRepository)
private userRepository: UserRepository
){}
// 리턴값을 UserDto -> User로 변경
async findByFields(options: FindOneOptions<UserDto | User>): Promise<User | undefined> {
return await this.userRepository.findOne(options);
}
async save(userDto: UserDto): Promise<UserDto | undefined> {
await this.transformPassword(userDto);
return await this.userRepository.save(userDto);
}
async transformPassword(user: UserDto): Promise<void> {
user.password = await bcrypt.hash(
user.password, 10,
);
return Promise.resolve();
}
}
조금 더 자세히 말하자면 AuthService
의 validateUser()
메서드 중 userFind
가 사용하게 되는 findByFields()
의 리턴값을
Promise<UserDto | undefined>
에서
Promise<User | undefined>
로 변경해주면 된다는 뜻이다.
사실 이 부분은 이전의 포스팅을 통해 이어져서 보는 분들을 위한 설명이다. 처음부터 현재 포스팅의 내용을 보는 분들한테는 적용되지 않는다.
자 , 그럼 다시 AuthService
의 validateUser()
메서드로 돌아와보자.
async validateUser(userDto: UserDto): Promise<{accesstoken:string} | undefined> {
// userFind가 UserDto가 아닌 User를 타입으로 가지도록 변경함
let userFind: User = await this.userService.findByFields({
where: { username: userDto.username}
});
const validatePassword = await bcrypt.compare(userDto.password, userFind.password);
if(!userFind || !validatePassword) {
throw new UnauthorizedException();
}
// payload 등록해주기 !!
// don't give the password, it's not good way to authorize with JWT!
const payload: Payload = { id: userFind.id, username: userFind.username };
return {
accessToken: this.jwtService.sign(payload),
}
}
해당 메서드의 리턴값을 통해 우린 jwt 토큰을 반환 받을 수 있다.
return {
accessToken: this.jwtService.sign(payload),
}
JwtService에서 지원하는 sign()
함수를 이용하여 User의 정보가 담긴 payload
를 넣고 최종 AccessToken을 반환 받는 것이다.
(참고로 JwtService 모듈을 사용하기 위해선, constructor에 등록해줘야한다. 또한, validateUser()
의 리턴값으로 accessToken: ~~
을 받아온 만큼 리턴 타입 또한 해당 객체로 지정해준다 --> Promise<{accesstoken:string} | undefined>
)
기존 controller 코드
// auth.controller.ts
import { Body, Controller, Post, Req } from '@nestjs/common';
import { Request } from 'express';
import { AuthService } from './auth.service';
import { UserDto } from './dto/user.dto';
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService){}
@Post('/register')
async registerAccount(@Req() req: Request, @Body() userDto: UserDto): Promise<any>{
return await this.authService.registerUser(userDto);
}
@Post('/login')
async login(@Body() userDto: UserDto, @Res() res: Response): Promise<any> {
return await this.authService.validateUser(userDto)
}
}
수정 controller 코드
컨트롤러에서 서비스에서 생성한 토큰의 값을 받아왔을 때, 처리할 부분을 생성해준다.
import { Body, Controller, Get, Post, Req, Res, UseGuards } from '@nestjs/common';
import { Request, Response } from 'express';
import { AuthService } from './auth.service';
import { UserDto } from './dto/user.dto';
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService){}
@Post('/register')
async registerAccount(@Req() req: Request, @Body() userDto: UserDto): Promise<any>{
return await this.authService.registerUser(userDto);
}
@Post('/login')
async login(@Body() userDto: UserDto, @Res() res: Response): Promise<any> {
const jwt = await this.authService.validateUser(userDto);
res.setHeader('Authorization', 'Bearer '+ jwt.accessToken);
return res.json(jwt);
}
유저가 로그인을 하였을 때, 토큰을 발급받도록 할 것이므로 login()
메서드에서 서비스단을 통해 받아온 jwt 토큰을 기입해준다.
그 후 중요한 부분은 바로 헤더 지정이다.
res.setHeader('Authorization', 'Bearer '+ jwt.accessToken);
setHeader()
메서드의 역할을 설명하기전 먼저 위의 로그인 POST 요청에 대한 전반적 이해가 필요하다.
💨동작 방식
먼저 사용자가 로그인 정보를 Body에 실어서 (@Body
를 이용) 서버에 보낸다.
서버는 DB에서 해당 로그인 정보를 확인한다. (우리가 만들어준 AuthService
의 validateUser()
이 그 역할을 한다.)
확인(검증)이 되면 JWT 토큰을 발급한다. 컨트롤러의 login()
메서드의 인자로 Response 객체를 받아온 것을 확인할 수 있을 것이다.(@Res
이용)
발급된 JWT 토큰을 사용자가 받는다.
사용자는 인증이 필요한 요청마다 JWT 토큰을 헤더에 실어 보낸다. (해당 커스텀 해더 지정을 controller에서 res.setHeader()
를 통해 지정해줄 수 있다.)
서버는 사용자가 보낸 JWT 토큰을 복호화(디코딩)를 통해 검증한다.
검증이 완료되면 서버는 사용자에게 요청 데이터를 보내준다.
res.setHeader()
의 첫 번째 인자로는 헤더의 이름(name), 두 번째 인자로는 운반 수단을 뜻하는 'Bearer'라는 문자와 AuthService
의 validateUser()
의 리턴 값으로 받아준 accessToken: this.jwtService.sign(payload)
을 받아준다.
그 후 최종 리턴 값으로 (return res.json(jwt);
) json 형태의 jwt객체를 받아온다. 당연히 해당 리턴 값은
{
"accessToken" : 토큰 값
}
형태로 반환 될 것이다.
기존에 회원가입 등록시킨 유저의 데이터를 바탕으로 로그인을 수행해보자.
다음과 같이 로그인 정보를 Body에 실어 보내주었고 서버의 응답으로 JSON 타입의 JWT 토큰을 받아올 수 있다.
위에 작업을 통해서 JWT 토큰을 얻게 되었지만 사실 이것이 완전한 값은 아니다. 우리는 이를 jwt.io에서 확인할 수 있다.
우리가 응답으로 받아온 토큰을 복호화 과정을 위해 기입해주었더니 "Invalid Signature"라는 문구와 함께 유효하지 않다고 알려주었다. 이는 JWT 토큰의 세 번째 요소인 "Signature Key"를 입력해주지 않았기 때문이다.
우리가 앞서 Authmodule
에서 지정해 준 secret 값을 입력해주면 된다.
@Module({
imports: [
TypeOrmModule.forFeature([User, UserAuthority]),
TypeOrmExModule.forCustomRepository([UserRepository, UserAuthorityRepository]),
JwtModule.register({
secret: 'SECRET_KEY', // --> 해당 secret key 값
signOptions: {expiresIn: '300s'},
}),
PassportModule,
],
exports: [TypeOrmModule, TypeOrmExModule],
controllers: [AuthController],
providers: [AuthService, UserService, JwtStrategy],
})
"Signature Verified"란 문구와 함께 정상적으로 값들이 만들어 진 것을 확인할 수 있다.
해당 헤더 값은 우리가 컨트롤러에서 res.setHeader()
로 지정해 준
res.setHeader('Authorization', 'Bearer '+ jwt.accessToken);
해당 응답으로 나타내 진 것이다.
이렇게 우린 이번 포스팅에서 JWT 패키지를 불러와 JWT 토큰을 생성하고 값을 받아오는 것 까지 구현해 보았다.
다음 포스팅에선 "Guard"를 이용하여 해당 JWT 토큰을 인증하는 부분을 구현해보고자 한다.