Mục lục

Phân quyền: Đừng để User thường vào trang Admin

Phân biệt Authentication và Authorization. Hướng dẫn xây dựng hệ thống RBAC (Role-Based Access Control) bảo vệ Admin Dashboard.

Bạn có hệ thống Login (Authentication) rồi. Nhưng nếu User bình thường gõ /admin/dashboard trên thanh địa chỉ và truy cập được trang quản trị thì... toang.

Đó là lúc ta cần Authorization (Phân quyền).

1. Auth vs Auth

  • Authentication (Xác thực): Bạn là ai? (User login).
  • Authorization (Phân quyền): Bạn được phép làm gì? (User xem post, Admin xóa post).

2. Mô hình RBAC (Role-Based)

Thay vì gán quyền cho từng user (A được xóa, B được sửa), ta gán quyền cho Vai trò (Role).

  • User: Xem, Comment.
  • Editor: Viết bài, Sửa bài mình.
  • Admin: Xóa bài, Ban user.

Database Schema thường gặp:

json:
// User Table
{
  "id": "u1",
  "email": "boss@company.com",
  "role": "ADMIN" // Trường quan trọng nhất
}

3. Bảo vệ 3 lớp (The 3-Layer Defense)

Lớp 1: Middleware (Chặn cửa)

Chặn ngay từ khi request chưa chạm vào layout. Áp dụng cho các route /admin/*.

ts:
// middleware.ts
import { NextResponse } from 'next/server';
import { auth } from '@/auth';

export default auth((req) => {
  const role = req.auth?.user?.role; // Lấy role từ session
  const isAdminPath = req.nextUrl.pathname.startsWith('/admin');

  if (isAdminPath && role !== 'ADMIN') {
    // User thường cố vào admin -> Đá về trang chủ
    return NextResponse.redirect(new URL('/', req.nextUrl));
  }
});

Lớp 2: Server Components (Check dữ liệu)

Đôi khi user vào được trang, nhưng không được xem dữ liệu nhạy cảm.

tsx:
// app/admin/users/page.tsx
export default async function UsersPage() {
  const session = await auth();
  
  // Double check (đề phòng middleware bị bypass)
  if (session?.user?.role !== 'ADMIN') {
    return <div>Access Denied</div>;
  }

  const users = await db.user.findMany(); // Chỉ Admin mới được query
  return <UserList users={users} />;
}

Lớp 3: UI Hiding (Trải nghiệm UX)

Đừng hiển thị nút "Delete" cho User thường (bấm vào lỗi 403 cũng rất khó chịu). Ẩn nó đi.

tsx:
// components/DeleteButton.tsx
'use client';
import { useSession } from 'next-auth/react';

export function DeleteButton() {
  const { data: session } = useSession();

  // Chỉ render nếu là Admin
  if (session?.user?.role !== 'ADMIN') return null;

  return <button>Delete User</button>;
}

4. Kiến trúc Client-side Authorization (Senior Level)

Thay vì viết if (role === 'ADMIN') rải rác khắp nơi (rất khó bảo trì khi đổi tên role), hãy xây dựng một hệ thống Permission-based System.

Bước 1: Định nghĩa Permission Map

Tạo file permissions.ts để map Role sang danh sách quyền cụ thể.

ts:
// lib/permissions.ts
export const ROLES = {
  ADMIN: "admin",
  EDITOR: "editor",
  USER: "user",
} as const;

export const PERMISSIONS = {
  // Post permissions
  "post:read": [ROLES.ADMIN, ROLES.EDITOR, ROLES.USER],
  "post:create": [ROLES.ADMIN, ROLES.EDITOR],
  "post:delete": [ROLES.ADMIN], // Chỉ Admin được xóa
  
  // User permissions
  "user:view_list": [ROLES.ADMIN],
  "user:ban": [ROLES.ADMIN],
} as const;

export type Permission = keyof typeof PERMISSIONS;

Bước 2: Tạo Hook Check Quyền (usePermission)

Logic check quyền tập trung tại một chỗ.

ts:
// hooks/use-permission.ts
import { useSession } from "next-auth/react";
import { PERMISSIONS, Permission } from "@/lib/permissions";

export function usePermission() {
  const { data: session } = useSession();
  const userRole = session?.user?.role;

  const can = (permission: Permission) => {
    if (!userRole) return false;
    
    // Lấy danh sách role được phép làm hành động này
    const allowedRoles = PERMISSIONS[permission];
    return allowedRoles.includes(userRole);
  };

  return { can, role: userRole };
}

Bước 3: Tạo Component <Guard> (Clean Code)

Giúp code JSX của bạn sạch sẽ, dễ đọc.

tsx:
// components/auth/Guard.tsx
import { usePermission } from "@/hooks/use-permission";
import { Permission } from "@/lib/permissions";

interface GuardProps {
  permission: Permission;
  children: React.ReactNode;
  fallback?: React.ReactNode; // Hiển thị gì nếu không có quyền? (vd: Icon khóa)
}

export function Guard({ permission, children, fallback = null }: GuardProps) {
  const { can } = usePermission();

  if (!can(permission)) {
    return <>{fallback}</>;
  }

  return <>{children}</>;
}

Usage Example (Thực tế)

Bây giờ code UI của bạn sẽ trông rất chuyên nghiệp và dễ hiểu:

tsx:
export function PostCard({ post }) {
  return (
    <div className="card">
      <h1>{post.title}</h1>
      
      {/* Chỉ hiện nút Edit cho Editor/Admin */}
      <Guard permission="post:create">
        <button>Edit Post</button>
      </Guard>

      {/* Chỉ hiện nút Delete cho Admin */}
      <Guard 
        permission="post:delete" 
        fallback={<span className="text-gray-400">Bạn không thể xóa</span>}
      >
        <button className="bg-red-500">Delete Post</button>
      </Guard>
    </div>
  );
}

5. Phân biệt: Page Protection vs Action Protection

Rất nhiều bạn hỏi: "Nếu tôi chặn URL /admin bằng Middleware rồi thì có cần check quyền trong hàm xóa user nữa không?". Câu trả lời là: CÓ, CHẮC CHẮN 100% CÓ.

Tại sao? Hãy so sánh:

Tính năngPage Protection (Middleware)Action Protection (Server Actions)
Mục đíchUX & Navigation: Ngăn user thường đi lạc vào trang Admin.Security (Bảo mật): Ngăn Hacker gọi API trái phép.
Cơ chếChặn URL (/admin/dashboard).Chặn Logic (deleteUser()).
Lỗ hổngMiddleware KHÔNG chặn được request gửi trực tiếp đến Server Action (POST API).Đây là lá chắn cuối cùng.
Code mẫuif (!role) redirect('/login')if (!role) throw new Error('Cấm!')

Ví dụ thực tế:

  1. Bạn có trang Admin (được bảo vệ bởi Middleware).
  2. Hacker không vào được trang Admin.
  3. NHƯNG Hacker biết API xóa user tên là deleteUser.
  4. Hacker dùng Postman gửi request POST /deleteUser { id: 1 } (kèm cookie session của hắn).
  5. Nếu trong hàm deleteUser bạn KHÔNG check quyền -> User bị xóa (Dù Hacker không hề vào được trang Admin).

--> Page Protection chỉ là cái Cổng Rào. Action Protection mới là Két Sắt.

6. Những bài học xương máu (Real World Scenarios)

💀 Bài học 1: IDOR (Lỗ hổng nguy hiểm nhất)

Kịch bản: Bạn có API xóa comment: DELETE /api/comments/123. Code của Junior:

ts:
// ❌ SAI LẦM CHẾT NGƯỜI
export async function deleteComment(id) {
  const session = await auth();
  if (!session) throw new Error("Chưa login");
  
  // Cứ thế xóa luôn???
  await db.comment.delete({ where: { id } });
}

Hậu quả: Hacker (đã login) có thể gửi request xóa comment ID 456 của người khác!

✅ Fix (Check Ownership):

ts:
export async function deleteComment(id) {
  const session = await auth();
  // KHÔNG ĐƯỢC QUÊN BƯỚC NÀY
  const comment = await db.comment.findUnique({ where: { id } });
  
  // Nếu không phải Admin VÀ không phải chủ nhân comment
  if (session.user.role !== 'ADMIN' && comment.authorId !== session.user.id) {
    throw new Error("Không có quyền xóa comment của người khác");
  }
  
  await db.comment.delete({ where: { id } });
}

💀 Bài học 2: Client-side Security là ảo ảnh

Nhiều bạn nghĩ disable nút bấm là an toàn. Hacker chỉ cần F12 hoặc dùng Console để bypass. Chân lý: Client-side logic chỉ để làm đẹp UX. Server-side logic mới là nơi giữ cổng.

7. UI/UX cho Access Control: Đừng để User hoang mang

Khi User bị chặn, đừng ném vào mặt họ một trang trắng xóa hay lỗi "500 Internal Server Error".

A. Ẩn vs Disabled (Mờ đi)

  • Ẩn luôn: Khi tính năng không liên quan gì đến user (Vd: Nút "Delete User" đối với khách vãng lai). Hiện ra chỉ làm rối giao diện (Visual Noise).
  • Disabled (Kèm Tooltip): Khi tính năng có thể được dùng nếu user nâng cấp tài khoản.
    • Ví dụ: Nút "Export Excel" trong Dashboard. User Free thấy nút bị mờ, hover vào hiện Tooltip: "Nâng cấp Pro để dùng tính năng này". -> Đây là cơ hội Upsell tuyệt vời.

B. Trang 403 (Forbidden)

Khi User cố tình gõ link /admin vào trình duyệt:

  • Tốt: Redirect về Home.
  • Tốt hơn: Hiện trang 403 đẹp mắt với thông báo: "Khu vực hạn chế. Bạn có muốn đổi tài khoản không?" (Kèm nút Login khác).
    • Lý do: Đôi khi Sếp dùng máy của nhân viên để login Admin, redirect về Home làm sếp tưởng web lỗi.

C. Xử lý lỗi Action (Toast Message)

Khi gọi Server Action và bị chặn (do hết session hoặc mất quyền):

  • Đừng crash App (Màn hình đỏ lòm).
  • Hãy try/catch lỗi và hiện Toast Notification: "Bạn không có quyền thực hiện hành động này" hoặc "Phiên đăng nhập hết hạn".
tsx:
// Client Component
const handleDelete = async () => {
  try {
    await deleteUser(id);
    toast.success("Đã xóa!");
  } catch (error) {
    // Nếu lỗi là "Unauthorized"
    if (error.message.includes("Unauthorized")) {
      toast.error("Bạn không đủ quyền để xóa user này!");
    } else {
      toast.error("Lỗi hệ thống");
    }
  }
}

Kết luận

Bảo mật phải có chiều sâu (Defense in Depth).

  1. UX: Dùng <Guard> component để ẩn/hiện nút bấm tinh tế.
  2. Routing: Chặn Middleware để bảo vệ URL.
  3. Data: Server Action kiểm tra quyền sở hữu (Ownership/IDOR).
  4. Feedback: Thông báo lỗi rõ ràng để User hiểu tại sao mình bị chặn.
Quảng cáo
mdhorizontal