[React] 성냥퍼즐 웹 서비스 만들기 13일차 (nodemailer, bcrypt, JWT)
안녕하세요~~

오늘은 nodemailer을 사용한 회원가입 및 메일 인증과 로그인 기능을 구현해 보도록 하겠습니다.
💡오늘 구현할 폴더 구조
src/
├── auth/ # 인증 및 회원가입/로그인 관련
│ ├── auth.controller.ts # 회원가입, 로그인, 이메일 인증 엔드포인트
│ ├── auth.service.ts # 인증 로직 (회원가입, 로그인, 이메일 인증)
│ ├── auth.module.ts # Auth 모듈 정의
│ ├── jwt.strategy.ts # JWT 인증 전략
│ ├── jwt-auth.guard.ts # JWT 인증 가드
│ ├── utils/ # 인증 관련 유틸리티
│ │ ├── mailer.ts # 네이버 SMTP 이메일 전송 로직
│ │ ├── token-generator.ts # 인증 토큰 생성 로직 (UUID 또는 JWT)
│ ├── dto/ # Auth 관련 데이터 전송 객체
│ │ ├── register.dto.ts # 회원가입 요청 DTO
│ │ ├── login.dto.ts # 로그인 요청 DTO
개인적인 공부를 위해 작성하는 블로그입니다. 혹시라도 잘못되거나 부족한 부분이 있다면 댓글로 알려주시면 감사하겠습니다.
$ nest g res auth
우선 nest generator를 통해 auth 관련 CRUD 로직을 만들어주겠습니다. prisma를 사용할 거라 entities 폴더만 삭제해 줄게요
🦭 NodeMailer 설정
$ npm install nodemailer
$ npm install --save-dev @types/nodemailer
다운을 받았으면 네이버 메일함에 들어가서 환경설정 > pop3/SMTP 설정과 IMAP/SMTP 설정에서 사용함을 체크해 줍니다.
하단에 적혀있는 SMTP 주소와 포트번호를 가져와 적어줍니다.
마지막으로 보안설정 > 2단계 인증에서 기기인증을 하고 애플리케이션 비밀번호 관리에서 이름과 비밀번호 생성을 누르면 비밀번호가 나오는데 이걸 그대로 복사해서. env 파일에 패스워드로 저장해 줍니다.
우리는 이제 회원가입 시에 uuid4로 만든 고유 인증키를 DB에 저장해 두고 이메일에 인증링크의 URL에 쿼리 파라미터로 token값에 이를 추가하여 사용자가 링크를 클릭 시 해당 url로 인증키를 DB에서 확인하여 인증하는 방식을 만들어보도록 하겠습니다.
$ npm i uuid
// src/auth/utils/token-generator.ts
import { v4 as uuiv4 } from 'uuid';
export function generateVarificationToken(): string {
return uuiv4(); // 고유한 인증 토큰 생성
}
// src/auth/utils/mailer.ts
import * as nodemailer from 'nodemailer';
export const transporter = nodemailer.createTransport({
host: 'smtp.naver.com',
port: 465,
secure: true, // true for 465, false for other ports
auth: {
user: process.env.NAVER_EMAIL, // 네이버 이메일 주소
pass: process.env.NAVER_PASSWORD, // 네이버 이메일 비밀번호
},
});
export async function sendVerificationEmail(email: string, username: string, token: string) {
const verificationUrl = `${process.env.APP_URL}/auth/verify-email?token=${token}`;
try {
await transporter.sendMail({
from: process.env.NAVER_EMAIL,
to: email,
subject: 'Email Verification',
html: `
<h1>Welcome, ${username}!</h1>
<p>Please verify your email by clicking the link below:</p>
<a href="${verificationUrl}">Verify Email</a>
`,
});
} catch (error) {
console.error(error);
throw new Error('Email sending failed.')
}
}
💁🏻 AuthService
패스워드를 서버에 저장할 때 해쉬를 통한 암호화가 필수이기 때문에 단방향 해쉬를 사용하려고 하는데 대표적인 알고리즘으로 MD5, SHA-1, SHA-256 등이 있지만 그냥 사용하기에는 보안적인 문제가 있어 솔트값과 반복 해쉬와 같은 강력한 기능을 제공하는 bcypt를 사용해 보기로 하자.
$ npm install bcrypt
// scr/auth/auth.service.ts
import * as bcrypt from 'bcrypt';
import { Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { PrismaService } from 'src/prisma/prisma.service';
import { sendVerificationEmail } from './utils/mailer';
import { generateVarificationToken } from './utils/token-generator';
(생략)
async register(registerDto: RegisterDto) {
const { username, email, password } = registerDto;
// 비밀번호 해싱
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(password, salt);
// 인증 토큰 생성
const verificationToken = generateVarificationToken();
// 사용자 저장
await this.prisma.user.create({
data: {
username,
email,
password: hashedPassword,
verificationToken // 인증 토큰 저장
}
});
// 이메일 전송
try{
await sendVerificationEmail(email, username, verificationToken)
} catch (error) {
console.error(error);
throw new Error('Email Error');
}
return { message: 'User registered successfully. please check your email to verify your account.'};
}
async verifyEmail(token: string) {
const user = await this.prisma.user.findFirst({
where: { verificationToken: token },
});
if (!user) {
throw new NotFoundException('User not found.');
}
await this.prisma.user.update({
where: { id: user.id },
data: {
isVerified: true, // 인증 완료
verificationToken: null, // 토큰제거
}
})
return { message: 'Email verified successfully. You can now log in.'}
}
}
👨🏻💻 JWT 인증 구현
JWT (Json Web Token)이란 앱이나 웹에서 인증을 위해 사용되는 JSON 포맷의 데이터 토큰으로 서명을 통해 데이터의 변조 여부를 확인할 수 있다. 서버에서 세션을 유지하지 않은 stateless 방식으로 사용자가 로그인했을 때 토큰을 발급하여 클라이언트 측에서 보관하고 요청 시 HTTP 헤더를 통해 서버에 전달하여 인증을 처리한다.
단점: 토큰 발급 후 서버에서 토큰 만료를 직접적으로 관리할 수 없기 때문에 보안에 취약할 수 있다.
(이를 보완하기 위해 Access Token과 Refresh Token을 함께 사용함)
장점: 서버에서 유저의 로그인 정보를 저장하지 않기 때문에 확장성과 비용 효율성 측면에서 유리하다.
nest에서 제공하는 jwtService를 사용할 건데 사용하려면 우선 @nestjs/jwt를 받아주고 모듈을 임포트 해야 한다.
$ npm install --save @nestjs/jwt
// auth.module.ts
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { PrismaModule } from 'src/prisma/prisma.module';
@Module({
imports: [
JwtModule.register({
secret: process.env.JWT_SECRET,
signOptions: { expiresIn: '1h'},
})
, PrismaModule],
controllers: [AuthController],
providers: [AuthService],
})
export class AuthModule {}
// auth.service.ts
import * as bcrypt from 'bcrypt';
import { Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { PrismaService } from 'src/prisma/prisma.service';
import { sendVerificationEmail } from './utils/mailer';
import { generateVarificationToken } from './utils/token-generator';
import { JwtService } from '@nestjs/jwt';
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwtService: JwtService, // 추가
) {}
async login(loginDto : LoginDto) {
const { email, password } = loginDto;
// 이메일로 사용자 찾기
const user = await this.prisma.user.findUnique({
where: { email },
});
if (!user) {
throw new NotFoundException('Invalid email or password.');
}
// 이메일 인증 여부 확인
if (!user.isVerified) {
throw new UnauthorizedException('Please verify your email before logging in.')
}
// 비밀번호 확인
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
throw new UnauthorizedException('Invalid email or password.');
}
console.log("login OK: ", user.username)
// JWT 토큰 생성
const payload = { userId: user.id, username: user.username };
const token = this.jwtService.sign(payload);
return { token, message: 'Login successful.'};
}