nest g resource -> auth -> RESTapi
인증과 관련된 부분을 위해서 auth 폴더를 생성합니다. 그리고 항상 모듈이 app.module.ts에 들어갔는지 체크해야합니다.
@Module({
imports: [
PostsModule,
TypeOrmModule.forRoot({
type: 'postgres',
host: '127.0.0.1',
port: 5433,
username: 'postgresql',
password: 'postgresql',
database: 'postgresql',
entities: [
PostsModel,
UsersModel,
],
synchronize: true,
}),
UsersModule,
AuthModule // 추가
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
이제 만드려고하는 기능을 나열해봅시다.
* 우리가 만드려는 기능
*
* 1) registerWithEmail
* - email, nickname, password를 입력받고 사용자를 생성한다.
* - 생성이 완료되면 accessToken과 refreshToken을 반환한다. -> 바로 로그인을 진행 해주는 것
*
* 2) loginWithEmail
* - email,password를 입력하면 사용자 검증을 진행한다.
* - 검증이 완료되면 accessToken과 refreshToken을 반환한다.
*
* 3) loginUser
* - (1)과 (2)에서 필요한 accessToken과 refreshToken을 반환하는 로직
*
* 4) signToken
* - (3)에서 필요한 accessToken과 refreshToken을 sign하는 로직
*
* 5) authenticateWithEmailAndPassword
* - (2)에서 로그인을 진행할때 필요한 기본적인 검증 진행
* 1. 사용자가 존재하는지 확인(null)
* 2. 비밀번호가 맞는지 확인
* 3. 모두 통과되면 찾은 사용자 정보 반환
* 4. loginWithEmail에서 반환된 데이터를 기반으로 토큰 생성
*/
먼저 jwt 관련 패키지와 Bcrypt를 설치합니다. 그리고 auth.module.ts에 imports를 합니다.
yarn add @nestjs/jwt bcrypt
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtModule } from '@nestjs/jwt';
@Module({
imports: [
JwtModule.register({}),
],
controllers: [AuthController],
providers: [AuthService],
})
export class AuthModule {}
그리고 auth.service.ts에 코드를 작성합니다. 일단 secret키를 넣는 코드가 있어서 const 폴더를 만들고 내부에 auth.const.ts라는 파일에 넣습니다.
export const JWT_SECRET = 'codefactory';
여기서는 typescript의 유틸리티 Pick을 이용해서 email과 id만 뽑아옵니다. 또한 jwtService에 sign이라는 메소드를 이용해서 코드를 작성합니다.
@Injectable()
export class AuthService {
constructor(
private readonly jwtService: JwtService,
) {}
/**
* Payload에 들어갈 정보
*
* 1) email
* 2) sub = id
* 3) type = 'access' | 'refresh'
*/
signToken(user: Pick<UsersModel, 'email' | 'id'>) { // 유틸리티 사용, UsersModel에서 전부 X 골라서 O
const payload = {
email: user.email,
sub: user.id,
type: isRefreshToken ? 'refresh' : 'access',
}
return this.jwtService.sign(payload, {
secret: JWT_SECRET,
expiresIn: isRefreshToken ? 3600 : 300, // seconds
});
}
}
nest.js를 하면 자주 보는 에러를 보겠습니다. 의미는 AuthService 0번째 요소인 jwtService가 존재하는지 AuthModule에서 확인을 해달라고 하는 것입니다.
즉, 에러가 발생한 곳으로 가서 의존성을 부분을 체크(Module)하면 해결할 수 있습니다.
loginUser에서 필요한 함수가 signToken이기 때문에 코드를 작성하겠습니다.
@Injectable()
export class AuthService {
constructor(
private readonly jwtService: JwtService,
) {}
signToken(user: Pick<UsersModel, 'email' | 'id'>, isRefreshToken: boolean) {
const payload = {
email: user.email,
sub: user.id,
type: isRefreshToken ? 'refresh' : 'access',
}
return this.jwtService.sign(payload, {
secret: JWT_SECRET,
expiresIn: isRefreshToken ? 3600 : 300, // seconds
});
}
// 추가
loginUser(user: Pick<UsersModel, 'email' | 'id'>) {
return {
accessToken: this.signToken(user, false),
refreshToken: this.signToken(user, true),
}
}
}
* 1. 사용자가 존재하는지 확인 (email) -> userService에다가 기능을 만들기
* 2. 비밀번호가 맞는지 확인
* 3. 모두 통과되면 찾은 사용자 정보 반환
import * as bcrypt from 'bcrypt'; // 추가 필요!!
.
.
constructor(
private readonly jwtService: JwtService,
private readonly usersService: UsersService, // 추가
) {}
.
.
async authenticateWithEmailAndPassword(user: Pick<UsersModel, 'email' | 'password'>) {
/**
* 1. 사용자가 존재하는지 확인 (email) -> userService에다가 기능을 만들기
* 2. 비밀번호가 맞는지 확인
* 3. 모두 통과되면 찾은 사용자 정보 반환
*/
const existingUser = await this.usersService.getUserByEmail(user.email); // 사용자관련 메소드는 해당 모듈에서 관리하자
if (!existingUser) throw new UnauthorizedException('존재하지 않는 사용자입니다.');
/**
* bcrypt.compare(1, 2) 파라미터
*
* 1. 입력된 비밀번호
* 2. 기존 해시 (hash) -> 사용자 정보에 저장된 hash
*/
const passOK = await bcrypt.compare(user.password, existingUser.password); // return true | false
if (!passOK) throw new UnauthorizedException('비밀번호가 틀렸습니다.');
return existingUser;
}
그리고 users.service.ts에 getUsersByEmail을 가지고오자. 되도록 사용자 관련 메소드는 해당 모듈에 작업하는 것이 좋습니다. 또한 UsersService를 사용하려면 module에서 작업을 좀 해야합니다.
async getUserByEmail(email: string) {
return this.usersRepository.findOne({
where: {
email,
},
});
}
exports를 해야한다는 것 잊지말자. exports된 UsersService를 다른 곳에서도 사용이 가능하게 된다.
@Module({
imports: [
TypeOrmModule.forFeature([
UsersModel,
])
],
exports: [UsersService],
controllers: [UsersController],
providers: [UsersService],
})
export class UsersModule {}
@Module({
imports: [
JwtModule.register({}),
UsersModule,
],
controllers: [AuthController],
providers: [AuthService],
})
export class AuthModule {}
async loginWithEmail(user: Pick<UsersModel, 'email' | 'password'>) {
const existingUser = await this.authenticateWithEmailAndPassword(user);
return this.loginUser(existingUser);
}
HASH_ROUND 관련 문서
https://www.npmjs.com/package/bcrypt
export const JWT_SECRET = 'codefactory';
export const HASH_ROUNDS = 10; // 추가
async registerWithEmail(user: Pick<UsersModel, 'nickname' | 'email' | 'password'>) {
const hash = await bcrypt.hash( // 내부에 salt가 내장
user.password,
HASH_ROUNDS, // user.password를 몇번 해싱할 것인지
);
const newUser = await this.usersService.createUser({
...user,
password: hash,
});
return this.loginUser(newUser);
}
async createUser(user: Pick<UsersModel, 'email' | 'nickname' | 'password'>) {
// 1) nickname 중복이 없는지 확인
// exist() -> 만약에 조건에 해당되는 값이 있으면 true
const nicknameExists = await this.usersRepository.exists({
where: {
nickname: user.nickname,
},
});
if (nicknameExists) throw new BadRequestException('이미 존재하는 nickname 입니다.!')
const emailExists = await this.usersRepository.exists({
where: {
email: user.email,
},
});
if (emailExists) throw new BadRequestException('이미 가입한 email 입니다.!')
const userObject = this.usersRepository.create({
// Object 형태로 넣기
nickname: user.nickname,
email: user.email,
password: user.password,
});
const newUser = await this.usersRepository.save(userObject);
return newUser;
}
@Post('login/email')
postLoginEmail(
@Body('email') email: string,
@Body('password') password: string,
) {
return this.authService.loginWithEmail({
email,
password,
});
}
@Post('register/email')
postRegisterEmail(
@Body('nickname') nickname: string,
@Body('email') email: string,
@Body('password') password: string,
) {
return this.authService.registerWithEmail({
nickname,
email,
password,
});
}
회원가입 테스트
{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImNvZGVmYWN0b3J5QGdtYWlsLmNvbSIsInN1YiI6MSwidHlwZSI6ImFjY2VzcyIsImlhdCI6MTcwNTg0MjU1MiwiZXhwIjoxNzA1ODQyODUyfQ.k6JtM6_XecS7wd4LNBJCWxbvg8HCPtYs7iAjNuU0_Fw",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImNvZGVmYWN0b3J5QGdtYWlsLmNvbSIsInN1YiI6MSwidHlwZSI6InJlZnJlc2giLCJpYXQiOjE3MDU4NDI1NTIsImV4cCI6MTcwNTg0NjE1Mn0.dy5K2KdWdNYL2RDKwPGiLPAZZRCE4VAvwo0X6s_rxDM"
}
로그인 테스트
{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImNvZGVmYWN0b3J5QGdtYWlsLmNvbSIsInN1YiI6MSwidHlwZSI6ImFjY2VzcyIsImlhdCI6MTcwNTg0MjY2NywiZXhwIjoxNzA1ODQyOTY3fQ.5f6WD4iGKBlChhP_lAOJg4MK8uWy1vjxhn_nDxZMWlo",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImNvZGVmYWN0b3J5QGdtYWlsLmNvbSIsInN1YiI6MSwidHlwZSI6InJlZnJlc2giLCJpYXQiOjE3MDU4NDI2NjcsImV4cCI6MTcwNTg0NjI2N30.FPekKWWg_s0CIXTRYf4XkOGBSjJzfgo78CY-7-6lIO0"
}
컨트롤러에서 헤더를 받고 authorization 부분만 추출 후, 서비스 로직으로 넘기는 과정입니다.
/**
* Header로부터 토큰을 받을 때
*
* {authorization: 'Basic {token}'}
* {authorization: 'Bearer {token}'}
*/
async extractTokenFromHeader(header: string, isBearer: boolean) {
const splitToken = header.split(' '); // 'Basic {token}' -> [Basic, {token}] / 'Bearer {token}' -> [Bearer, {token}]
const prefix = isBearer ? 'Bearer' : 'Basic';
if (splitToken.length !== 2 || splitToken[0] !== prefix) throw new UnauthorizedException('잘못된 토큰입니다.');
const token = splitToken[1];
}
기존의 auth.controller.ts를 변경할 것입니다.
@Post('login/email')
postLoginEmail(
@Body('email') email: string,
@Body('password') password: string,
) {
return this.authService.loginWithEmail({
email,
password,
});
}
.
.
변경
.
.
import { Body, Controller, Post, Headers } from '@nestjs/common';
@Post('login/email')
postLoginEmail(
@Headers('authorization') rawToken: string, // {authorization: 'Bearer {token}'} -> 'Bearer {token}' 가져옴
) {
// email:password -> base64 -> asdasdkjb12kjebdkas -> email:password
const token = this.authService.extractTokenFromHeader(rawToken, false); // basic 토큰이기 때문에 false
const credentials = this.authService.decodeBasicToken(token);
return this.authService.loginWithEmail(credentials);
}
이후에 auth.service.ts에 로직을 작성합니다.
extractTokenFromHeader(header: string, isBearer: boolean) {
const splitToken = header.split(' '); // 'Basic {token}' -> [Basic, {token}] / 'Bearer {token}' -> [Bearer, {token}]
const prefix = isBearer ? 'Bearer' : 'Basic';
if (splitToken.length !== 2 || splitToken[0] !== prefix) throw new UnauthorizedException('잘못된 토큰입니다.');
const token = splitToken[1];
return token;
}
/**
* Basic: asdljn1n2l1k2n213l1j23n
*
* 1) asdljn1n2l1k2n213l1j23n -> email:password
* 2) email:password -> [email, password]
* 3) {email: email, password: password}
*/
decodeBasicToken(base64String: string) {
const decoded = Buffer.from(base64String, 'base64').toString('utf8'); // Node.js에서 제공해주는 기능
const split = decoded.split(':');
if (split.length !== 2) throw new UnauthorizedException('잘못된 유형의 토큰입니다.');
const email = split[0];
const password = split[1];
return {
email,
password,
}
}
완성 후 테스트를 합니다. https://www.base64decode.org/ko/
인코딩도 가능하고 디코딩도 가능하다.
포스트맨으로 테스트를 합시다. 디코딩된 문자열을 로그인하는 엔드포인트 헤더에 Authorization으로 넣고 요청을 보내봅시다. 그러면 올바르게 토큰이 발급이 됩니다.
{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImNvZGVmYWN0b3J5MTBAY29kZWZhY3RvcnkuYWkiLCJzdWIiOjIsInR5cGUiOiJhY2Nlc3MiLCJpYXQiOjE3MDU4Nzc0NDcsImV4cCI6MTcwNTg3Nzc0N30.JD0W9CRh84A87NWcP3rGefcLg3eYFkdrPxsl-8JrlSI",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImNvZGVmYWN0b3J5MTBAY29kZWZhY3RvcnkuYWkiLCJzdWIiOjIsInR5cGUiOiJyZWZyZXNoIiwiaWF0IjoxNzA1ODc3NDQ3LCJleHAiOjE3MDU4ODEwNDd9.5w174IrNn28U0u42gkwBTzMY6ngYdVSks9RhvNxmvfY"
}
하지만 이제는 email과 password를 인코딩하지 않고 요청을 보내게 되면 500 에러가 나옵니다.
{
"statusCode": 500,
"message": "Internal server error"
}
만약 token의 일부를 조금만 삭제하고 요청을 하게 되면 어떻게 될까?
{
"message": "잘못된 유형의 토큰입니다.",
"error": "Unauthorized",
"statusCode": 401
}
토큰 재발급을 위해서 auth.service.ts에 코드를 작성합니다.
/**
* 토큰 검증
*/
verifyToken(token: string) {
// verify는 jwt 패키지에 존재
return this.jwtService.verify(token, {
secret: JWT_SECRET,
});
}
rotateToken(token: string, isRefreshToken: boolean) {
const decoded = this.jwtService.verify(token, {
secret: JWT_SECRET,
});
/**
* sub: id
* email: email
* type: 'access' | 'refresh'
*/
if (decoded.type !== 'refresh') throw new UnauthorizedException('토큰 재발급은 refresh 토큰으로만 가능합니다.');
return this.signToken({ // 토큰 발급받기
...decoded,
}, isRefreshToken);
}
코드의 일반화를 잘했기 때문에, 1~2줄로 간결하게 만들어 졌습니다.
@Post('token/access')
postTokenAccess(
@Headers('authorization') rawToken: string
) {
const token = this.authService.extractTokenFromHeader(rawToken, true);
const newToken = this.authService.rotateToken(token, false);
return {
// 반환 형태 -> {accessToken: {token}}
accessToken: newToken,
}
}
@Post('token/refresh')
postTokenRefresh(
@Headers('authorization') rawToken: string
) {
const token = this.authService.extractTokenFromHeader(rawToken, true);
const newToken = this.authService.rotateToken(token, true);
return {
// 반환 형태 -> {refreshToken: {token}}
refreshToken: newToken,
}
}
포스트맨으로 확인을 합시다.
{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImNvZGVmYWN0b3J5MTBAY29kZWZhY3RvcnkuYWkiLCJzdWIiOjIsInR5cGUiOiJhY2Nlc3MiLCJpYXQiOjE3MDU4NzkzMjYsImV4cCI6MTcwNTg3OTYyNn0.nlNECqQMOUHkVUjPA1BVJHtic2IKcgC1_AzTSav99-U",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImNvZGVmYWN0b3J5MTBAY29kZWZhY3RvcnkuYWkiLCJzdWIiOjIsInR5cGUiOiJyZWZyZXNoIiwiaWF0IjoxNzA1ODc5MzI2LCJleHAiOjE3MDU4ODI5MjZ9.a2hSFdLOaE9Xunckyee6bNS70ndxWBplLOw8wt_jNnc"
}
refresh 토큰을 가지고 access 토큰을 재발급 합시다.
{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImNvZGVmYWN0b3J5MTBAY29kZWZhY3RvcnkuYWkiLCJ0eXBlIjoiYWNjZXNzIiwiaWF0IjoxNzA1ODc5MzU5LCJleHAiOjE3MDU4Nzk2NTl9.fSaYQlxTrC4LvwnJsEUmWfiIToR3JvAjOKGEeAVYnUA"
}
만약 accessToken으로 Header의 Authorization에 넣어서 재발급 받으면 다음과 같은 에러가 나옵니다.
{
"message": "토큰 재발급은 refresh 토큰으로만 가능합니다.",
"error": "Unauthorized",
"statusCode": 401
}
마찬가지로 refresh 토큰을 재발급 받아봅시다.
{
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImNvZGVmYWN0b3J5MTBAY29kZWZhY3RvcnkuYWkiLCJ0eXBlIjoicmVmcmVzaCIsImlhdCI6MTcwNTg3OTU4NywiZXhwIjoxNzA1ODgzMTg3fQ.IN_gQ1wduGs2QydjHj4YGM1jV_4t1mOKiEn3EsdM3BM"
}
동일하게, 만약 accessToken으로 Header의 Authorization에 넣어서 재발급 받으면 다음과 같은 에러가 나옵니다.
{
"message": "토큰 재발급은 refresh 토큰으로만 가능합니다.",
"error": "Unauthorized",
"statusCode": 401
}