0

# Bài 19 — Đăng nhập & Đăng ký thật với JWT

⬅️ Bài trước | Mục lục | Bài tiếp theo ➡️


🎯 Mục tiêu

Trong source gốc, /login và /register chỉ là form HTML tĩnh — bấm nút không có gì xảy ra. Bài này hoàn thiện chúng thành hệ thống xác thực thật:

  • Băm mật khẩu với bcrypt
  • Cấp và xác minh JWT (JSON Web Token)
  • Middleware bảo vệ route ở backend
  • Lưu token và quản lý phiên đăng nhập ở frontend
  • Chặn người lạ vào /admin

📚 1. Xác thực hoạt động thế nào?

ĐĂNG KÝ
  Client gửi: { email, password: "123456" }
      ↓
  Server: bcrypt.hash("123456") → "$2b$10$N9qo8uLO..."
      ↓
  Lưu vào DB (KHÔNG BAO GIỜ lưu mật khẩu gốc)

ĐĂNG NHẬP
  Client gửi: { email, password: "123456" }
      ↓
  Server tìm user theo email
      ↓
  bcrypt.compare("123456", "$2b$10$N9qo8uLO...") → true
      ↓
  Server tạo JWT: jwt.sign({ userId, role }, SECRET, { expiresIn: "7d" })
      ↓
  Trả về token cho client

GỌI API CẦN QUYỀN
  Client gửi header: Authorization: Bearer eyJhbGciOi...
      ↓
  Middleware: jwt.verify(token, SECRET) → { userId, role }
      ↓
  Hợp lệ → cho qua | Không hợp lệ → 401

Vì sao phải băm mật khẩu?

Nếu lưu mật khẩu dạng gốc và database bị lộ, kẻ tấn công có ngay mật khẩu của mọi người. Tệ hơn, nhiều người dùng chung một mật khẩu cho nhiều dịch vụ.

bcrypt là hàm băm một chiều: từ mật khẩu ra được chuỗi băm, nhưng từ chuỗi băm không thể suy ngược ra mật khẩu.

"123456" → bcrypt → "$2b$10$N9qo8uLOickgx2ZMRZoMye..."

Nó cũng cố ý chậm (~100ms) để chống tấn công dò mật khẩu hàng loạt, và tự thêm "muối" (salt) ngẫu nhiên nên hai người cùng mật khẩu vẫn ra chuỗi băm khác nhau.

JWT là gì?

Một chuỗi gồm 3 phần ngăn bởi dấu chấm:

eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiI2NmMwIiwicm9sZSI6InVzZXIifQ.dBjftJeZ4CVP...
└──── header ────┘ └──────────── payload ────────────┘ └─── signature ───┘
  • header — thuật toán ký
  • payload — dữ liệu bạn nhét vào (userId, role, thời hạn)
  • signature — chữ ký tạo từ header + payload + SECRET

⚠️ JWT không mã hóa, chỉ ký. Payload chỉ được encode Base64 — ai cũng đọc được (thử dán token vào https://jwt.io). Đừng bao giờ để mật khẩu hay thông tin nhạy cảm vào payload.

Chữ ký đảm bảo token không bị sửa — nếu ai đó đổi role từ user thành admin, chữ ký sẽ không khớp và server từ chối.


💻 2. Backend: cài đặt và cấu hình

cd backend
npm install bcrypt jsonwebtoken dotenv

Tạo backend/.env:

PORT=5000
MONGODB_URI=mongodb://localhost:27017
DB_NAME=lith_perfume
JWT_SECRET=doi_chuoi_nay_thanh_chuoi_ngau_nhien_that_dai_va_bi_mat
JWT_EXPIRES_IN=7d

🔒 .env KHÔNG được commit lên Git. Thêm vào backend/.gitignore:

.env
node_modules
public/img/*
!public/img/.gitkeep

Sinh chuỗi bí mật mạnh:

node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"

Nạp .env ở dòng đầu backend/bin/www:

#!/usr/bin/env node
require('dotenv').config();      // ← THÊM DÒNG NÀY ĐẦU TIÊN

var app = require('../app');
// ...

💻 3. Backend: router users.js

Tạo backend/routes/users.js (thay thế file mẫu):

var express = require("express");
var router = express.Router();
const bcrypt = require("bcrypt");
const jwt = require("jsonwebtoken");
const { ObjectId } = require("mongodb");
const connectDb = require("../model/db");

const SALT_ROUNDS = 10;
const JWT_SECRET = process.env.JWT_SECRET;
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "7d";

// ============================================
// POST /users/register — Đăng ký
// ============================================
router.post("/register", async (req, res, next) => {
  try {
    const { name, email, password } = req.body;

    // --- Validate ---
    if (!name || !name.trim()) {
      return res.status(400).json({ message: "Vui lòng nhập họ tên" });
    }
    if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
      return res.status(400).json({ message: "Email không hợp lệ" });
    }
    if (!password || password.length < 6) {
      return res.status(400).json({ message: "Mật khẩu phải có ít nhất 6 ký tự" });
    }

    const db = await connectDb();
    const users = db.collection("users");

    // --- Email đã tồn tại? ---
    const existing = await users.findOne({ email: email.toLowerCase() });
    if (existing) {
      return res.status(409).json({ message: "Email đã được sử dụng" });
    }

    // --- Băm mật khẩu ---
    const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS);

    const newUser = {
      name: name.trim(),
      email: email.toLowerCase().trim(),
      password: hashedPassword,
      role: "user",              // "user" | "admin"
      createdAt: new Date(),
    };

    const result = await users.insertOne(newUser);

    res.status(201).json({
      message: "Đăng ký thành công",
      user: { _id: result.insertedId, name: newUser.name, email: newUser.email, role: newUser.role },
    });
  } catch (error) {
    next(error);
  }
});

// ============================================
// POST /users/login — Đăng nhập
// ============================================
router.post("/login", async (req, res, next) => {
  try {
    const { email, password } = req.body;

    if (!email || !password) {
      return res.status(400).json({ message: "Vui lòng nhập email và mật khẩu" });
    }

    const db = await connectDb();
    const user = await db.collection("users").findOne({ email: email.toLowerCase() });

    // Cố ý dùng CHUNG một thông báo cho cả 2 trường hợp
    if (!user) {
      return res.status(401).json({ message: "Email hoặc mật khẩu không đúng" });
    }

    const isMatch = await bcrypt.compare(password, user.password);
    if (!isMatch) {
      return res.status(401).json({ message: "Email hoặc mật khẩu không đúng" });
    }

    const token = jwt.sign(
      { userId: user._id.toString(), email: user.email, role: user.role },
      JWT_SECRET,
      { expiresIn: JWT_EXPIRES_IN }
    );

    res.status(200).json({
      message: "Đăng nhập thành công",
      token,
      user: { _id: user._id, name: user.name, email: user.email, role: user.role },
    });
  } catch (error) {
    next(error);
  }
});

// ============================================
// GET /users/me — Thông tin người đang đăng nhập
// ============================================
const { verifyToken } = require("../middleware/auth");

router.get("/me", verifyToken, async (req, res, next) => {
  try {
    const db = await connectDb();
    const user = await db.collection("users").findOne(
      { _id: new ObjectId(req.user.userId) },
      { projection: { password: 0 } }        // KHÔNG trả về mật khẩu
    );

    if (!user) return res.status(404).json({ message: "Không tìm thấy người dùng" });

    res.status(200).json(user);
  } catch (error) {
    next(error);
  }
});

module.exports = router;

🔍 Hai chi tiết bảo mật quan trọng

a) Thông báo lỗi giống nhau cho "sai email" và "sai mật khẩu"

Nếu bạn báo "Email không tồn tại" và "Mật khẩu sai" riêng biệt, kẻ tấn công có thể dò xem email nào đã đăng ký trên hệ thống (user enumeration).

b) projection: { password: 0 }

Loại bỏ trường password khỏi kết quả truy vấn. Không bao giờ gửi chuỗi băm về client.


💻 4. Backend: middleware xác thực

Tạo backend/middleware/auth.js:

const jwt = require("jsonwebtoken");

const JWT_SECRET = process.env.JWT_SECRET;

// Kiểm tra token hợp lệ
function verifyToken(req, res, next) {
  const authHeader = req.headers["authorization"];

  if (!authHeader || !authHeader.startsWith("Bearer ")) {
    return res.status(401).json({ message: "Chưa đăng nhập" });
  }

  const token = authHeader.split(" ")[1];   // "Bearer eyJhbG..." → "eyJhbG..."

  try {
    const decoded = jwt.verify(token, JWT_SECRET);
    req.user = decoded;    // gắn thông tin user vào request để route sau dùng
    next();
  } catch (error) {
    if (error.name === "TokenExpiredError") {
      return res.status(401).json({ message: "Phiên đăng nhập đã hết hạn" });
    }
    return res.status(401).json({ message: "Token không hợp lệ" });
  }
}

// Kiểm tra quyền admin (dùng SAU verifyToken)
function requireAdmin(req, res, next) {
  if (!req.user || req.user.role !== "admin") {
    return res.status(403).json({ message: "Bạn không có quyền truy cập" });
  }
  next();
}

module.exports = { verifyToken, requireAdmin };

Áp dụng cho các route cần bảo vệ

// backend/routes/products.js
const { verifyToken, requireAdmin } = require("../middleware/auth");

// Ai cũng xem được
router.get("/", async (req, res, next) => { /* ... */ });

// Chỉ admin mới thêm được
router.post("/", verifyToken, requireAdmin, upload.single("image"), async (req, res, next) => {
  /* ... */
});
// backend/routes/oder.js
// Chỉ admin xem được toàn bộ đơn hàng
router.get("/", verifyToken, requireAdmin, async (req, res, next) => { /* ... */ });

// Khách vãng lai vẫn đặt hàng được — KHÔNG cần token
router.post("/", async (req, res, next) => { /* ... */ });

Gắn router users vào backend/app.js:

var usersRouter = require('./routes/users');
app.use('/users', usersRouter);

Tạo tài khoản admin đầu tiên

Đăng ký một tài khoản qua API, rồi vào mongosh nâng quyền:

use lith_perfume
db.users.updateOne(
  { email: "admin@lith.com" },
  { $set: { role: "admin" } }
)

Thêm index để email không bị trùng ở tầng database:

db.users.createIndex({ email: 1 }, { unique: true })

💻 5. Frontend: quản lý phiên đăng nhập

Tạo frontend/redux/slices/authSlicer.js:

import { createSlice } from "@reduxjs/toolkit";

const authSlice = createSlice({
  name: "auth",
  initialState: { user: null, token: null },
  reducers: {
    setCredentials: (state, action) => {
      state.user = action.payload.user;
      state.token = action.payload.token;
    },
    logout: () => ({ user: null, token: null }),
  },
});

export const { setCredentials, logout } = authSlice.actions;
export default authSlice.reducer;

Thêm vào frontend/redux/store.js:

import authSlicer from "./slices/authSlicer";

const rootReducer = combineReducers({
  cart: cartSlicer,
  sort: sortSlide,
  filter: filterSlicer,
  auth: authSlicer,          // ← thêm
});

const persistConfig = {
  key: "root",
  storage,
  version: 1,
  whitelist: ["cart", "auth"],   // ← lưu cả auth để F5 không bị đăng xuất
};

⚠️ Về bảo mật: lưu token trong localStorage khiến nó có thể bị đánh cắp qua tấn công XSS. Cách an toàn hơn là dùng httpOnly cookie — JavaScript không đọc được, nên XSS không lấy được token.

Tài liệu dùng localStorage vì đơn giản và phù hợp mức độ dự án học tập. Mục 8 sẽ nói kỹ hơn.


💻 6. Frontend: trang đăng nhập

frontend/app/(user)/login/page.js:

"use client"
import { useState } from "react";
import { useDispatch } from "react-redux";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { setCredentials } from "@/redux/slices/authSlicer";
import { API } from "@/lib/fetcher";

export default function Login() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState("");
  const [loading, setLoading] = useState(false);

  const dispatch = useDispatch();
  const router = useRouter();

  const handleSubmit = async (e) => {
    e.preventDefault();
    setError("");
    setLoading(true);

    try {
      const res = await fetch(`${API}/users/login`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ email, password }),
      });
      const data = await res.json();

      if (!res.ok) {
        setError(data.message || "Đăng nhập thất bại");
        return;
      }

      dispatch(setCredentials({ user: data.user, token: data.token }));
      router.push(data.user.role === "admin" ? "/admin" : "/");
    } catch (err) {
      setError("Không kết nối được tới máy chủ");
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="container mt-5" style={{ maxWidth: 480 }}>
      <h2>Đăng Nhập</h2>

      {error && <div className="alert alert-danger">{error}</div>}

      <form onSubmit={handleSubmit}>
        <div className="mb-3">
          <label htmlFor="email" className="form-label">Email</label>
          <input
            type="email"
            className="form-control"
            id="email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            required
          />
        </div>

        <div className="mb-3">
          <label htmlFor="password" className="form-label">Mật Khẩu</label>
          <input
            type="password"
            className="form-control"
            id="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            required
          />
        </div>

        <button type="submit" className="btn btn-dark w-100" disabled={loading}>
          {loading ? "Đang đăng nhập..." : "Đăng Nhập"}
        </button>
      </form>

      <p className="mt-3 text-center">
        Chưa có tài khoản? <Link href="/register">Đăng ký ngay</Link>
      </p>
    </div>
  );
}

frontend/app/(user)/register/page.js làm tương tự với 3 trường (name, email, password), gọi POST /users/register, thành công thì chuyển sang /login.


💻 7. Frontend: bảo vệ khu admin

Tạo frontend/app/(admin)/component/authguard.js:

"use client"
import { useEffect, useState } from "react";
import { useSelector } from "react-redux";
import { useRouter } from "next/navigation";

export default function AuthGuard({ children }) {
  const { user, token } = useSelector((state) => state.auth);
  const router = useRouter();
  const [checked, setChecked] = useState(false);

  useEffect(() => {
    if (!token) {
      router.replace("/login");
      return;
    }
    if (user?.role !== "admin") {
      router.replace("/");
      return;
    }
    setChecked(true);
  }, [token, user, router]);

  if (!checked) {
    return (
      <div className="d-flex justify-content-center align-items-center vh-100">
        <div className="spinner-border" role="status">
          <span className="visually-hidden">Đang kiểm tra quyền...</span>
        </div>
      </div>
    );
  }

  return children;
}

Vì AuthGuard dùng Redux, layout admin cũng cần <Providers>:

// frontend/app/(admin)/layout.js
import Providers from "@/redux/provider";
import AuthGuard from "./component/authguard";

export default function AdminLayout({ children }) {
  return (
    <html lang="vi">
      <body style={{ background: "#eff8ff" }}>
        <Providers>
          <AuthGuard>
            {/* sidebar, topbar, {children} — như bài 17 */}
          </AuthGuard>
        </Providers>
        <script src="/js/bootstrap.bundle.min.js"></script>
      </body>
    </html>
  );
}

⚠️ Bảo vệ ở client chỉ là "khóa cửa hình thức"

AuthGuard chỉ ẩn giao diện. Người dùng am hiểu vẫn có thể:

  • Sửa Redux state qua DevTools để role = "admin"
  • Gọi thẳng API bằng Postman

Bảo vệ thật nằm ở backend — middleware verifyToken + requireAdmin. Kể cả khi họ vào được giao diện admin, mọi request thêm/sửa/xóa vẫn bị chặn 403.

Đây là nguyên tắc: client-side guard cho trải nghiệm, server-side guard cho bảo mật.


💻 8. Gửi token trong mọi request

Cập nhật frontend/lib/fetcher.js:

export const API = process.env.NEXT_PUBLIC_API_URL;
export const IMG = process.env.NEXT_PUBLIC_IMA_URL;

// Lấy token từ redux-persist trong localStorage
function getToken() {
  if (typeof window === "undefined") return null;      // đang chạy ở server
  try {
    const persisted = JSON.parse(localStorage.getItem("persist:root"));
    if (!persisted?.auth) return null;
    return JSON.parse(persisted.auth).token;
  } catch {
    return null;
  }
}

export const fetcher = async (url) => {
  const token = getToken();
  const res = await fetch(url, {
    headers: token ? { Authorization: `Bearer ${token}` } : {},
  });

  if (!res.ok) {
    const error = new Error("Lỗi khi tải dữ liệu");
    error.status = res.status;
    error.info = await res.json().catch(() => ({}));
    throw error;
  }
  return res.json();
};

// Dùng cho POST/PUT/DELETE với body JSON
export async function apiFetch(url, options = {}) {
  const token = getToken();
  const res = await fetch(url, {
    ...options,
    headers: {
      ...(options.body instanceof FormData ? {} : { "Content-Type": "application/json" }),
      ...(token ? { Authorization: `Bearer ${token}` } : {}),
      ...options.headers,
    },
  });

  const data = await res.json().catch(() => ({}));
  if (!res.ok) {
    const error = new Error(data.message || "Yêu cầu thất bại");
    error.status = res.status;
    throw error;
  }
  return data;
}

💡 Chú ý dòng options.body instanceof FormData ? {} : {...} — khi upload file, không được đặt Content-Type (bài 18 mục 4).


✅ 9. Kiểm thử

Test backend bằng Postman

1. Đăng ký

POST http://localhost:5000/users/register
{ "name": "Admin", "email": "admin@lith.com", "password": "123456" }

→ 201 + thông tin user (không có password)

2. Đăng ký lại cùng email → 409 "Email đã được sử dụng"

3. Đăng nhập

POST http://localhost:5000/users/login
{ "email": "admin@lith.com", "password": "123456" }

→ 200 + token

4. Đăng nhập sai mật khẩu → 401 "Email hoặc mật khẩu không đúng"

5. Gọi API cần quyền mà không có token

POST http://localhost:5000/products

→ 401 "Chưa đăng nhập"

6. Gọi với token của user thường

Authorization: Bearer <token của user>

→ 403 "Bạn không có quyền truy cập"

7. Nâng quyền admin trong mongosh, đăng nhập lại, gọi lại → 201 thành công

Kiểm tra mật khẩu trong DB

Compass → collection users → trường password phải là:

$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy

Nếu bạn thấy 123456 → có gì đó sai nghiêm trọng, kiểm tra lại bcrypt.hash.

Test frontend

  1. Vào /register, tạo tài khoản mới → chuyển sang /login
  2. Đăng nhập → chuyển về / (hoặc /admin nếu là admin)
  3. Vào thẳng /admin khi chưa đăng nhập → bị đẩy về /login
  4. Đăng nhập bằng tài khoản user rồi vào /admin → bị đẩy về /
  5. Đăng nhập bằng admin → vào được /admin
  6. F5 → vẫn đang đăng nhập (nhờ redux-persist)

📚 10. Ghi chú về bảo mật

Vấn đề Mức độ Giải pháp thật
Token trong localStorage dễ bị XSS Cao Dùng httpOnly cookie + CSRF token
Không có refresh token Trung bình Access token 15 phút + refresh token 7 ngày
Không giới hạn số lần đăng nhập sai Trung bình express-rate-limit, khóa tài khoản sau 5 lần sai
Không xác thực email Thấp Gửi link kích hoạt qua email
Không có "quên mật khẩu" Thấp Token đặt lại mật khẩu gửi qua email
CORS mở cho mọi domain Cao (khi deploy) cors({ origin: 'https://ten-mien-cua-ban.com' })

Với dự án học tập, mức hiện tại là đủ. Nhưng hãy biết những giới hạn này trước khi đưa lên môi trường thật.


📝 Bài tập

  1. Nút Đăng xuất ở navbar và topbar admin: dispatch(logout()) + router.push("/"). Ẩn/hiện menu "Đăng nhập"/"Đăng xuất" theo trạng thái.

  2. Hiển thị tên người dùng ở navbar thay cho chữ "Tài Khoản" khi đã đăng nhập.

  3. Gắn userId vào đơn hàng: khi đặt hàng mà đã đăng nhập, backend lấy req.user.userId gắn vào đơn. Thêm trang /my-orders xem đơn của mình.

  4. Trang hồ sơ /profile: hiện thông tin, cho đổi tên và mật khẩu.

  5. Rate limiting:

    npm install express-rate-limit
    

    Giới hạn 5 lần đăng nhập sai / 15 phút / IP.

  6. Thử "hack" AuthGuard: đăng nhập bằng user thường, mở Redux DevTools, dispatch action đổi role thành "admin". Bạn sẽ vào được giao diện admin — nhưng thử thêm sản phẩm xem sao. Đây là bài học trực quan nhất về việc tại sao bảo mật phải nằm ở server.


⬅️ Bài trước | Mục lục | Bài tiếp theo: CRUD admin đầy đủ ➡️


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í