+1

[Series] Xây dựng Hệ thống Bất động sản với Node.js & TypeScript - Bài 3: Bảo mật tài khoản với Xác thực Email & OTP

Chào anh em! Ở bài 2, chúng ta đã cho phép người dùng đăng ký. Nhưng nếu họ dùng email ảo hoặc số điện thoại "lụi" thì sao? Điều này cực kỳ nguy hiểm cho một sàn giao dịch Bất động sản. Vì vậy, hôm nay chúng ta sẽ hiện thực hóa hai trường emailVerifiedphoneVerified trong Database về true.

1. Chuẩn bị "Vũ khí"

Chúng ta cần cài thêm nodemailer để gửi mail. Về phần OTP, trong khuôn mẫu bài học mình sẽ hướng dẫn cách tạo mã và giả lập gửi (log ra console), vì các dịch vụ như Twilio thường tốn phí.

npm install nodemailer
npm install @types/nodemailer --save-dev

Đừng quên cập nhật file .env với thông tin Email của bạn (nên dùng Gmail App Password):

# Email Config
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER="your-email@gmail.com"
SMTP_PASS="your-app-password" # Mật khẩu ứng dụng của Gmail
FRONTEND_URL=http://localhost:3000

2. Xây dựng các tiện ích (Utilities)

A. Gửi Email với Nodemailer File: src/utils/mail.ts

import nodemailer from "nodemailer";
import dotenv from "dotenv";

dotenv.config();

const transporter = nodemailer.createTransport({
  host: process.env.SMTP_HOST,
  port: parseInt(process.env.SMTP_PORT || "587"),
  secure: false,
  auth: {
    user: process.env.SMTP_USER,
    pass: process.env.SMTP_PASS,
  },
});

export const sendVerificationEmail = async (to: string, token: string) => {
  const link = `${process.env.FRONTEND_URL}/api/verify/email?token=${token}`;
  
  const mailOptions = {
    from: `"BDS App Support" <${process.env.SMTP_USER}>`,
    to,
    subject: "🔐 Xác thực tài khoản Bất động sản của bạn",
    html: `
      <div style="font-family: Arial, sans-serif; max-width: 600px; margin: auto; border: 1px solid #ddd; padding: 20px;">
        <h2 style="color: #4CAF50;">Chào mừng bạn!</h2>
        <p>Vui lòng click vào nút dưới đây để xác thực địa chỉ email của bạn:</p>
        <a href="${link}" style="display: inline-block; padding: 10px 20px; background-color: #4CAF50; color: white; text-decoration: none; border-radius: 5px;">Xác thực ngay</a>
        <p>Hoặc copy link này: ${link}</p>
        <p>Link có hiệu lực trong 1 giờ.</p>
      </div>
    `,
  };

  await transporter.sendMail(mailOptions);
};

B. Tạo mã OTP ngẫu nhiên

File: src/utils/otp.ts

export const generateOTP = (): string => {
  // Tạo mã 6 số ngẫu nhiên
  return Math.floor(100000 + Math.random() * 900000).toString();
};

3. Xử lý Logic tại Service Layer

Chúng ta sẽ sử dụng một Map để lưu tạm OTP. Trong thực tế, bạn nên dùng Redis để mã này tự động biến mất sau 5-10 phút.

File: src/services/verify.service.ts

import jwt from 'jsonwebtoken';
import prisma from '../prisma/client';
import { sendVerificationEmail } from '../utils/mail';
import { generateOTP } from '../utils/otp';

// Tạm thời lưu OTP trong Memory (Nên dùng Redis cho Production)
const phoneOTPStore = new Map<number, string>();

export const generateEmailToken = async (userId: number, email: string) => {
  const token = jwt.sign({ userId }, process.env.JWT_SECRET!, { expiresIn: '1h' });
  await sendVerificationEmail(email, token);
};

export const verifyEmailToken = async (token: string) => {
  const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { userId: number };
  
  return await prisma.users.update({
    where: { id: decoded.userId },
    data: { emailVerified: true },
  });
};

export const sendPhoneOTP = async (userId: number, phone: string) => {
  const otp = generateOTP();
  phoneOTPStore.set(userId, otp);
  
  // Giả lập gửi SMS - Thực tế sẽ gọi API của Twilio/eSMS ở đây
  console.log(`📲 [SMS Simulation] Gửi OTP ${otp} đến số: ${phone}`);
  return otp;
};

export const verifyPhoneOTP = async (userId: number, otp: string) => {
  const storedOtp = phoneOTPStore.get(userId);
  
  if (storedOtp && storedOtp === otp) {
    await prisma.users.update({
      where: { id: userId },
      data: { phoneVerified: true },
    });
    phoneOTPStore.delete(userId); // Xóa mã sau khi dùng xong
    return true;
  }
  return false;
};

4. Controller & Routes

File: src/controllers/verify.controller.ts

import { Request, Response } from 'express';
import * as VerifyService from '../services/verify.service';

export const verifyEmail = async (req: Request, res: Response) => {
  const { token } = req.query;
  try {
    await VerifyService.verifyEmailToken(token as string);
    res.status(200).send("<h1>Xác thực Email thành công! ✅</h1><p>Bạn có thể đóng cửa sổ này.</p>");
  } catch (err) {
    res.status(400).json({ error: 'Token không hợp lệ hoặc đã hết hạn ❌' });
  }
};

export const verifyPhone = async (req: Request, res: Response) => {
  const { userId, otp } = req.body;
  try {
    const success = await VerifyService.verifyPhoneOTP(Number(userId), otp);
    if (success) {
      res.status(200).json({ message: 'Xác thực số điện thoại thành công ✅' });
    } else {
      res.status(400).json({ error: 'Mã OTP không chính xác hoặc đã hết hạn ❌' });
    }
  } catch (err: any) {
    res.status(500).json({ error: err.message });
  }
};

File: src/routes/verify.routes.ts

import { Router } from 'express';
import * as VerifyController from '../controllers/verify.controller';

const router = Router();

router.get('/email', VerifyController.verifyEmail);
router.post('/phone', VerifyController.verifyPhone);

export default router;

5. Hướng dẫn Test với Postman (Cực kỳ chi tiết)

Bước 1: Đăng ký tài khoản mới

  • Endpoint: POST /api/auth/register
  • Sau khi đăng ký thành công, hãy kiểm tra hộp thư Email của bạn (Email thật nhé).
  • Đồng thời, nhìn vào Terminal/Console của VS Code, bạn sẽ thấy dòng: 📲 [SMS Simulation] Gửi OTP 123456...

Bước 2: Xác thực Email

  • Mở Email của bạn lên, click vào nút Xác thực.
  • Trình duyệt sẽ mở ra link:http://localhost:3000/api/verify/email?token=...
  • Nếu thấy thông báo "Thành công", hãy kiểm tra Database, cột emailVerified của bạn đã là true (hoặc 1).

Bước 3: Xác thực Số điện thoại

  • Mở Postman, tạo một Request mới.
  • Method: POST
  • URL: http://localhost:3000/api/verify/phone
  • Body (raw JSON):
{
  "userId": 1, 
  "otp": "mã-số-bạn-thấy-trong-console"
}

Nhấn Send. Nếu nhận được message: "Xác thực số điện thoại thành công ✅", bạn đã hoàn thành bài học hôm nay!

6. Tổng kết

Bài học này giúp hệ thống của chúng ta trở nên tin cậy hơn rất nhiều. Người dùng giờ đây đã có "danh tính xác thực".

Nếu anh em chưa nhận được email, hãy kiểm tra lại mật khẩu ứng dụng (App Password) của Gmail nhé! Chúc anh em thành công!


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí