Xây dựng hệ thống xác thực OTP qua Email với Next.js - Phần 2: Email Service và API
Bài viết này (Phần 2) tập trung vào việc xây dựng Email Service sử dụng Nodemailer và triển khai các API endpoints chính cho hệ thống xác thực OTP qua email với Next.js. Các API bao gồm send-otp (có rate limiting 60 giây) và verify-otp (có giới hạn 5 lần thử sai và kiểm tra thời gian hết hạn 10 phút). Cuối cùng, bài viết hướng dẫn cập nhật API đăng ký để thiết lập emailVerified: false ban đầu và trả về flag requireVerification: true, chuẩn bị cho việc tích hợp giao diện người dùng ở phần sau.

Tiếp nối Phần 1, trong phần này chúng ta sẽ xây dựng Email Service và các API endpoints.
Bước 1: Tạo Email Service
Tạo file src/lib/email.ts:
import nodemailer from "nodemailer";
// Email configuration
const EMAIL_CONFIG = {
host: "smtp.gmail.com",
port: 587,
secure: false, // Use TLS
auth: {
user: process.env.GMAIL_USER,
pass: process.env.GMAIL_APP_PASSWORD,
},
};
// Create reusable transporter
const createTransporter = () => {
if (!process.env.GMAIL_USER || !process.env.GMAIL_APP_PASSWORD) {
throw new Error("Gmail credentials not configured");
}
return nodemailer.createTransport(EMAIL_CONFIG);
};
// Generate 6-digit OTP
export function generateOTP(): string {
return Math.floor(100000 + Math.random() * 900000).toString();
}
// Send OTP email
export async function sendOTPEmail(
email: string,
otp: string,
name?: string
) {
try {
const transporter = createTransporter();
const mailOptions = {
from: {
name: process.env.NEXT_PUBLIC_SITE_NAME || "My App",
address: process.env.GMAIL_USER as string,
},
to: email,
subject: "Xác thực tài khoản - Mã OTP",
html: `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Xác thực tài khoản</title>
</head>
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4;">
<table role="presentation" style="width: 100%; border-collapse: collapse;">
<tr>
<td align="center" style="padding: 40px 0;">
<table role="presentation" style="width: 600px; border-collapse: collapse; background-color: #ffffff; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);">
<tr>
<td style="padding: 40px 40px 20px 40px; text-align: center; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: bold;">
${process.env.NEXT_PUBLIC_SITE_NAME || "My App"}
</h1>
</td>
</tr>
<tr>
<td style="padding: 40px;">
<h2 style="margin: 0 0 20px 0; color: #333333; font-size: 24px;">
Xin chào${name ? ` ${name}` : ""}!
</h2>
<p style="margin: 0 0 20px 0; color: #666666; font-size: 16px; line-height: 1.6;">
Cảm ơn bạn đã đăng ký tài khoản. Để hoàn tất quá trình đăng ký, vui lòng sử dụng mã OTP dưới đây:
</p>
<table role="presentation" style="width: 100%; margin: 30px 0;">
<tr>
<td align="center" style="padding: 20px; background-color: #f8f9fa; border-radius: 8px;">
<div style="font-size: 36px; font-weight: bold; color: #667eea; letter-spacing: 8px; font-family: 'Courier New', monospace;">
${otp}
</div>
</td>
</tr>
</table>
<p style="margin: 20px 0; color: #666666; font-size: 16px; line-height: 1.6;">
Mã OTP này có hiệu lực trong <strong>10 phút</strong>.
</p>
<div style="margin: 30px 0; padding: 20px; background-color: #fff3cd; border-left: 4px solid #ffc107; border-radius: 4px;">
<p style="margin: 0; color: #856404; font-size: 14px;">
<strong>⚠️ Lưu ý:</strong> Nếu bạn không thực hiện đăng ký này, vui lòng bỏ qua email này.
</p>
</div>
</td>
</tr>
<tr>
<td style="padding: 30px 40px; background-color: #f8f9fa; border-top: 1px solid #e9ecef;">
<p style="margin: 0 0 10px 0; color: #999999; font-size: 14px; text-align: center;">
Email này được gửi tự động, vui lòng không trả lời.
</p>
<p style="margin: 0; color: #999999; font-size: 14px; text-align: center;">
© ${new Date().getFullYear()} ${process.env.NEXT_PUBLIC_SITE_NAME || "My App"}. All rights reserved.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`,
text: `
Xin chào${name ? ` ${name}` : ""}!
Cảm ơn bạn đã đăng ký tài khoản.
Mã OTP của bạn là: ${otp}
Mã này có hiệu lực trong 10 phút.
Nếu bạn không thực hiện đăng ký này, vui lòng bỏ qua email này.
Trân trọng,
${process.env.NEXT_PUBLIC_SITE_NAME || "My App"}
`,
};
const info = await transporter.sendMail(mailOptions);
return {
success: true,
messageId: info.messageId,
};
} catch (error) {
console.error("Error sending OTP email:", error);
throw new Error("Failed to send OTP email");
}
}
// Verify email configuration (for testing)
export async function verifyEmailConfig() {
try {
const transporter = createTransporter();
await transporter.verify();
return { success: true, message: "Email configuration is valid" };
} catch (error) {
console.error("Email configuration error:", error);
return { success: false, message: "Email configuration is invalid" };
}
}
Giải thích:
Gmail SMTP config: Port 587 với TLS (secure: false)
generateOTP(): Random 6 chữ số (100000-999999)
sendOTPEmail(): Gửi email với HTML template đẹp
verifyEmailConfig(): Test SMTP connection
Bước 2: API Endpoint - Send OTP
Tạo file src/app/api/auth/send-otp/route.ts
import { NextRequest, NextResponse } from "next/server";
import connectDB from "@/lib/db";
import OTPVerification from "@/models/OTPVerification";
import User from "@/models/User";
import { sendOTPEmail, generateOTP } from "@/lib/email";
export async function POST(request: NextRequest) {
try {
await connectDB();
const { email } = await request.json();
// Validate email
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return NextResponse.json(
{ success: false, error: "Email không hợp lệ" },
{ status: 400 }
);
}
// Check if email already exists and is verified
const existingUser = await User.findOne({ email: email.toLowerCase() });
if (existingUser && existingUser.emailVerified) {
return NextResponse.json(
{ success: false, error: "Email này đã được đăng ký và xác thực" },
{ status: 400 }
);
}
// Rate limiting - check recent OTP request (60 seconds)
const recentOTP = await OTPVerification.findOne({
email: email.toLowerCase(),
createdAt: { $gte: new Date(Date.now() - 60 * 1000) },
});
if (recentOTP) {
return NextResponse.json(
{
success: false,
error: "Vui lòng chờ 1 phút trước khi gửi lại mã OTP",
},
{ status: 429 }
);
}
// Generate OTP
const otp = generateOTP();
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
// Delete any existing unverified OTPs
await OTPVerification.deleteMany({
email: email.toLowerCase(),
verified: false,
});
// Create new OTP record
await OTPVerification.create({
email: email.toLowerCase(),
otp,
expiresAt,
verified: false,
attempts: 0,
});
// Send OTP email
const userName = existingUser?.name;
await sendOTPEmail(email, otp, userName);
return NextResponse.json(
{
success: true,
message: "Mã OTP đã được gửi đến email của bạn",
expiresIn: 600, // 10 minutes in seconds
},
{ status: 200 }
);
} catch (error) {
console.error("Send OTP error:", error);
return NextResponse.json(
{
success: false,
error: "Không thể gửi mã OTP. Vui lòng thử lại sau.",
},
{ status: 500 }
);
}
}
Flow logic:
Validate email format
Check email đã verified chưa
Rate limiting (60 seconds)
Generate OTP mới
Delete OTP cũ (nếu có)
Save OTP với TTL 10 phút
Gửi email
Bước 3: API Endpoint - Verify OTP
Tạo file src/app/api/auth/verify-otp/route.ts:
import { NextRequest, NextResponse } from "next/server";
import connectDB from "@/lib/db";
import OTPVerification from "@/models/OTPVerification";
import User from "@/models/User";
const MAX_ATTEMPTS = 5;
export async function POST(request: NextRequest) {
try {
await connectDB();
const { email, otp } = await request.json();
// Validate inputs
if (!email || !otp) {
return NextResponse.json(
{ success: false, error: "Email và mã OTP là bắt buộc" },
{ status: 400 }
);
}
if (otp.length !== 6 || !/^\d{6}$/.test(otp)) {
return NextResponse.json(
{ success: false, error: "Mã OTP phải là 6 chữ số" },
{ status: 400 }
);
}
// Find OTP record
const otpRecord = await OTPVerification.findOne({
email: email.toLowerCase(),
verified: false,
}).sort({ createdAt: -1 }); // Get most recent
if (!otpRecord) {
return NextResponse.json(
{
success: false,
error: "Không tìm thấy mã OTP. Vui lòng yêu cầu mã mới.",
},
{ status: 404 }
);
}
// Check expiration
if (new Date() > otpRecord.expiresAt) {
await OTPVerification.deleteOne({ _id: otpRecord._id });
return NextResponse.json(
{
success: false,
error: "Mã OTP đã hết hạn. Vui lòng yêu cầu mã mới.",
},
{ status: 400 }
);
}
// Check max attempts
if (otpRecord.attempts >= MAX_ATTEMPTS) {
await OTPVerification.deleteOne({ _id: otpRecord._id });
return NextResponse.json(
{
success: false,
error: "Bạn đã nhập sai quá nhiều lần. Vui lòng yêu cầu mã mới.",
},
{ status: 400 }
);
}
// Verify OTP
if (otpRecord.otp !== otp) {
// Increment attempts
otpRecord.attempts += 1;
await otpRecord.save();
const remainingAttempts = MAX_ATTEMPTS - otpRecord.attempts;
return NextResponse.json(
{
success: false,
error: `Mã OTP không chính xác. Còn ${remainingAttempts} lần thử.`,
},
{ status: 400 }
);
}
// OTP is correct - mark as verified
otpRecord.verified = true;
await otpRecord.save();
// Update user's emailVerified status
const user = await User.findOne({ email: email.toLowerCase() });
if (user) {
user.emailVerified = true;
await user.save();
}
// Clean up - delete verified OTP
await OTPVerification.deleteOne({ _id: otpRecord._id });
return NextResponse.json(
{
success: true,
message: "Email đã được xác thực thành công",
},
{ status: 200 }
);
} catch (error) {
console.error("Verify OTP error:", error);
return NextResponse.json(
{
success: false,
error: "Không thể xác thực mã OTP. Vui lòng thử lại sau.",
},
{ status: 500 }
);
}
}
Flow logic:
Validate input (email, OTP format)
Tìm OTP record mới nhất
Check expiration
Check số lần thử (max 5)
Verify OTP
Update
user.emailVerified = trueDelete OTP record
Bước 4: Cập nhật Register API
Cập nhật src/app/api/auth/register/route.ts:
// ... imports
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const validatedData = registerSchema.parse(body);
await connectDB();
// Check if user already exists
const existingUser = await User.findOne({ email: validatedData.email });
if (existingUser) {
return NextResponse.json(
{ success: false, error: 'Email đã được sử dụng' },
{ status: 400 }
);
}
// Create new user with emailVerified: false
const user = await User.create({
name: validatedData.name,
email: validatedData.email,
password: validatedData.password,
role: 'user',
isActive: true,
provider: 'credentials',
emailVerified: false, // ← Chưa verify
});
// Return response with requireVerification flag
return NextResponse.json(
{
success: true,
message: 'Tạo tài khoản thành công! Vui lòng xác thực email.',
requireVerification: true, // ← Flag này quan trọng
data: {
id: user._id,
name: user.name,
email: user.email,
emailVerified: user.emailVerified,
},
},
{ status: 201 }
);
} catch (error) {
// ... error handling
}
}
Testing APIs với curl
Test Send OTP:
400">curl -X POST http://localhost:3000/api/auth/send-otp \
-H 400">"Content-Type: application/json" \
-d 400">'{"email":"test@example.com"}'
Expected response:
{
"success": true,
"message": "Mã OTP đã được gửi đến email của bạn",
"expiresIn": 600
}
Test Verify OTP:
400">curl -X POST http://localhost:3000/api/auth/verify-otp \
-H 400">"Content-Type: application/json" \
-d 400">'{"email":"test@example.com","otp":"123456"}'
Expected response (success):
{
"success": true,
"message": "Email đã được xác thực thành công"
}
Expected response (wrong OTP):
{
"success": false,
"error": "Mã OTP không chính xác. Còn 4 lần thử."
}
Error Handling
Các lỗi phổ biến và cách xử lý:
Lỗi | Nguyên nhân | Giải pháp |
| Chưa set env vars | Kiểm tra |
| App password sai | Tạo lại App Password |
| Gửi quá nhiều request | Đợi 60 giây |
| OTP hết hạn (>10 phút) | Gửi lại OTP mới |
| Nhập sai 5 lần | Gửi lại OTP mới |
Kết thúc Phần 2
Trong phần này, chúng ta đã:
✅ Xây dựng Email Service với Nodemailer
✅ Tạo API Send OTP với rate limiting
✅ Tạo API Verify OTP với attempt limiting
✅ Cập nhật Register API
Tiếp theo: ()