NestJS로 이메일 인증을 구현해보자
계정
과 비밀번호
와 인증번호
를 입력해서 인증 받야합니다. 그러나 로그인하여 JWT 토큰을 보유한 유저는 이미 인증받은 것 입니다. 그렇습니다! 저는 JWT 토큰을 이용해서 이메일 인증을 해보겠습니다! 코드를 작성하기 전에 이메일 인증은 네이버로 가능하다. 구글은 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의 인증 코드를 빈값으로 만들어 줍니다.위와 같이 인증코드가 온 모습을 볼 수 있습니다.
👍 잘 보고 갑니다.
그런데 궁금한 점이 있습니다. 말씀하시는 이메일 인증을 가입 시점에 안하고, 가입 이후에 서비스 이용을 할때 하는 이유가 있을까요?
저는 가입시점에 확인하게 적용하려고 합니다만, 발생하는 문제가 있으셔서 해당하는 이유로 작업하신 건지 궁금합니다.