+2

Phân quyền tầng View: Giữa "Bảo mật" và "Trải nghiệm người dùng" (UX), bạn đang làm đúng hay sai?

Chào anh em! Vẫn là series những câu chuyện "bóc phốt" bản thân sau hơn 5 năm đi code dạo.

Nếu anh em làm Frontend, chắc chắn công việc hàng ngày không thể thoát khỏi các task kiểu như: "Ẩn cái menu này đi nếu user không phải Admin nhé", "Chặn user thường không cho vào trang Dashboard nha em". Kỹ thuật đó gọi chung là View-level Authorization (Phân quyền ở tầng hiển thị).

Nghe thì có vẻ dễ òm, chỉ vài dòng if-else là xong. Nhưng chính cái suy nghĩ "dễ òm" đó đã từng khiến mình gây ra một thảm họa bảo mật tồi tệ nhất trong những năm tháng junior. Hôm nay rảnh rỗi, mình xin lôi vết thương cũ ra để anh em cùng rút kinh nghiệm.

1. Mở bài: Sự cố "Mắt không thấy, nhưng Data vẫn bay"

Năm đó mình code một trang CMS cho báo điện tử. Trong trang chi tiết bài viết, có một nút đỏ chót: "Xóa bài". Sếp yêu cầu chỉ có role Editor (Biên tập viên) mới được thấy và bấm nút này, user thường (Viewer) thì không.

Mình tự tin đập bàn, mở React lên và phang ngay một dòng code chân lý:

{ currentUser.role === 'Editor' && <button>Xóa bài</button> }

Xong! User thường login vào, giao diện sạch bóng, không thấy nút Xóa đâu. Mình ung dung gập máy đi uống bia, tự nhủ hệ thống an toàn tuyệt đối rồi.

Hai tuần sau, một cậu thực tập sinh (role Viewer) bên phòng nội dung không biết buồn bực gì sếp, đã xóa sạch sành sanh 50 bài báo đang chờ duyệt. Mình hốt hoảng check log, check UI. Rõ ràng trên màn hình của cậu ta làm quái gì có nút Xóa mà bấm?

Hóa ra, cậu nhóc này có học lõm chút IT. Cậu ta mở Chrome DevTools (F12), vào tab Network, lấy cái JWT Token của mình, sau đó mở Postman lên và bắn thẳng một request DELETE /api/posts/123.

Backend lúc đó (cũng do một bạn junior khác viết) đinh ninh rằng: "Chắc Frontend nó đã chặn nút Xóa rồi thì tao không cần kiểm tra quyền nữa". BÙM! Dữ liệu bốc hơi.

2. Hành trình giác ngộ: Bản chất thực sự của View-level Authz

Cú tát đó làm mình tỉnh mộng và nhận ra một sự thật phũ phàng: View-level Authorization KHÔNG PHẢI LÀ BẢO MẬT (Security). Nó chỉ là TRẢI NGHIỆM NGƯỜI DÙNG (UX)!

Mọi thứ bạn chạy trên trình duyệt của người dùng (HTML, JS, React, Vue) đều nằm trong tay họ. Họ có thể sửa code JS, bỏ ẩn CSS, hoặc gọi thẳng API bỏ qua UI.

Vậy kỹ thuật Authorization ở tầng View thực chất là làm gì? Nó làm 2 nhiệm vụ chính để mang lại một UX tốt, giúp người dùng không bị bối rối:

  1. Route Guards (Bảo vệ vòng ngoài): Chặn không cho user truy cập vào các trang (URL) mà họ không có quyền. Thay vì để họ load xong trang rồi API báo lỗi 403, ta đá họ ra ngay từ cửa.
  2. Conditional Rendering (Bảo vệ vòng trong): Ẩn/hiện hoặc disable các thành phần UI (nút bấm, menu, form) dựa trên quyền hạn.

Cách mình sửa sai và cấu trúc lại hệ thống:

Đầu tiên, phải bắt Backend làm việc của nó: Luôn luôn kiểm tra quyền trên API (API-level Authz). Không tin bố con thằng nào gửi request lên hết.

Tiếp theo, ở tầng View (Frontend), mình không hard-code role nữa mà chuyển sang đọc cấu hình từ Backend trả về, kết hợp với các kỹ thuật Render hợp lý:

Ví dụ về Route Guard trong React Router:

// PrivateRoute.jsx - Chặn ở cấp độ URL
const PrivateRoute = ({ children, requiredPermission }) => {
  const { permissions } = useAuth(); // Lấy quyền từ state/token

  if (!permissions.includes(requiredPermission)) {
    // Không có quyền thì đá về trang chủ hoặc trang 403
    return <Navigate to="/403" replace />; 
  }

  return children;
};

Ví dụ về xử lý UI tinh tế hơn (Không chỉ là ẩn đi): Thay vì chỉ giấu biệt cái nút đi (khiến user tưởng hệ thống bị lỗi thiếu nút), đôi khi mình chọn cách hiển thị nhưng Disable kèm theo giải thích.

const DeleteButton = ({ postId }) => {
  const { can } = useAuth();
  const hasAccess = can('delete_post');

  return (
    <Tooltip title={!hasAccess ? "Bạn cần quyền Biên tập viên để xóa" : ""}>
      <button 
        disabled={!hasAccess}
        className={hasAccess ? 'bg-red-500' : 'bg-gray-300 cursor-not-allowed'}
        onClick={() => api.delete(`/posts/${postId}`)}
      >
        Xóa bài
      </button>
    </Tooltip>
  );
};

3. Bài học xương máu (The Takeaways)

  1. Never trust the client (Không bao giờ tin tưởng Frontend): Dù code View-level Authz của bạn có xịn xò đến mấy, nó giống như việc bạn dán một tờ giấy "Cấm vào" ở ngoài cửa nhưng không khóa ổ khóa. API Backend mới là cái ổ khóa.
  2. Đồng bộ hóa Quyền (Syncing Rules): Nỗi đau lớn nhất là Frontend và Backend lệch logic phân quyền (FE cho phép nhưng BE báo lỗi, hoặc ngược lại). Hãy cố gắng để Backend trả về danh sách các quyền (VD: ['create_user', 'delete_post']) ngay lúc đăng nhập, và Frontend chỉ việc check theo mảng string đó.
  3. UX Optimization: Phân quyền ở tầng View là nghệ thuật điều hướng. Hãy dùng nó để hướng dẫn user (ẩn bớt tính năng thừa để đỡ rối mắt, disable nút kèm tooltip để giải thích), thay vì coi nó như một bức tường lửa.

Lời kết

Sau ngần ấy năm, mỗi lần review code cho các bạn fresher, thấy bạn nào tự hào khoe "Em chặn kỹ lắm rồi, không có nút đâu mà bấm" là mình lại kể cho nghe câu chuyện "bốc hơi 50 bài báo" năm xưa.

View-level Authorization là bắt buộc phải làm, nhưng hãy làm nó với tâm thế của một người làm Trải nghiệm người dùng (UX), chứ đừng ảo tưởng nó là Bảo mật (Security).

Còn anh em thì sao? Đã bao giờ anh em rơi vào tình cảnh khóc dở mếu dở vì Frontend chặn nhưng quên báo Backend chưa? Hoặc anh em đang có best practice nào để đồng bộ file định nghĩa quyền giữa FE và BE không? Comment bên dưới để chúng ta cùng mổ xẻ nhé!


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í