NestJS로 가입 인증 메일을 구현해봅시다.

허창원·2023년 10월 26일
1
post-thumbnail
post-custom-banner

NestJS로 이메일 인증을 구현해보자

이메일 인증의 논리는???

  1. 유저는 가입을 합니다. 그러나 이메일 인증을 안했기 때문에 서비스는 이용할 수 없습니다. 서비스를 이용하려면 로그인 하여 이메일 인증을 해야합니다.
  2. 유저는 계정비밀번호인증번호를 입력해서 인증 받야합니다. 그러나 로그인하여 JWT 토큰을 보유한 유저는 이미 인증받은 것 입니다. 그렇습니다! 저는 JWT 토큰을 이용해서 이메일 인증을 해보겠습니다!
  3. 이메일로 인증 번호를 보낸 유저는 데이터베이스에 인증 번호가 생성이 됩니다.
  4. 이메일로 날아온 인증번호를 Body 값으로 입력하면 데이터베이스의 인증번호와 비교하여 IsVerified = true로 바꿔주고 인증번호를 “”값으로 바꿔 줍니다!

코드를 통해 알아보자!

코드를 작성하기 전에 이메일 인증은 네이버로 가능하다. 구글은 2022년부터 보안 수준을 높여야 이메일 인증이 가능하다고 합니다. 번거로운 작업을 피하기 위해서 네이버 이메일 인증을 사용했습니다.

IMAP / SMTP 설정하기

https://help.naver.com/service/5632/contents/18534?osType=PC&lang=ko

위 URL을 보고 자신의 네이버 메일의 IMAP/SMTP를 사용함으로 체크해주고 저장합니다.

다음 두 패키지는 메일인증을 하는데 사용합니다.

npm i nodemailer
npm i -D @types/nodemailer

email.service.ts

import { Injectable } from '@nestjs/common';
import * as nodemailer from 'nodemailer';

// Email 인터페이스. 타입을 지정해줍니다.
interface EmailOptions {
  from: string;
  to: string;
  subject: string;
  html: string;
}

@Injectable()
export class EmailService {
  private transporter; // nodemailer에서 mail을 보내기 위한 것

  constructor() {
    this.transporter = nodemailer.createTransport({
      service: 'naver',
      auth: {
        user: 'wonn23@naver.com', // 여러분 메일을 적으세요
        pass: '내 이메일 비밀번호',
      },
    });
  }

  async sendVerificationToEmail(email: string, code: string): Promise<void> {
    const emailOptions: EmailOptions = {
      from: 'wonn23@naver.com', // 보내는 사람 이메일 주소
      to: email, // 회원가입한 사람의 받는 이메일 주소
      subject: '가입 인증 메일',
      html: `<h1> 인증 코드를 입력하면 가입 인증이 완료됩니다.</h1><br/>${code}`,
    };

    return await this.transporter.sendMail(emailOptions);
  }
}

이메일 서비스를 따로 작성해줍니다. EmailOptions를 인터페이스를 통해 타입을 지정해줍니다. 그리고 service는 gmail인지 naver인지 작성하고 누가 전송할 건지 이메일과 비밀번호를 작성해줍니다.

email과 code를 인수로 받아 이메일을 작성해주고 nodemailer 패키지의 sendMail 함수로 인증코드를 담아 이메일로 날립니다.

user.module.ts

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserRepository } from './user.repository';
import { UserController } from './user.controller';
import { UserService } from './user.service';
import { TypeOrmExModule } from '../common/decorator/typeorm-ex.module';
import { PassportModule } from '@nestjs/passport';
import * as config from 'config';
import { JwtModule } from '@nestjs/jwt';
import { JwtStrategy } from './jwt.strategy';
import { User } from './entities/user.entity';
import { EmailService } from './email.service';

const jwtConfig = config.get('jwt');

@Module({
  imports: [
    PassportModule.register({ defaultStrategy: 'jwt' }),
    JwtModule.register({
      secret: process.env.JWT_SECRET || jwtConfig.secret,
      signOptions: {
        expiresIn: jwtConfig.expiresIn,
      },
    }),
    TypeOrmModule.forFeature([User]),
    TypeOrmExModule.forCustomRepository([UserRepository]),
  ],
  controllers: [UserController],
  providers: [UserService, JwtStrategy, EmailService],
  exports: [JwtStrategy, PassportModule],
})
export class UserModule {}

이메일 서비스를 만들었으면 User 모듈의 Providers에 주입 해줍니다.

다음 컨트롤러로 가볼까요?

user.controller.ts

import {
  Body,
  Controller,
  Post,
  Req,
  UseGuards,
  ValidationPipe,
} from '@nestjs/common';
import { UserService } from './user.service';
import { RegisterUserDto } from './dto/registerUser.dto';
import { LogInDto } from './dto/login.dto';
import { AuthGuard } from '@nestjs/passport';
import { GetUser } from './decorator/get-user.decorator';
import { User } from './entities/user.entity';

@Controller('users')
export class UserController {
  constructor(private userService: UserService) {}

...

  @Post('/sendcode')
  @UseGuards(AuthGuard())
  sendCode(@GetUser() user: User): Promise<void> {
    return this.userService.sendVerificationCode(user);
  }

  @Post('/confirmcode')
  @UseGuards(AuthGuard())
  confirmCode(
    @Body('verificationCode') verificationCode: string,
    @GetUser() user: User,
  ): Promise<object> {
    return this.userService.confirmVerificationCode(verificationCode, user);
  }
}

컨트롤러에는 이메일을 보내는 API와 인증코드 입력 API 2개를 작성했습니다.
@UseGuards(AuthGuard())를 통해서 JWT 토큰을 통해 유저를 확인해줍니다.

@GetUser()는 JwtStrategy에서 반환하는 유저 객체를 사용할 수 있도록 따로 데코레이터를 작성했습니다.

다음은 유저 서비스를 살펴봅시다.

user.service.ts

import {
  BadRequestException,
  Injectable,
  InternalServerErrorException,
  NotFoundException,
  UnauthorizedException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { UserRepository } from './user.repository';
import { RegisterUserDto } from './dto/registerUser.dto';
import { JwtService } from '@nestjs/jwt';
import { LogInDto } from './dto/login.dto';
import * as bcrypt from 'bcryptjs';
import { User } from './entities/user.entity';
import { EmailService } from './email.service';

@Injectable()
export class UserService {
  constructor(
    @InjectRepository(UserRepository)
    private userRepository: UserRepository,
    private jwtService: JwtService,
    private emailService: EmailService,
  ) {}

  async generateVerificationCode(): Promise<string> {
    return Math.floor(100000 + Math.random() * 900000).toString();
  }

...

  async sendVerificationCode(user: User): Promise<void> {
    const verificationCode = await this.generateVerificationCode();

    user.verificationCode = verificationCode;

    await this.userRepository.save(user);

    return await this.emailService.sendVerificationToEmail(
      user.email,
      verificationCode,
    );
  }

  async confirmVerificationCode(verificationCode: string, user: User) {
    try {
      const savedCode = user.verificationCode;

      if (!savedCode) {
        throw new BadRequestException('저장된 인증 코드가 없습니다.');
      }

      if (savedCode !== verificationCode) {
        throw new BadRequestException('인증 코드가 일치하지 않습니다.');
      }

      await this.userRepository.isVerfied(verificationCode, user);
      await this.clearVerificationCode(user);

      return { message: '인증 코드가 확인되었습니다.' };
    } catch (error) {
      console.error(error);
      throw new InternalServerErrorException(
        '인증 코드 확인 중 오류가 발생했습니다.',
      );
    }
  }

  async clearVerificationCode(user: User): Promise<void> {
    const userId = await this.userRepository.findById(user.id);

    if (!userId) {
      throw new NotFoundException('사용자를 찾을 수 없습니다.');
    }

    await this.userRepository.clearVerificationCode(
      user.email,
      user.verificationCode,
    );
  }
}
  • generateVerificationCode는 랜덤으로 6자리를 생성해주는 함수입니다.
  • sendVerificationCode는 랜덤으로 생성한 6자리 인증코드를 이메일 서비스에서 작성한 sendVerificationToEmail함수에 로그인한 유저의 이메일과 인증코드를 담아서 보냅니다.
  • confirmVerificationCode는 이메일로 온 인증 코드를 req.body에 입력하여 내 계정에 있는 인증 코드가 일치하는지 확인하는 함수입니다. 일치하면 가입 승인 여부를 true로 만들어 줍니다. await this.userRepository.isVerfied(verificationCode, user);이 코드가 가입 승인을 true만드는 것입니다.
  • clearVerificationCode는 데이터베이스에 있는 내 인증코드를 빈 값으로 만들어 주는 함수입니다.

user.repository.ts

import { Repository } from 'typeorm';
import { CustomRepository } from '../common/decorator/typeorm-ex.decorator';
import { User } from './entities/user.entity';
import { RegisterUserDto } from './dto/registerUser.dto';
import * as bcrypt from 'bcryptjs';
import {
  ConflictException,
  InternalServerErrorException,
} from '@nestjs/common';

@CustomRepository(User)
export class UserRepository extends Repository<User> {
  
...

  async isVerfied(verificationCode: string, user: User): Promise<void> {
    try {
      user.verificationCode = verificationCode;
      user.isVerified = true;

      await this.save(user);
    } catch (error) {
      throw new InternalServerErrorException(
        '인증 코드 확인 중 오류가 발생했습니다.',
      );
    }
  }

  async clearVerificationCode(
    email: string,
    verificationCode: string,
  ): Promise<void> {
    try {
      const user = await this.findOne({ where: { email, verificationCode } });

      user.verificationCode = '';

      await this.save(user);
    } catch (error) {
      throw new InternalServerErrorException(
        '인증 코드 제거 중 오류가 발생했습니다.',
      );
    }
  }
}
  • isVerfied는 유저의 isVerified 컬럼의 false를 true로 만들어 줍니다.
  • clearVerificationCode는 email과 인증코드가 일치하는 user를 찾아서 그 user의 인증 코드를 빈값으로 만들어 줍니다.

위와 같이 인증코드가 온 모습을 볼 수 있습니다.

post-custom-banner

3개의 댓글

comment-user-thumbnail
2024년 5월 1일

👍 잘 보고 갑니다.

그런데 궁금한 점이 있습니다. 말씀하시는 이메일 인증을 가입 시점에 안하고, 가입 이후에 서비스 이용을 할때 하는 이유가 있을까요?

저는 가입시점에 확인하게 적용하려고 합니다만, 발생하는 문제가 있으셔서 해당하는 이유로 작업하신 건지 궁금합니다.

1개의 답글