Vibe Code
Giáo trình/Phần 3 — App có dự liệu

Đăng nhập, phiên và phân quyền

Băm mật khẩu đúng cách, cookie phiên an toàn, và luật quan trọng nhất của Cấp 3: nút bị ẩn không phải là phân quyền.

Cấp 350 phút làm

Đây là bài mà sai thì không ai thấy — cho đến ngày có người thấy. Lớp 6 của sơ đồ bắt đầu từ đây.

Luật quan trọng nhất, đọc trước khi viết dòng nào

Nút bị ẩn không phải là phân quyền. Mọi kiểm tra quyền phải nằm ở máy chủ.

Giao diện ẩn nút "Xóa đơn" với nhân viên thường — rất tốt cho trải nghiệm. Nhưng nhân viên đó chỉ cần mở F12 và gọi thẳng:

fetch("/api/don-hang/42", { method: "DELETE" })

Nếu Worker không kiểm quyền, đơn hàng bị xóa. Giao diện chỉ là gợi ý; Worker mới là cửa khóa.

Phép thử để tự kiểm, làm cho mọi app: đăng nhập bằng tài khoản quyền thấp nhất, rồi gọi thẳng các đường API của admin bằng curl. Cái nào không trả về 403 là một lỗ hổng.

Mật khẩu: băm, không mã hóa, không lưu thô

// Băm bằng PBKDF2 — có sẵn trong Workers, không cần thư viện
async function bamMatKhau(matKhau, muoi) {
  const key = await crypto.subtle.importKey(
    "raw", new TextEncoder().encode(matKhau), "PBKDF2", false, ["deriveBits"]
  );
  const bits = await crypto.subtle.deriveBits(
    { name: "PBKDF2", salt: muoi, iterations: 100000, hash: "SHA-256" }, key, 256
  );
  return [...new Uint8Array(bits)].map(b => b.toString(16).padStart(2, "0")).join("");
}

async function taoMatKhau(matKhau) {
  const muoi = crypto.getRandomValues(new Uint8Array(16));
  const bam = await bamMatKhau(matKhau, muoi);
  const muoiHex = [...muoi].map(b => b.toString(16).padStart(2, "0")).join("");
  return `pbkdf2$100000$${muoiHex}$${bam}`;     // lưu cả tham số vào chuỗi
}

async function kiemMatKhau(matKhau, luuTru) {
  const [thuatToan, vong, muoiHex, bamCu] = luuTru.split("$");
  if (thuatToan !== "pbkdf2") return false;
  const muoi = new Uint8Array(muoiHex.match(/../g).map(h => parseInt(h, 16)));
  const bamMoi = await bamMatKhau(matKhau, muoi, +vong);
  return soSanhAnToan(bamMoi, bamCu);
}

// So sánh thời gian không đổi — chống dò theo thời gian phản hồi
function soSanhAnToan(a, b) {
  if (a.length !== b.length) return false;
  let r = 0;
  for (let i = 0; i < a.length; i++) r |= a.charCodeAt(i) ^ b.charCodeAt(i);
  return r === 0;
}

Bốn điểm, mỗi điểm một lý do:

  1. Mỗi người một "muối" (salt) ngẫu nhiên. Không có muối thì hai người đặt mật khẩu giống nhau sẽ có cùng chuỗi băm — và bảng tra sẵn phá được ngay.
  2. 100.000 vòng lặp. Làm việc dò mật khẩu chậm đi hàng vạn lần. Đừng hạ xuống cho "nhanh".
  3. Lưu cả tham số vào chuỗi (pbkdf2$100000$...). Sau này muốn nâng số vòng, bạn vẫn kiểm được mật khẩu cũ.
  4. So sánh thời gian không đổi. a === b thoát sớm khi gặp ký tự khác, và chênh lệch thời gian đó rò rỉ thông tin.
Đổi tham số băm là khóa cửa với toàn bộ người dùng cũ

Số vòng lặp, kiểu băm, cách ghép muối — đổi bất kỳ thứ nào mà không có đường chuyển tiếp là mọi mật khẩu cũ không còn khớp. Cả công ty không đăng nhập được vào sáng hôm sau, và bạn không có cách nào khôi phục vì mật khẩu gốc đã băm rồi.

Luật: ghi các hằng số băm vào README kèm dòng chữ "đổi là mất toàn bộ mật khẩu cũ". Muốn nâng cấp thì làm dần: khi ai đăng nhập đúng bằng tham số cũ, băm lại bằng tham số mới ngay lúc đó rồi lưu đè.

Hai cách giữ phiên. Với app Vibe Code, cookie + phiên lưu phía máy chủ là lựa chọn đúng gần như luôn luôn:

Cookie + phiên máy chủ JWT trong localStorage
Thu hồi ngay khi sa thải nhân viên Được Không, phải chờ hết hạn
Chống mã độc đọc trộm (XSS) Được (HttpOnly) Không — JS đọc được
Phải tra cơ sở dữ liệu mỗi lượt Có (rất rẻ nếu dùng KV) Không
async function dangNhap(request, env) {
  const { dangNhap: ten, matKhau } = await request.json();

  const u = await env.DB.prepare(
    "SELECT id, ten, vai_tro, mat_khau_bam, khoa FROM nguoi_dung WHERE dang_nhap = ?"
  ).bind(ten).first();

  // Thông báo GIỐNG NHAU cho sai tài khoản và sai mật khẩu
  const sai = () => json({ loi: "Tai khoan hoac mat khau khong dung" }, 401);
  if (!u || u.khoa) return sai();
  if (!(await kiemMatKhau(matKhau, u.mat_khau_bam))) return sai();

  const maPhien = crypto.randomUUID();
  await env.PHIEN.put(
    "phien:" + maPhien,
    JSON.stringify({ id: u.id, ten: u.ten, vai_tro: u.vai_tro }),
    { expirationTtl: 60 * 60 * 24 * 7 }               // 7 ngày
  );

  return new Response(JSON.stringify({ ok: true, ten: u.ten, vai_tro: u.vai_tro }), {
    headers: {
      "content-type": "application/json; charset=utf-8",
      "set-cookie": `phien=${maPhien}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=604800`,
    },
  });
}

Bốn thuộc tính cookie, học thuộc:

  • HttpOnly — JavaScript không đọc được. Chặn việc mã độc chèn vào trang lấy trộm phiên.
  • Secure — chỉ gửi qua HTTPS.
  • SameSite=Lax — chặn trang khác dùng cookie của bạn để gửi yêu cầu thay mặt người dùng.
  • Max-Age — hạn. 7 ngày là cân bằng hợp lý cho app nội bộ.
Vì sao thông báo lỗi phải giống nhau

Nếu "không có tài khoản này" khác với "sai mật khẩu", người ngoài dò được danh sách số điện thoại/email có trong hệ thống của bạn — chỉ cần gửi thử hàng loạt và đọc thông báo. Với app dùng số điện thoại làm tên đăng nhập, đây là rò rỉ danh sách nhân viên hoặc khách hàng.

Cùng lý do: trang "quên mật khẩu" luôn trả "Nếu tài khoản tồn tại, chúng tôi đã gửi hướng dẫn", dù có hay không.

Kiểm quyền ở mọi đường API

Viết một hàm, dùng ở mọi nơi, không có ngoại lệ:

async function nguoiDungTuPhien(request, env) {
  const cookie = request.headers.get("cookie") || "";
  const m = cookie.match(/(?:^|;\s*)phien=([^;]+)/);
  if (!m) return null;                       // chưa đăng nhập: KHÔNG đụng D1
  return await env.PHIEN.get("phien:" + m[1], { type: "json" });
}

function doiQuyen(u, ...vaiTro) {
  if (!u) return json({ loi: "Chua dang nhap" }, 401);
  if (!vaiTro.includes(u.vai_tro)) return json({ loi: "Khong co quyen" }, 403);
  return null;                               // null = qua cửa
}

// dùng:
if (p === "/api/don-hang" && request.method === "DELETE") {
  const u = await nguoiDungTuPhien(request, env);
  const chan = doiQuyen(u, "admin", "quan_ly");
  if (chan) return chan;
  return xoaDon(request, env, u);
}

Chú ý dòng if (!m) return null — người chưa có cookie thì không chạm vào cơ sở dữ liệu. Đây là chi tiết nhỏ nhưng quan trọng: nó làm cho việc bắn phá hàng loạt vào app của bạn chỉ nhận lại lỗi 401 rẻ tiền, không làm nghẽn D1. Xem thêm Bảo mật & rate limit.

Phân quyền theo dữ liệu, không chỉ theo vai trò. Nhân viên được xem đơn — nhưng chỉ đơn của mình:

// SAI: ai đăng nhập cũng xem được mọi đơn
"SELECT * FROM don_hang WHERE id = ?"

// ĐÚNG: ràng buộc ngay trong truy vấn
u.vai_tro === "admin"
  ? db.prepare("SELECT * FROM don_hang WHERE id = ?").bind(id)
  : db.prepare("SELECT * FROM don_hang WHERE id = ? AND tao_boi = ?").bind(id, u.id)

Lỗi "xem được dữ liệu của người khác bằng cách đổi số trong đường dẫn" là lỗ hổng phổ biến nhất trong app tự làm. Nó không hiện ra trong bất kỳ bài test thông thường nào, vì bạn luôn test bằng tài khoản admin của chính mình.

Đăng nhập bằng số điện thoại — chuyện rất Việt Namchuyên gia insight & case thực tế

Trong app nội bộ cho doanh nghiệp Việt Nam, số điện thoại thắng email gần như tuyệt đối: nhân viên bán hàng không có email công ty, nhưng ai cũng có số điện thoại và nhớ nó.

Ba việc phải làm kèm theo, cả ba đều từ sự cố thật:

  1. Chuẩn hóa số trước khi lưu và trước khi so. 0968991966, +84968991966, 84968991966, 0968 991 966 phải quy về một dạng. Không làm là nhân viên gõ đúng số mà báo sai tài khoản.
  2. Tài khoản tạo sẵn chưa đặt mật khẩu là một lỗ hổng. Admin nhập danh sách 200 nhân viên, chưa ai đăng nhập. Nếu luồng "đặt mật khẩu lần đầu" chỉ cần số điện thoại, bất kỳ ai biết số của sếp đều đặt mật khẩu cho tài khoản sếp. Phải có thêm một yếu tố: mã do admin cấp, hoặc ngày sinh, hoặc admin kích hoạt tay.
  3. Đừng để dò được số nào có trong hệ thống — thông báo lỗi giống nhau, như mục trên.

Những thứ không tự làm

Có ba việc mà tự viết gần như luôn thua, hãy dùng thứ có sẵn:

  • Gửi OTP qua SMS — dùng nhà cung cấp. Tự làm vừa đắt vừa không ổn định.
  • Đăng nhập bằng Google/Facebook — dùng OAuth chuẩn, đừng tự chế.
  • Mã hóa dữ liệu nhạy cảm — dùng crypto.subtle có sẵn, đừng tự nghĩ thuật toán.

Và một việc phải tự làm, không ủy quyền cho AI: quyết định ai được thấy gì. Đây là nghiệp vụ, không phải kỹ thuật. Viết ra giấy bảng vai trò × quyền trước khi ra đề cho AI.

Đề dựng đăng nhập đầy đủchuyên gia thực thi
Thêm đăng nhập vào app, theo đúng các ràng buộc:

- Bảng nguoi_dung: id, dang_nhap (số điện thoại đã chuẩn hóa về dạng
  0xxxxxxxxx), ten, vai_tro ('admin'|'quan_ly'|'nhan_vien'),
  mat_khau_bam, khoa (0/1), tao_luc.
- Băm PBKDF2 100000 vòng, SHA-256, muối ngẫu nhiên 16 byte, lưu dạng
  pbkdf2$vong$muoi$bam. So sánh thời gian không đổi.
- Phiên lưu trong KV, cookie HttpOnly + Secure + SameSite=Lax, 7 ngày.
- Thông báo sai tài khoản và sai mật khẩu PHẢI giống hệt nhau.
- Hàm nguyenDungTuPhien: không có cookie thì trả null NGAY, không
  đụng cơ sở dữ liệu.
- Mọi đường /api/ đều gọi kiểm quyền. Liệt kê cho tôi bảng
  đường API × vai trò được phép, để tôi duyệt TRƯỚC khi bạn viết mã.

Chưa làm quên mật khẩu — bước sau.

Câu áp chót là câu đáng giá nhất: bắt nó đưa bảng quyền ra duyệt trước. Đọc bảng mất 2 phút và bắt được hiểu sai ngay, thay vì phát hiện sau khi đã có 20 đường API.

Tôi lo nhất chuyện "lỡ làm mất mật khẩu của mọi người"người dùng bình thường góp ý

Nên tôi hỏi: nếu ai quên mật khẩu thì tôi tra ở đâu? Câu trả lời làm tôi yên tâm hẳn: bạn không tra được, và đó là điều tốt. Cái lưu trong máy là một chuỗi băm, không phải mật khẩu. Kể cả bạn, kể cả người lấy được cơ sở dữ liệu, cũng không đọc ra.

Nên việc cần làm không phải là "giữ mật khẩu cẩn thận" mà là có đường đặt lại: admin bấm một nút cấp mật khẩu tạm, bắt đổi khi đăng nhập lần đầu. Với app nội bộ, thế là đủ — không cần email khôi phục phức tạp.

Chốt review bài nàychuyên gia review
  • Đủ: băm PBKDF2 đúng cách, cookie phiên 4 thuộc tính, kiểm quyền ở máy chủ, phân quyền theo dữ liệu, chuẩn hóa số điện thoại, lỗ hổng tài khoản chưa đặt mật khẩu.
  • Chưa làm: quên mật khẩu, xác thực hai bước, đăng nhập một lần cho nhiều app. Thêm khi cần, đừng làm trước.
  • Rủi ro còn lại: bài này không chống được việc dò mật khẩu hàng loạt — cần thêm trần đăng nhập, và trần đó phải làm đúng cách, xem Bảo mật & rate limit. Đừng coi app đã an toàn khi mới xong bài này.
Bài tập chốt Cấp 3 — phần an toàn

Tự tấn công app của mình, bằng curl, với tài khoản quyền thấp nhất:

  1. Gọi mọi đường API của admin → phải nhận 403, không phải 200, không phải 500.
  2. Đổi số id trong đường dẫn sang dữ liệu của người khác → phải nhận 403 hoặc 404.
  3. Gọi API không kèm cookie → phải nhận 401.
  4. Đăng nhập sai mật khẩu và đăng nhập bằng tài khoản không tồn tại → hai thông báo phải giống hệt nhau, kể cả thời gian phản hồi.
  5. Mở F12 → document.cookie → không được thấy cookie phiên (vì HttpOnly).

Năm phép thử này là bảng kiểm bạn chạy lại mỗi khi thêm đường API mới. Lưu nó vào README.