[NestJS] 이메일 인증 구현하기(1)

NestJSnodemailer로 이메일 인증을 구현하는 방법입니다.


시작

먼저 Nest CLI로 프로젝트를 생성해줍니다.

npm i -g @nestjs/cli
nest new email-verification

프로젝트 폴더로 이동한 후 nodemailer 모듈을 설치해줍니다.

npm i @nestjs-modules/mailer nodemailer
npm i --save-dev @types/nodemailer

다음으로 hbs파일을 사용하기 위해서 handlebarshbs를 설치해줍니다.

npm i hbs handlebars

MailerModule 추가

app.module.ts파일에 가서 MailerModule를 추가해줍니다.

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { MailerModule } from '@nestjs-modules/mailer';
import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter';
 
@Module({
  imports: [
    MailerModule.forRoot({
      transport: {
        host: 'smtp.gmail.com',
        port: 587,
        auth: {
          user: // 이메일 송신지 입력,
          pass: // 앱 비밀번호 입력,
        },
      },
      defaults: {
        from: '"nest-modules" <modules@nestjs.com>',
      },
      template: {
        dir: __dirname + '/templates/',
        adapter: new HandlebarsAdapter(),
        options: {
          strict: true,
        },
      },
    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
 

auth 부분의 user에 이메일을 보낼 송신지 이메일을 입력하시고 pass 부분에 앱 비밀번호를 입력합니다. 앱 비밀번호를 발급받는 방법은 이곳에서 확인할 수 있습니다. (저는 gmail을 사용했습니다.) user과 pass는 하드코딩할 경우 이메일과 비밀번호과 유출될 수 있으므로 @nest/cofing의 환경변수(env)를 이용해서 넣어줍니다.

메인페이지 생성 및 렌더링

'/' 요청의 페이지를 만들기 위해 루트 폴더 밑에 views 폴더를 생성한 후 안에 index.hbs 파일을 생성해줍니다.

<form action='/' method='post'>
  <p><input type='email' name='email' placeholder='이메일 입력' required /></p>
  <button type='submit'>인증번호 보내기</button>
</form>

index.hbs파일을 불러오기 위해 main.ts에 다음 코드를 넣어줍니다.

//main.ts
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';
import { AppModule } from './app.module';
 
async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule);
 
  app.setBaseViewsDir(join(__dirname, '..', 'views'));
  app.setViewEngine('hbs');
 
  await app.listen(3000);
}
bootstrap();
 

다음으로 controllerindex.hbs 파일을 렌더링 하기 위한 코드를 만듭니다.

//app.controller.ts
import { Get, Controller, Render, Post, Body } from '@nestjs/common';
import { AppService } from './app.service';
 
@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}
 
  @Get('/')
  @Render('index')
  root() {
    return {};
  }
}
 

등록한 후 http://localhost:3000/에 접속하시면 다음과 같이 index.hbs 파일이 렌더링됩니다.

인증번호 생성 및 발송

이제 이메일을 입력하고 인증번호 보내기 버튼을 눌렀을때 인증번호와 함께 이메일을 보내는 코드를 만듭니다. controllerservice에 다음 코드를 입력합니다.

//app.controller.ts
import { Get, Controller, Render, Post, Body } from '@nestjs/common';
import { AppService } from './app.service';
 
@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}
 
  @Get('/')
  @Render('index')
  root() {
    return {};
  }
 
  @Post('/')
  async sendCode(@Body('email') userEmail: string): Promise<boolean> {
    return this.appService.sendCode(userEmail);
  }
}
 
//app.service.ts
import { Injectable, ConflictException } from '@nestjs/common';
import { MailerService } from '@nestjs-modules/mailer/dist';
import * as crypto from 'crypto';
 
@Injectable()
export class AppService {
  constructor(private readonly mailerService: MailerService) {}
 
  sendCode(userEmail: string): boolean {
    //6자리 인증코드 생성
    const authCode = crypto.randomBytes(3).toString('hex');
    console.log(`발송대기`);
    this.mailerService
      .sendMail({
        to: //이메일 수신지 주소 입력,
        from: //이메일 송신지 주소 입력,
        subject: 'Hello', //이메일 제목입력
        template: 'email', //templates 폴더 안에 있는 hbs 파일명 입력
        context: {
          userEmail,
          authCode,
        },
      })
      .then((res) => {
        console.log(res); //이메일 발송 결과
      })
      .catch((err) => {
        new ConflictException(err);
      });
    return true;
  }
}

인증번호는 보안을 위해서 랜덤한 문자를 생성하여 보냅니다. 자바스크립트에서는 난수를 만드는 법으로 Math.random() 함수를 지원하지만 완벽한 난수가 아니여서 보안상 문제가 있을 수 있습니다. 따라서 안전한 crypto로 난수를 만드는 것을 추천합니다.

이메일 템플릿

이제 이메일 내용에 들어갈 템플릿을 만들겠습니다. src폴더 안에 templates 폴더를 만들고 안에 email.hbs 파일을 만듭니다.

<h1>{{userEmail}}님 반갑습니다.</h1>
<p>인증번호는 [{{authCode}}] 입니다.</p>

ejs에서는 변수를 넣을려면 <%= userEmail %>같이 넣어야 돼서 귀찮은데 hbs는 간단해서 맘에듭니다.

여기서 이제 이메일을 보내면 다음과 같이 인증번호와 함께 이메일이 잘 발송됩니다.

오류

갑자기 dist폴더에서 hbs을 못찾는 오류가 발생하여 몇시간 삽질을 하다가 깃허브 이슈에서 해결법을 찾았습니다. nest-cli.json 파일의 compilerOptions에 다음과 같은 코드를 추가하시고 서버를 껐다가 다시 시작하시면 해결이 됩니다.

{
  "$schema": "https://json.schemastore.org/nest-cli",
  "collection": "@nestjs/schematics",
  "sourceRoot": "src",
  "compilerOptions": {
    "assets": [
      {
        "include": "**/*.hbs",
        "watchAssets": true
      }
    ],
    "deleteOutDir": true
  }
}
 

인증번호 암호화

인증번호를 암호화없이 어딘가 저장해둔다면 보안상의 문제가 발생합니다. 따라서 암호화를 시키고 저장해야되는데 NestJS 공식문서에서 양방향 암호화로 aes-256-ctr를 단방향 암호화으로는 argon2bcrypt를 추천합니다. 서버가 생성한 인증 번호를 암호화한 후 유저가 입력한 인증 번호를 암호화해서 두 개를 비교하면 되므로 양방향 암호화 대신 단방향 암호화를 선택했고 그 방식으로는 bcrypt를 사용하겠습니다.

npm i bcrypt
npm i -D @types/bcrypt

app.service.ts파일에 인증번호가 성공적으로 발송이 되었다면 인증번호를 암호화하는 로직을 추가시켜줍니다.

//app.service.ts
import * as bcrypt from 'bcrypt'; //코드 상단에 추가
...
      .then((res) => {
        console.log(res); //이메일 발송 결과
        // 인증번호 암호화
        encrypt(authCode);
      })
...
const encrypt = async (authCode: string) => {
  // salt 생성
  const salt = await bcrypt.genSalt();
  // 인증코드와 salt로 hash 생성
  const hash = await bcrypt.hash(authCode, salt);
  return hash;
};
 

인증번호 저장

내용이 길어져서 다음글에서 계속.