Giới thiệu

Trong môi trường mạng ngày càng phức tạp, việc chỉ dựa vào mật khẩu để bảo vệ tài khoản không còn đủ an toàn. Xác thực đa yếu tố (Multi‑Factor Authentication - MFA) đã trở thành tiêu chuẩn bảo mật cho các hệ thống API, đặc biệt là những API xử lý dữ liệu nhạy cảm hoặc thực hiện các giao dịch tài chính. Bài viết sẽ hướng dẫn chi tiết cách tích hợp MFA vào một dự án Node.js sử dụng Express, từ lý thuyết cơ bản, kiến trúc đề xuất, tới các đoạn mã thực thi thực tế.

Lý thuyết về MFA và các phương thức phổ biến

MFA yêu cầu người dùng cung cấp ít nhất hai yếu tố xác thực thuộc ba nhóm:

  • Something you know: mật khẩu, PIN.
  • Something you have: thiết bị OTP, token, điện thoại.
  • Something you are: sinh trắc học (vân tay, khuôn mặt).

Trong các ứng dụng web, hai phương thức được triển khai nhiều nhất là:

OTP dựa trên thời gian (TOTP)

Thuật toán TOTP sinh mã ngắn (6‑8 chữ số) dựa trên thời gian hiện tại và một secret key chia sẻ. Người dùng có thể dùng ứng dụng Google Authenticator hoặc Authy để tạo mã.

OTP qua email hoặc SMS

Hệ thống gửi một mã ngẫu nhiên tới email hoặc số điện thoại của người dùng. Phương pháp này dễ triển khai nhưng phụ thuộc vào độ tin cậy của nhà cung cấp dịch vụ email/SMS.

Kiến trúc đề xuất cho Node.js Express

Một kiến trúc sạch sẽ giúp tách biệt logic xác thực khỏi các route nghiệp vụ, đồng thời dễ mở rộng khi muốn thêm các phương thức MFA mới.

Luồng xác thực

  1. Người dùng gửi usernamepassword tới /login.
  2. Server kiểm tra mật khẩu. Nếu hợp lệ, trả về accessToken tạm thời và yêu cầu MFA.
  3. Người dùng lựa chọn phương thức MFA (TOTP, email, SMS) và gửi mã tới /mfa/verify.
  4. Server xác thực mã, nếu thành công sẽ cấp accessToken đầy đủ (có thời gian sống dài hơn) và refreshToken.

Cấu trúc thư mục

my-app/
├─ src/
│  ├─ controllers/
│  │   ├─ auth.controller.js
│  │   └─ mfa.controller.js
│  ├─ middlewares/
│  │   └─ auth.middleware.js
│  ├─ services/
│  │   ├─ totp.service.js
│  │   ├─ email.service.js
│  │   └─ sms.service.js
│  ├─ models/
│  │   └─ user.model.js
│  └─ routes/
│      └─ auth.routes.js
├─ config/
│   └─ redis.js
├─ .env
└─ package.json

Việc tách các service giúp chúng ta dễ dàng thay đổi nhà cung cấp (ví dụ chuyển từ Twilio sang Nexmo) mà không ảnh hưởng tới logic chung.

Cài đặt môi trường

npm init -y
npm install express jsonwebtoken bcryptjs dotenv speakeasy qrcode nodemailer ioredis twilio
npm install --save-dev nodemon

Thêm script khởi động nhanh trong package.json:

<pre class="brush:jscript;">
{
  "scripts": {
    "dev": "nodemon src/index.js"
  }
}
</pre>

Triển khai OTP dựa trên TOTP (Google Authenticator)

Chúng ta sẽ sử dụng thư viện speakeasy để sinh secret và mã TOTP, và qrcode để tạo QR code cho người dùng quét.

Service TOTP

<pre class="brush:jscript;">
// src/services/totp.service.js
const speakeasy = require('speakeasy');
const QRCode = require('qrcode');

/**
 * Tạo secret cho người dùng mới.
 * @param {string} email - Email của người dùng (được dùng làm label).
 * @returns {Promise<{ otpauth_url: string, base32: string }>}
 */
async function generateSecret(email) {
  const secret = speakeasy.generateSecret({
    length: 20,
    name: `MyApp (${email})`
  });
  return {
    otpauth_url: secret.otpauth_url,
    base32: secret.base32
  };
}

/**
 * Tạo QR code dạng data URL để người dùng quét.
 * @param {string} otpauthUrl
 * @returns {Promise<string>}
 */
async function generateQRCode(otpauthUrl) {
  return await QRCode.toDataURL(otpauthUrl);
}

/**
 * Xác thực mã TOTP do người dùng cung cấp.
 * @param {string} token - Mã 6 chữ số.
 * @param {string} secret - Secret base32 đã lưu trong DB.
 * @returns {boolean}
 */
function verifyToken(token, secret) {
  return speakeasy.totp.verify({
    secret,
    encoding: 'base32',
    token,
    window: 1 // chấp nhận lệch 30s một phía
  });
}

module.exports = { generateSecret, generateQRCode, verifyToken };
</pre>

Controller đăng ký TOTP

<pre class="brush:jscript;">
// src/controllers/mfa.controller.js
const User = require('../models/user.model');
const totpService = require('../services/totp.service');
const redis = require('../config/redis');

/**
 * Endpoint: GET /mfa/totp/setup
 * Trả về QR code và secret để người dùng cấu hình.
 */
async function setupTotp(req, res) {
  const userId = req.user.id; // giả sử đã có middleware xác thực JWT tạm thời
  const user = await User.findById(userId);
  if (!user) return res.status(404).json({ message: 'User not found' });

  // Nếu đã có secret thì không tạo mới
  if (user.totpSecret) {
    return res.json({ message: 'TOTP already configured' });
  }

  const { otpauth_url, base32 } = await totpService.generateSecret(user.email);
  const qrCodeDataUrl = await totpService.generateQRCode(otpauth_url);

  // Lưu secret tạm thời vào Redis, thời gian 10 phút để người dùng kích hoạt
  await redis.setex(`totp:setup:${userId}`, 600, base32);

  res.json({ qrCode: qrCodeDataUrl, secret: base32 });
}

/**
 * Endpoint: POST /mfa/totp/verify
 * Xác thực mã TOTP và lưu secret vĩnh viễn.
 */
async function verifyTotpSetup(req, res) {
  const { token } = req.body;
  const userId = req.user.id;
  const tempSecret = await redis.get(`totp:setup:${userId}`);
  if (!tempSecret) return res.status(400).json({ message: 'Setup session expired' });

  const isValid = totpService.verifyToken(token, tempSecret);
  if (!isValid) return res.status(400).json({ message: 'Invalid token' });

  // Lưu secret vào DB
  await User.findByIdAndUpdate(userId, { totpSecret: tempSecret });
  await redis.del(`totp:setup:${userId}`);
  res.json({ message: 'TOTP configured successfully' });
}

module.exports = { setupTotp, verifyTotpSetup };
</pre>

Quá trình này cho phép người dùng tự cấu hình TOTP một cách an toàn mà không lưu secret trên client.

Triển khai OTP qua email

Service email

<pre class="brush:jscript;">
// src/services/email.service.js
const nodemailer = require('nodemailer');
require('dotenv').config();

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

/**
 * Gửi OTP tới email.
 * @param {string} to - Địa chỉ email người nhận.
 * @param {string} otp - Mã OTP.
 */
async function sendOtpEmail(to, otp) {
  const mailOptions = {
    from: `"MyApp" <${process.env.SMTP_USER}>`,
    to,
    subject: 'Mã xác thực đa yếu tố (MFA)',
    text: `Mã OTP của bạn là: ${otp}. Mã có hiệu lực trong 5 phút.`
  };
  await transporter.sendMail(mailOptions);
}

module.exports = { sendOtpEmail };
</pre>

Controller OTP email

<pre class="brush:jscript;">
// src/controllers/mfa.controller.js (tiếp)
const crypto = require('crypto');
const emailService = require('../services/email.service');
const redis = require('../config/redis');

/**
 * Gửi OTP qua email và lưu vào Redis.
 */
async function sendEmailOtp(req, res) {
  const userId = req.user.id;
  const user = await User.findById(userId);
  if (!user) return res.status(404).json({ message: 'User not found' });

  const otp = crypto.randomInt(100000, 999999).toString();
  await redis.setex(`mfa:email:${userId}`, 300, otp); // 5 phút
  await emailService.sendOtpEmail(user.email, otp);
  res.json({ message: 'OTP sent to email' });
}

/**
 * Xác thực OTP email.
 */
async function verifyEmailOtp(req, res) {
  const { token } = req.body;
  const userId = req.user.id;
  const storedOtp = await redis.get(`mfa:email:${userId}`);
  if (!storedOtp) return res.status(400).json({ message: 'OTP expired' });
  if (storedOtp !== token) return res.status(400).json({ message: 'Invalid OTP' });

  await redis.del(`mfa:email:${userId}`);
  // Tạo JWT đầy đủ ở đây (bỏ qua chi tiết)
  const accessToken = generateAccessToken(userId);
  res.json({ accessToken });
}

module.exports = { sendEmailOtp, verifyEmailOtp };
</pre>

Triển khai OTP qua SMS (Twilio)

Service SMS

<pre class="brush:jscript;">
// src/services/sms.service.js
const twilio = require('twilio');
require('dotenv').config();

const client = twilio(process.env.TWILIO_SID, process.env.TWILIO_AUTH_TOKEN);

/**
 * Gửi OTP tới số điện thoại.
 * @param {string} to - Số điện thoại (E.164).
 * @param {string} otp - Mã OTP.
 */
async function sendOtpSms(to, otp) {
  await client.messages.create({
    body: `Mã OTP của bạn là ${otp}. Có hiệu lực 5 phút.`,
    from: process.env.TWILIO_PHONE,
    to
  });
}

module.exports = { sendOtpSms };
</pre>

Controller OTP SMS

<pre class="brush:jscript;">
// src/controllers/mfa.controller.js (tiếp)
const smsService = require('../services/sms.service');

async function sendSmsOtp(req, res) {
  const userId = req.user.id;
  const user = await User.findById(userId);
  if (!user || !user.phone) return res.status(400).json({ message: 'Phone number not set' });

  const otp = crypto.randomInt(100000, 999999).toString();
  await redis.setex(`mfa:sms:${userId}`, 300, otp);
  await smsService.sendOtpSms(user.phone, otp);
  res.json({ message: 'OTP sent via SMS' });
}

async function verifySmsOtp(req, res) {
  const { token } = req.body;
  const userId = req.user.id;
  const storedOtp = await redis.get(`mfa:sms:${userId}`);
  if (!storedOtp) return res.status(400).json({ message: 'OTP expired' });
  if (storedOtp !== token) return res.status(400).json({ message: 'Invalid OTP' });

  await redis.del(`mfa:sms:${userId}`);
  const accessToken = generateAccessToken(userId);
  res.json({ accessToken });
}

module.exports = { sendSmsOtp, verifySmsOtp };
</pre>

Middleware bảo vệ route

Sau khi người dùng đã vượt qua MFA, chúng ta sẽ cấp JWT có thời gian sống dài hơn (ví dụ 1 ngày). Các route nhạy cảm sẽ được bảo vệ bằng middleware kiểm tra JWT.

<pre class="brush:jscript;">
// src/middlewares/auth.middleware.js
const jwt = require('jsonwebtoken');
require('dotenv').config();

function verifyToken(req, res, next) {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];
  if (!token) return res.sendStatus(401);

  jwt.verify(token, process.env.JWT_SECRET, (err, payload) => {
    if (err) return res.sendStatus(403);
    req.user = { id: payload.sub };
    next();
  });
}

module.exports = { verifyToken };
</pre>

Áp dụng middleware vào route:

<pre class="brush:jscript;">
// src/routes/auth.routes.js
const express = require('express');
const router = express.Router();
const authCtrl = require('../controllers/auth.controller');
const mfaCtrl = require('../controllers/mfa.controller');
const { verifyToken } = require('../middlewares/auth.middleware');

router.post('/login', authCtrl.login);
router.get('/mfa/totp/setup', verifyToken, mfaCtrl.setupTotp);
router.post('/mfa/totp/verify', verifyToken, mfaCtrl.verifyTotpSetup);
router.post('/mfa/email/send', verifyToken, mfaCtrl.sendEmailOtp);
router.post('/mfa/email/verify', verifyToken, mfaCtrl.verifyEmailOtp);
router.post('/mfa/sms/send', verifyToken, mfaCtrl.sendSmsOtp);
router.post('/mfa/sms/verify', verifyToken, mfaCtrl.verifySmsOtp);

router.get('/protected', verifyToken, (req, res) => {
  res.json({ message: 'You have accessed a protected resource' });
});

module.exports = router;
</pre>

Quản lý trạng thái OTP và phòng chống brute‑force

Việc lưu OTP trong Redis cho phép chúng ta áp dụng các biện pháp sau:

  • Rate limiting: Đếm số lần yêu cầu gửi OTP trong một khoảng thời gian (ví dụ 5 lần/giờ). Nếu vượt ngưỡng, trả về lỗi.
  • Lockout: Khi người dùng nhập sai OTP liên tục (ví dụ 5 lần), khóa tài khoản trong 15 phút.

Ví dụ cài đặt rate limiting cho OTP email:

<pre class="brush:jscript;">
// src/middlewares/rateLimitOtp.js
const redis = require('../config/redis');

async function limitOtp(req, res, next) {
  const userId = req.user.id;
  const key = `rate:otp:${userId}`;
  const count = await redis.incr(key);
  if (count === 1) {
    // Đặt thời gian hết hạn 1 giờ cho key
    await redis.expire(key, 3600);
  }
  if (count > 5) {
    return res.status(429).json({ message: 'Too many OTP requests, please try later' });
  }
  next();
}

module.exports = { limitOtp };
</pre>

Sau đó chèn middleware này vào các route gửi OTP.

Kiểm thử và giám sát

Để chắc chắn rằng MFA hoạt động đúng, chúng ta nên viết các test tự động (Jest) và sử dụng công cụ giám sát như Prometheus + Grafana để theo dõi số lượng OTP được gửi, thời gian phản hồi, và tỉ lệ lỗi.

Ví dụ một test đơn giản cho endpoint /mfa/email/send:

<pre class="brush:jscript;">
// tests/mfa.email.test.js
const request = require('supertest');
const app = require('../src/app'); // Express app

describe('Email OTP', () => {
  let token;
  beforeAll(async () => {
    // Đăng nhập và lấy token tạm thời
    const res = await request(app)
      .post('/login')
      .send({ email: '[email protected]', password: 'Password123' });
    token = res.body.tempToken;
  });

  it('should send OTP email', async () => {
    const res = await request(app)
      .post('/mfa/email/send')
      .set('Authorization', `Bearer ${token}`);
    expect(res.statusCode).toBe(200);
    expect(res.body.message).toBe('OTP sent to email');
  });
});
</pre>

Sau khi triển khai, chúng ta có thể dùng curl để kiểm tra nhanh:

curl -X POST http://localhost:3000/mfa/email/send \
  -H "Authorization: Bearer "

Kết luận

Việc tích hợp đa yếu tố vào API Node.js Express không chỉ nâng cao mức độ bảo mật mà còn giúp xây dựng niềm tin với người dùng cuối. Bằng cách sử dụng các công cụ chuẩn như speakeasy, nodemailer, twilioRedis, chúng ta có thể triển khai nhanh chóng các phương thức MFA, đồng thời áp dụng các biện pháp phòng chống brute‑force và rate limiting để giảm thiểu rủi ro. Khi đã nắm vững kiến trúc và các mẫu code trên, bạn có thể mở rộng sang các phương thức sinh sinh trắc học hoặc WebAuthn trong tương lai. Tham khảo khóa học "Lập trình Back-End với NodeJS Express" tại đây