NextJS

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.

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

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:

typescript
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

typescript
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:

  1. Validate email format

  2. Check email đã verified chưa

  3. Rate limiting (60 seconds)

  4. Generate OTP mới

  5. Delete OTP cũ (nếu có)

  6. Save OTP với TTL 10 phút

  7. Gửi email

Bước 3: API Endpoint - Verify OTP

Tạo file src/app/api/auth/verify-otp/route.ts:

typescript
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:

  1. Validate input (email, OTP format)

  2. Tìm OTP record mới nhất

  3. Check expiration

  4. Check số lần thử (max 5)

  5. Verify OTP

  6. Update user.emailVerified = true

  7. Delete OTP record

Bước 4: Cập nhật Register API

Cập nhật src/app/api/auth/register/route.ts:

typescript
// ... 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:

bash
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:

json
{
  "success": true,
  "message": "Mã OTP đã được gửi đến email của bạn",
  "expiresIn": 600
}

Test Verify OTP:

bash
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):

json
{
  "success": true,
  "message": "Email đã được xác thực thành công"
}

Expected response (wrong OTP):

json
{
  "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

Gmail credentials not configured

Chưa set env vars

Kiểm tra .env.local

Authentication failed

App password sai

Tạo lại App Password

Rate limit exceeded

Gửi quá nhiều request

Đợi 60 giây

OTP expired

OTP hết hạn (>10 phút)

Gửi lại OTP mới

Max attempts exceeded

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: ()

👁️11 lượt xem
❤️0 lượt thích