Bỏ qua

Xác Thực Người Dùng (Authentication) — Chuyện Mật Khẩu & Thẻ Thông Hành (JWT + PBKDF2) 🔐

Chào bạn! Trong phần này, chúng ta sẽ tìm hiểu cách hệ thống nhận diện "Bạn là ai?" và bảo vệ mật khẩu của bạn không bị kẻ gian đánh cắp. Đừng lo nếu bạn thấy mấy cái tên như PBKDF2 hay JWT nghe có vẻ hàn lâm, chúng ta sẽ mổ xẻ nó bằng ngôn ngữ bình dân nhất.


Bức Tranh Tổng Thể (Luồng Đăng Nhập) 🖼️

Hãy xem cách hệ thống hoạt động từ lúc bạn gõ mật khẩu đến khi xem được dữ liệu nhé:

┌──────────────┐         ┌──────────────────┐         ┌────────────┐
│ Trình Duyệt  │         │   Não Bộ         │         │ Kho Dữ Liệu│
│ (Client)     │         │   (Worker)       │         │ (D1)       │
└──────┬───────┘         └────────┬─────────┘         └──────┬─────┘
       │                          │                          │
       │ 1. Gửi Email & Pass      │                          │
       │─────────────────────────►│                          │
       │                          │ 2. Lấy Mật khẩu mã hóa   │
       │                          │─────────────────────────►│
       │                          │◄─────────────────────────│
       │                          │ 3. Nhào nặn & So sánh    │
       │                          │    (PBKDF2 verify)       │
       │                          │                          │
       │ 4. Đúng -> Cấp Thẻ       │                          │
       │    (Mã Token JWT)        │                          │
       │◄─────────────────────────│                          │
       │                          │                          │
       │ 5. Lấy Danh sách nhóm    │                          │
       │ (Kẹp theo thẻ Token)     │                          │
       │─────────────────────────►│                          │
       │                          │ 6. Soi thẻ xem thật/giả  │
       │                          │    -> Lấy được Data      │
       │                          │                          │
       │ 7. Trả về thông tin      │                          │
       │◄─────────────────────────│                          │

Mã Hóa Mật Khẩu (PBKDF2) — Tuyệt Chiêu "Câu Giờ" ⏳

Tại sao không lưu mật khẩu trần trụi (Plaintext)?

Giống như việc bạn cất tiền hớ hênh dưới nệm vậy. Nếu lỡ nhà bị trộm (database bị rò rỉ), hacker sẽ thấy ngay toàn bộ mật khẩu. Đáng sợ hơn, vì chúng ta hay xài chung một mật khẩu cho nhiều web, hacker sẽ lấy mật khẩu đó đi đăng nhập trộm Facebook, Gmail của bạn luôn!

Tại sao không dùng các công cụ băm nhanh như MD5 hay SHA-256?

Thử băm bằng MD5: "password123" biến thành "482c811da5d5b4bc6d497ffa98491e38"

Nhìn thì nguy hiểm, nhưng MD5 được thiết kế để chạy siêu nhanh. Với sức mạnh của các dàn máy tính hiện đại (GPU), hacker có thể thử 10 tỷ mật khẩu mỗi giây. Một mật khẩu 8 chữ cái sẽ bị bẻ khóa chỉ trong nháy mắt.

PBKDF2 — Sinh Ra Để Lề Mề Có Chủ Đích

Vì máy tính của hacker tính quá nhanh, ta phải dùng một thuật toán cố tình làm chậm lại, đó là PBKDF2:

// api/src/utils.ts
export async function hashPassword(password: string): Promise<string> {
  // 1. Rắc thêm "Muối" (Salt) - Tạo một chuỗi 16 byte ngẫu nhiên
  const salt = crypto.getRandomValues(new Uint8Array(16));

  // 2. Chuẩn bị đưa mật khẩu vào lò
  const key = await crypto.subtle.importKey(
    'raw',
    new TextEncoder().encode(password),
    { name: 'PBKDF2' },
    false,
    ['deriveBits']
  );

  // 3. Xay, nhào nặn đúng 100,000 lần (cố tình bắt máy tính tính toán mệt mỏi)
  const bits = await crypto.subtle.deriveBits(
    { name: 'PBKDF2', salt, iterations: 100_000, hash: 'SHA-256' },
    key,
    256  // đầu ra: 32 bytes
  );

  // 4. Lưu vào Database theo chuẩn: "pbkdf2:chuỗi_muối:chuỗi_mã_hóa"
  const saltHex = toHex(salt);
  const hashHex = toHex(new Uint8Array(bits));
  return `pbkdf2:${saltHex}:${hashHex}`;
}

Ủa, "Muối" (Salt) để làm gì? Hãy tưởng tượng 2 người dùng chung một mật khẩu là "123456". Nếu không có muối, mã băm ra sẽ y chang nhau. Hacker chỉ cần tra bảng là biết ngay mật khẩu của cả hai. Nhưng nếu rắc "Muối" (Mỗi người được cấp một loại muối ngẫu nhiên khác nhau), thì:

Xay ("123456" + Muối của An) = "aabb..."
Xay ("123456" + Muối của Bình) = "ccdd..."  ← Khác nhau hoàn toàn!

Mỗi người một muối -> Hacker phải đi mò từng tài khoản một. Kết hợp với việc nhào nặn 100.000 lần (tốn ~0.1 giây cho mỗi lần thử), để hacker dò 1 tỷ mật khẩu, chúng sẽ mất tận... 3 năm!

Cách Kiểm Tra Mật Khẩu (Lúc Đăng Nhập)

export async function verifyPassword(password: string, stored: string): Promise<boolean> {
  try {
    // Tách lấy phần muối và phần mã hóa từ Database
    const [, saltHex, hashHex] = stored.split(':');

    // Phục hồi lại hạt muối
    const salt = new Uint8Array(saltHex.match(/.{2}/g)!.map(h => parseInt(h, 16)));

    const key = await crypto.subtle.importKey(
      'raw', new TextEncoder().encode(password), { name: 'PBKDF2' }, false, ['deriveBits']
    );

    // Lấy mật khẩu khách vừa nhập, cộng với "Muối", rồi đem xay 100,000 lần y như lúc tạo
    const bits = await crypto.subtle.deriveBits(
      { name: 'PBKDF2', salt, iterations: 100_000, hash: 'SHA-256' },
      key, 256
    );

    const computed = toHex(new Uint8Array(bits));

    // So sánh xem có khớp với mã lưu trong Database không
    return computed === hashHex;
  } catch {
    return false;
  }
}

Thẻ Thông Hành (JWT — JSON Web Token) 💳

Hình Dáng Của Chiếc Thẻ Này Ra Sao?

JWT là một chuỗi chữ cái dài ngoằng, được mã hóa an toàn và nối với nhau bằng dấu chấm ., chia làm 3 phần:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJhYmMiLCJyb2xlIjoidXNlciIsImV4cCI6MTcwMDAwMDAwMH0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
│────────────────────────────────────│ │────────────────────────────────────────────────│ │──────────────────────────────────────────────────│
        1. PHẦN ĐẦU (HEADER)                      2. PHẦN THÂN (PAYLOAD)                       3. CHỮ KÝ BẢO MẬT (SIGNATURE)
  • Phần đầu: Khai báo thuật toán dùng để ký (Ví dụ: HS256).
  • Phần thân: Chứa thông tin như: "Anh này ID là gì?", "Quyền hạn ra sao?", "Khi nào thẻ hết hạn?".
  • Chữ ký: Đoạn này cực quan trọng! Server dùng một "chìa khóa bí mật" đóng mộc lên thẻ. Nếu ai đó cố tình sửa Phần thân để tự thăng cấp cho mình, Chữ ký sẽ bị lệch và server biết ngay là thẻ giả.

Tại Sao Không Dùng "Sổ Cái" (Session) Cho Đơn Giản?

Cách Sổ Cái (Session): Khách sạn (Server) giữ một cuốn sổ. Khi bạn đăng nhập, lễ tân ghi tên bạn vào sổ rồi phát cho bạn cái chìa khóa phòng (session_id). Mỗi lần bạn gọi đồ ăn, lễ tân phải lật sổ ra dò xem chìa khóa đó là của ai. -> Vấn đề: Đám mây Cloudflare Workers chạy trên hàng trăm data center khác nhau, không có chung một "cuốn sổ". Copy sổ cái cho mọi nơi đọc là bất khả thi.

Cách Thẻ Thông Hành (JWT): Lễ tân cấp cho bạn một tấm thẻ VIP đã được đóng mộc đỏ (Chữ ký). Mỗi lần bạn gọi đồ ăn, chỉ cần chìa thẻ ra. Lễ tân chỉ cần sờ xem cái mộc đỏ có phải hàng do chính tay mình đóng không là phục vụ luôn, khỏi cần lật sổ sách gì hết! -> Lợi ích: Máy chủ không cần nhớ gì cả (Stateless), cực kỳ hoàn hảo cho Cloudflare Workers.

Cách Làm Trong Code

Lưu Ý Chết Người Về Thời Gian ⏰

Theo chuẩn quốc tế của JWT (RFC 7519), thời gian hết hạn (exp) phải được tính bằng GIÂY, KHÔNG phải mili-giây. Tính sai là thẻ của bạn sẽ bị hệ thống báo hết hạn từ đời tám hoánh nào rồi!

// src/utils.ts

// CẤP THẺ (Sign JWT)
export async function signJwt(
  payload: Omit<JwtPayload, 'exp'>,
  secret: string,
): Promise<string> {
  // Tính thời gian hết hạn bằng GIÂY (30 ngày)
  const exp = Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 30;
  const fullPayload: JwtPayload = { ...payload, exp };

  // Dịch thông tin ra mã an toàn để truyền qua mạng
  const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).replace(/=/g, '');
  const body = btoa(JSON.stringify(fullPayload)).replace(/=/g, '');
  const data = `${header}.${body}`;

  // Ký tên đóng mộc bằng Chìa khóa bí mật (Secret)
  const key = await crypto.subtle.importKey(
    'raw', new TextEncoder().encode(secret),
    { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'],
  );
  const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(data));

  const sig = btoa(String.fromCharCode(...new Uint8Array(signature)))
    .replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');

  return `${data}.${sig}`;
}

// SOI THẺ (Verify JWT)
export async function verifyJwt(token: string, secret: string): Promise<JwtPayload | null> {
  try {
    const parts = token.split('.');
    if (parts.length !== 3) return null; // Thẻ không đủ 3 phần -> Đuổi
    const [header, body, sig] = parts;
    const data = `${header}.${body}`;

    // Lấy chìa khóa bí mật ra soi chữ ký
    const key = await crypto.subtle.importKey(
      'raw', new TextEncoder().encode(secret),
      { name: 'HMAC', hash: 'SHA-256' }, false, ['verify'],
    );
    const sigBytes = Uint8Array.from(
      atob(sig.replace(/-/g, '+').replace(/_/g, '/')),
      c => c.charCodeAt(0),
    );
    const valid = await crypto.subtle.verify(
      'HMAC', key, sigBytes, new TextEncoder().encode(data),
    );
    if (!valid) return null; // Chữ ký sai -> Thẻ giả!

    // Soi tiếp xem thẻ còn hạn không (Nhớ so sánh bằng giây)
    const payload: JwtPayload = JSON.parse(atob(body));
    if (payload.exp < Math.floor(Date.now() / 1000)) return null;

    return payload; // Thẻ xịn!
  } catch {
    return null;
  }
}

Nội Dung Thẻ Cần Lưu Gì? (JWT Payload)

export interface JwtPayload {
  userId: string;
  role: 'super_admin' | 'user';
  exp: number;  // Hạn sử dụng - tính bằng GIÂY
}

Trạm Kiểm Soát Thẻ (JWT Middleware)

Bất cứ hành động nào cần bảo mật, ta đều bắt người dùng đi qua trạm này:

const jwtMiddleware = async (c: any, next: () => Promise<void>) => {
  const authHeader = c.req.header('Authorization');

  // Thẻ phải kẹp theo cú pháp "Bearer <mã_token>"
  if (!authHeader?.startsWith('Bearer ')) {
    return c.json({ error: 'Chưa đăng nhập nha!' }, 401);
  }

  // Soi thẻ
  const payload = await verifyJwt(authHeader.slice(7), c.env.JWT_SECRET);
  if (!payload) {
    return c.json({ error: 'Thẻ giả hoặc hết hạn rồi' }, 401);
  }

  // Thẻ xịn -> Ghi chú lại thông tin khách và mời đi tiếp
  c.set('jwtPayload', payload);
  await next();
};

Lắp Ráp Lại: Luồng Đăng Nhập Hoàn Chỉnh ⚙️

// routes/auth.ts
auth.post('/login', async (c) => {
  const { email, password, turnstileToken } = await c.req.json();

  // 1. Kiểm tra xem có phải robot spam không (Turnstile)
  const turnstileOk = await verifyTurnstile(turnstileToken, c.env.TURNSTILE_SECRET_KEY);
  if (!turnstileOk) return c.json({ error: 'Xác minh thất bại' }, 400);

  // 2. Tìm tài khoản trong DB theo email
  const user = await c.env.DB.prepare(
    'SELECT * FROM users WHERE email = ? AND deleted_at IS NULL'
  ).bind(email.toLowerCase()).first<User>();

  if (!user) return c.json({ error: 'Email hoặc mật khẩu không đúng' }, 401);

  // 3. Đem mật khẩu đi băm và kiểm tra (PBKDF2)
  const isValid = await verifyPassword(password, user.password_hash);
  if (!isValid) return c.json({ error: 'Email hoặc mật khẩu không đúng' }, 401);

  // 4. Lấy danh sách nhóm quỹ mà người đó tham gia
  const { results: groups } = await c.env.DB.prepare(
    `SELECT g.id, g.slug, g.name, g.currency, gm.role as member_role
     FROM groups g JOIN group_members gm ON g.id = gm.group_id
     WHERE gm.user_id = ?`
  ).bind(user.id).all();

  // 5. Cấp thẻ VIP (JWT)
  const accessToken = await signJwt(
    { userId: user.id, role: user.role },
    c.env.JWT_SECRET
  );

  // 6. Giao thẻ và thông tin cho khách đem về
  return c.json({
    accessToken,
    userId: user.id,
    name: user.name,
    email: user.email,
    role: user.role,
    groups,
  });
});

Điểm Yếu Của Thẻ JWT Và Cách "Chữa Cháy" 🚒

Vấn đề nan giải: JWT đã phát ra là không thể thu hồi trước thời hạn! Giả sử bạn đổi mật khẩu, tên hacker đang cầm cái thẻ cũ của bạn vẫn có thể ung dung xài cho đến khi cái thẻ đó hết hạn.

Tại sao dự án này chấp nhận rủi ro đó?

  • Thẻ sẽ tự hủy sau 30 ngày (Cân bằng giữa việc người dùng không phải đăng nhập liên tục và độ an toàn).
  • Ứng dụng quản lý quỹ này chỉ ghi chép sổ sách, không cất giữ thẻ tín dụng hay tiền thật của bạn.
  • Hệ thống làm đơn giản, không cần tốn tiền chạy thêm Server/Redis chỉ để theo dõi xem thẻ nào bị cấm.

Ở các hệ thống ngân hàng người ta làm thế nào?

  • Họ tạo một "Sổ đen" (Blacklist) lưu các thẻ bị cấm, mỗi lần khách vào đều phải soi lại sổ.
  • Hoặc họ xài chiêu 2 Thẻ: Một thẻ vào cửa xài được có 15 phút (Access token), và một thẻ chuyên dùng để đi xin thẻ mới (Refresh token) cất kỹ trong Cookie. Rất an toàn nhưng code cực kỳ phức tạp.

Tiêu chí Ngăn bàn (localStorage) Két sắt (httpOnly Cookie)
Code JavaScript tự mở ra xem được? ✅ Thoải mái (Tiện nhưng dễ bị trộm) ❌ Bó tay (Bảo mật hơn)
Trình duyệt tự động kẹp thẻ mang đi? ❌ Không, phải tự viết code gắn vào ✅ Trình duyệt tự lo hết
Sợ bị mã độc ăn cắp thẻ (Lỗi XSS)? Cao (Vì JS đọc được thẻ) Thấp
Sợ bị lừa bấm link bậy bạ (Lỗi CSRF)? Không sợ Rất sợ, phải làm thêm bước chống đỡ
Dùng chéo tên miền được không? Chỉ chung một nhà mới xài được Có thể xài chung với anh em họ (.example.com)

Vì sao dự án này lại chọn cất thẻ ở ngăn bàn localStorage?

  • Mặt tiền (Frontend: *.pages.dev) và Não bộ (Backend: *.workers.dev) nằm ở 2 tên miền hoàn toàn khác nhau. Việc thiết lập Cookie cho 2 nhà khác nhau cực kỳ đau đầu và dễ lỗi.
  • Web của chúng ta không cho người dùng đăng bài hay viết mã HTML tự do, nên rủi ro bị bơm mã độc (XSS) để ăn cắp thẻ là cực kỳ thấp.
  • Code đơn giản, dễ test và dễ chạy!