🛡️ Phân quyền theo vai trò (RBAC) trong API

By Better Wise

Bài trước bạn đã có access token ngắn hạn và refresh token xoay vòng. Nhưng token hợp lệ mới chỉ trả lời câu hỏi "bạn là ai". Câu hỏi thứ hai, "bạn được làm gì", là toàn bộ nội dung bài này. Trong thực tế, phần lớn lỗ hổng auth tôi từng sửa không nằm ở chỗ token yếu, mà ở chỗ API tin rằng ai đăng nhập cũng được làm mọi thứ.

Sau bài này, bạn sẽ:

  • Phân biệt rõ authentication và authorization.
  • Gắn role vào user ở database và biết đâu là nguồn sự thật.
  • Viết middleware kiểm tra quyền chạy trước handler.
  • Nhận ra lỗi "chỉ ẩn nút ở frontend" và chặn đúng chỗ.
  • Xử lý trường hợp role bị đổi khi token còn hạn.

401 và 403: hai lỗi khác nhau, đừng gộp

Một buổi chiều tôi nhận ticket "user thường xóa được đơn hàng của người khác". Mở code ra: frontend ẩn nút Xóa với user thường, nhưng route DELETE /orders/:id chỉ có requireAuth. Ai mở DevTools, copy token, gọi bằng curl là xong. Nút bị ẩn, quyền thì không.

Hai mã lỗi cần dùng đúng:

  • 401 Unauthorized: chưa xác thực, token thiếu hoặc sai.
  • 403 Forbidden: đã xác thực rồi, nhưng vai trò không đủ quyền.

Warning: Client không phải nơi tin cậy. Mọi thứ chạy trên máy người dùng đều có thể bị bỏ qua hoặc sửa. Ẩn nút chỉ là chuyện giao diện.

Gắn role vào user: một nguồn sự thật duy nhất

Bắt đầu đơn giản nhất có thể, một cột role trên bảng users:

ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'member';
-- giá trị dùng trong toàn hệ thống: 'member' | 'staff' | 'admin'

Khi nào cần nhiều role cho một user hoặc cần cấu hình quyền động, hãy tách bảng roles và user_roles. Nhưng với sản phẩm mới, ba vai trò cố định là đủ, và một cột role dễ debug hơn nhiều.

Điểm quan trọng: role phải lấy từ token đã xác thực hoặc từ database. Không bao giờ lấy role từ request body hay query string, vì đó là dữ liệu người dùng tự gửi lên.

Middleware: chặn ngay ở tầng API

Luồng chuẩn của một request có hai chặng: xác thực trước, phân quyền sau.

flowchart TD
  R["📨<br/>Request"] --> A["🔑<br/>requireAuth"]
  A -->|token sai| E401["401"]
  A -->|token hợp lệ| Z["🧭<br/>requireRole"]
  Z -->|sai vai trò| E403["403"]
  Z -->|đúng vai trò| H["⚙️<br/>Handler"]

Với Express, hai middleware nhỏ là đủ:

// Chặng 1: token này có thật không
function requireAuth(req, res, next) {
  const token = req.headers.authorization?.replace('Bearer ', '');
  try {
    req.user = verifyAccessToken(token); // { sub, role }
    next();
  } catch {
    res.status(401).json({ error: 'unauthenticated' });
  }
}

// Chặng 2: user này có được làm không
function requireRole(...allowed) {
  return (req, res, next) => {
    if (!req.user) return res.status(401).json({ error: 'unauthenticated' });
    if (!allowed.includes(req.user.role)) {
      return res.status(403).json({ error: 'forbidden' });
    }
    next();
  };
}

app.delete('/orders/:id', requireAuth, requireRole('admin'), deleteOrder);

Thứ tự trong route không được đảo: requireRole cần req.user, mà req.user do requireAuth tạo ra.

Note: Nguyên tắc deny by default: route không khai báo quyền thì mặc định chặn, chỉ mở khi có khai báo rõ ràng. Đừng viết middleware kiểu "nếu không phải admin thì thôi cho qua".

Bài tập 1. Viết route PATCH /users/:id/role chỉ admin được gọi, dùng hai middleware trên.

Gợi ý đáp án

app.patch('/users/:id/role', requireAuth, requireRole('admin'), updateUserRole);

Nếu muốn chắc hơn, thêm một lớp nữa: admin không được tự đổi role của chính mình, tránh trường hợp mất hết admin trong hệ thống.

Role trong token: tiện, nhưng dễ cũ

Nhét role vào access token là cách phổ biến vì mỗi request không cần query database. Cái giá: token sống bao lâu thì role cũ sống bấy lâu. Bạn hạ quyền một admin xuống member, nhưng token phát hành 10 phút trước vẫn mang role: "admin" và vẫn qua cửa.

Ba cách xử lý, chọn theo mức nhạy cảm:

  1. Giữ access token thật ngắn (5 đến 15 phút), chấp nhận độ trễ nhỏ.
  2. Với hành động nhạy cảm (xóa, đổi quyền, xem dữ liệu tài chính), đọc role từ database ngay trong request đó.
  3. Thêm claim role_version vào user, tăng số này mỗi lần đổi quyền, và từ chối token có version cũ.

Bài tập 2. Một admin bị hạ quyền nhưng token của họ còn hạn 12 phút. Nêu hai hướng xử lý và nói hướng nào phù hợp với API xóa dữ liệu.

Gợi ý đáp án

Hướng A: chấp nhận, vì token ngắn và rủi ro nhỏ. Phù hợp với API đọc dữ liệu ít nhạy cảm. Hướng B: kiểm tra role từ database hoặc dùng role_version cho các route xóa, đổi quyền, thanh toán. Với API xóa dữ liệu, chọn hướng B.

Vai trò đúng chưa đủ: còn phải kiểm tra quyền sở hữu

Đây là lỗi tôi gặp nhiều thứ hai. requireRole('member') cho phép mọi member xem GET /orders/:id, kể cả đơn của người khác. Vai trò không nói gì về việc tài nguyên đó có thuộc về người gọi hay không.

app.get('/orders/:id', requireAuth, async (req, res) => {
  const order = await db.order.findById(req.params.id);
  if (!order) return res.status(404).json({ error: 'not_found' });
  if (order.user_id !== req.user.sub) {
    return res.status(403).json({ error: 'forbidden' });
  }
  res.json(order);
});

Cùng ý tưởng đó, danh sách quyền theo vai trò nên gom vào một bảng nhỏ để cả team đọc chung:

Hành động member staff admin
Xem đơn của mình có có có
Xem mọi đơn hàng không có có
Xóa đơn hàng không không có
Đổi role của user không không có

Bài tập 3. Đoạn route sau sai ở đâu?

app.delete('/orders/:id', requireRole('admin'), requireAuth, deleteOrder);

Gợi ý đáp án

Hai lỗi. Thứ nhất, requireRole chạy trước requireAuth nên req.user chưa tồn tại, request admin hợp lệ cũng bị chặn. Thứ hai, nếu requireRole đọc role từ req.body.role thì đó là dữ liệu client tự khai, không có giá trị bảo mật. Đúng phải là requireAuth, requireRole('admin').

Những chỗ tôi luôn kiểm tra lại trước khi merge

  • Đọc lại thứ tự middleware trên từng route có ghi dữ liệu, không chỉ route mới viết.
  • Test bằng token của vai trò thấp hơn, không chỉ test bằng admin. Một curl với token member vào route admin phải trả 403.
  • Không so sánh chuỗi role rải rác khắp code. Gom thành hằng số hoặc một chỗ khai báo để đổi tên role không phải sửa 20 file.
  • Ghi log khi trả 403, kèm user id và route. Đây là dấu hiệu sớm của cả lỗi cấu hình lẫn dò quét.
  • Kiểm tra quyền sở hữu với mọi route có :id, kể cả route chỉ đọc.

Vài câu để tự kiểm tra nhanh: endpoint trả _____ khi đã xác thực nhưng thiếu quyền, và _____ khi chưa xác thực. Middleware phân quyền phải chạy _____ middleware xác thực. Ẩn nút ở frontend chỉ để _____, còn chặn thật phải nằm ở _____. Với route có :id, ngoài role còn phải kiểm tra _____.

Mang gì sang bài checkpoint

  • Authentication trả lời "bạn là ai", authorization trả lời "bạn được làm gì".
  • Role là nguồn sự thật ở database, lấy từ token đã xác thực, không lấy từ client.
  • Thứ tự luôn là requireAuth rồi requireRole, và mặc định là chặn.
  • Ẩn nút chỉ là giao diện. 403 ở API mới là phân quyền.
  • Role trong token có thể cũ, nên hành động nhạy cảm cần đọc lại từ database hoặc dùng role_version.

Bài tiếp theo là checkpoint của module: bạn sẽ ghép luồng refresh token với phân quyền vừa học, và trả lời các câu hỏi tình huống kiểu "token này ai giữ, sống bao lâu, bị lộ thì sao".

Front: Authentication trả lời câu hỏi gì? Back: Bạn là ai, danh tính của người gọi request.

Front: Authorization trả lời câu hỏi gì? Back: Bạn được làm gì, vai trò của bạn có đủ quyền cho hành động này không.

Front: Khi nào trả 401, khi nào trả 403? Back: 401 khi chưa xác thực, 403 khi đã xác thực nhưng thiếu quyền.

Front: RBAC là gì? Back: Gán quyền cho role, rồi gán role cho user, thay vì gán quyền lẻ cho từng user.

Front: Middleware kiểm tra quyền nên đứng ở đâu trong route? Back: Sau middleware xác thực, vì nó cần req.user do bước xác thực tạo ra.

Front: Vì sao ẩn nút ở frontend không phải là phân quyền? Back: Client không tin cậy được, người dùng vẫn gọi thẳng API bằng token của mình.

Front: Rủi ro khi nhét role vào JWT là gì? Back: Role cũ trong token vẫn có hiệu lực cho tới khi token hết hạn.

Front: Ba cách xử lý role bị cũ trong token? Back: Access token thật ngắn, đọc role từ database khi cần, hoặc dùng claim role_version.

Front: Deny by default nghĩa là gì? Back: Route không khai báo quyền thì mặc định chặn, chỉ mở khi cho phép rõ ràng.

Front: Vì sao kiểm tra role chưa đủ với route có :id? Back: Còn phải kiểm tra quyền sở hữu tài nguyên, nếu không sẽ dính lỗi IDOR.

Front: Role nên lấy từ đâu? Back: Từ token đã xác thực hoặc từ database, không lấy từ body hay query string.

Front: Cách test nhanh một route phân quyền? Back: Gọi route đó bằng token của vai trò thấp hơn và kiểm tra trả về 403.

Front: Vì sao nên gom tên role vào một chỗ khai báo? Back: Tránh so sánh chuỗi rải rác, đổi tên role không phải sửa nhiều file.