Xây dựng một hệ thống Role-Based Access System đơn giản sử dụng Node.js

EminelEminel
8 min read

Trong bất kỳ hệ thống nào – từ nhỏ đến lớn – đều cần giới hạn quyền truy cập. Bài viết này sẽ hướng dẫn bạn cách xây dựng một hệ thống RBAC (Role-Based Access Control) đơn giản trên server Node.js.

🔎 Kiến thức cần có trước khi bắt đầu

  • Hiểu cơ bản về JavaScriptNode.js

  • Biết cách tạo server với Express

  • Sử dụng cơ bản Mongoose để làm việc với MongoDB

  • Làm việc với Postman để test API

📌 Role-Based Access Control là gì?

RBAC là cơ chế phân quyền dựa trên vai trò. Từng người dùng được gán một vai trò (role), và hệ thống sẽ kiểm tra vai trò đó để quyết định người dùng có quyền làm gì.

Ví dụ:

Vai tròQuyền
ReaderChỉ đọc bài viết
WriterTạo, chỉnh sửa, xóa bài viết
AdminThêm hoặc xóa Writer

⚙️ Cách hoạt động của hệ thống RBAC

  1. Khi người dùng đăng ký, hệ thống gán vai trò cho họ.

  2. Thông tin người dùng và vai trò được lưu trong database.

  3. Khi người dùng truy cập route được bảo vệ, middleware sẽ lấy role ra từ DB.

  4. Nếu role hợp lệ với route đó, cho truy cập; ngược lại từ chối.

✅ Ưu điểm của hệ thống RBAC

  • Tăng bảo mật: Giới hạn quyền truy cập theo vai trò.

  • Hạn chế tấn công: Không ai có toàn quyền – nếu bị hack cũng không ảnh hưởng toàn hệ thống.

  • Đơn giản hóa quản lý: Không cần cấp nhiều mật khẩu cho từng route.

  • Rõ ràng trách nhiệm: Dễ truy vết ai đã làm gì.

❌ Nhược điểm

  • Bùng nổ vai trò: Tạo quá nhiều vai trò nếu không kiểm soát tốt.

  • Xung đột vai trò: Một người có thể bị gán các role gây xung đột quyền (ví dụ: tạo và duyệt cùng một đơn hàng).

🧠 Best Practices khi triển khai RBAC

  • Xác định rõ dữ liệu/route cần bảo vệ.

  • Phân nhóm người dùng theo vai trò rõ ràng.

  • Tránh tạo quá nhiều vai trò không cần thiết.

  • Vai trò nên áp dụng cho nhóm người dùng, không phải cá nhân đơn lẻ.

  • Xây dựng cơ chế thêm/sửa/xóa vai trò dễ bảo trì.

  • RBAC là thứ cần liên tục cải tiến theo thời gian.

🛠️ Bắt đầu xây dựng hệ thống

1. Khởi tạo project

mkdir rbac-system
cd rbac-system
npm init -y

2. Cài đặt package cần thiết

npm install bcrypt body-parser cookie-parser cors dotenv express jsonwebtoken mongoose nodemon

3. Cài đặt mongo database

Trong bài viết này tôi sẽ sử dụng docker để tạo server mongodb sau đây là file docker-compose.yml

version: '3.8'

services:
  mongodb:
    image: mongo:latest
    container_name: rbac-mongodb
    ports:
      - "${MONGO_PORT}:27017"
    environment:
      - MONGO_INITDB_ROOT_USERNAME=${MONGO_USERNAME}
      - MONGO_INITDB_ROOT_PASSWORD=${MONGO_PASSWORD}
      - MONGO_INITDB_DATABASE=${MONGO_DATABASE}
    volumes:
      - mongodb_data:/data/db
    networks:
      - rbac-network

networks:
  rbac-network:
    driver: bridge

volumes:
  mongodb_data:

Trong docker-compose.yml:

  • Sử dụng cú pháp ${VARIABLE_NAME} để tham chiếu đến biến môi trường

  • Docker Compose tự động đọc file .env trong cùng thư mục

  • Đây là ví dụ file .env

      # MongoDB Configuration
      MONGO_USERNAME=rbac_user
      MONGO_PASSWORD=rbac_password
      MONGO_DATABASE=rbac_db_1
      MONGO_PORT=27017
    
      # Node.js Server Configuration
      SERVER_PORT=3000
      NODE_ENV=development
      APP_SECRET=a75638e528d0a947012c61222e0c8354
    

Sử dụng các câu lệnh sau để start/stop container mongodb

# start
docker-compose up -d

#stop
docker compose down -v

Để kiểm tra xem server mongodb đã chạy chưa sử dụng bạn hãy sử dụng lệnh sau:

docker exec -it rbac-mongodb mongosh -u rbac_user -p rbac_password --authenticationDatabase admin

Kết quả như sau:

4. Tạo schema cho nhân viên (Employee)

const { Schema, model } = require('mongoose');

const EmployeeSchema = new Schema(
  {
    name: {
      type: String,
      require: true
    },
    email: {
      type: String,
      require: true,
    },
    role: {
      type: String,
      enum: ["se", "marketer", "HR", "admin"]
    },
    password: {
      type: String,
      require: true,
    }
  },
  { timestamps: true }
);

module.exports = model("employee", EmployeeSchema);

5. Đăng ký người dùng (Signup)

Xử lý:

  • Kiểm tra trùng tên hoặc email

  • Hash password

  • Lưu vào database

const bcrypt = require('bcrypt');
const Employee = require("../Database/employee");

/**
 * @DESC To register the employee (ENGINEER, MARKETER, HR-PERSONNEL)
 */
const employeeSignup = async (req, role, res) => {
  try {

    // validate the name
    const nameNotTaken = await validateEmployeename(req.name);
    if (!nameNotTaken) {
      return res.status(400).json({
        message: 'Employee is already registered.'
      })
    }

    // validate the email
    const emailNotRegistered = await validateEmail(req.email);
    if (!emailNotRegistered) {
      return res.status(400).json({
        message: 'Email is already registered.'
      })
    }

    // get the hashed password
    const password = await bcrypt.hash(req.password, 12);

    // create a new user
    const newEmployee = new Employee({
      ...req,
      password,
      role
    })

    await newEmployee.save();

    return res.status(201).json({
      message: "Hurry! now you are successfully registred. Please nor login."
    });
  } catch (error) {
    return res.status(500).json({
      message: `${err.message}`
    });
  }
}

6. Đăng nhập người dùng (Login)

Xử lý:

  • Kiểm tra email tồn tại

  • Kiểm tra đăng nhập đúng portal (route theo role)

  • So sánh password

  • Trả về JWT nếu hợp lệ

/**
 * @DESC To Login the employee (ENGINEER, MARKETER, HR-PERSONNEL)
 */
const employeeLogin = async (req, role, res) => {
  let { email, password } = req;
  console.log('role', role);

  // check if the email is in the database
  const employee = await Employee.findOne({ email });
  if (!employee) {
    return res.status(404).json({
      message: "Employee email is not found. Invalid login credentials.",
    })
  };

  // check the role
  if (employee.role !== role) {
    return res.status(404).json({
      message: "Please make sure you are logging in from the right portal.",
    })
  };

  // check for the password
  const isMatch = await bcrypt.compare(password, employee.password);
  if (isMatch) {
    // sign the token and issue it to the user
    const token = jwt.sign({
      id: employee.id,
      role: employee.role,
      name: employee.name,
      email: employee.email
    },
      process.env.APP_SECRET,
      { expiresIn: "1 days" }
    )

    let result = {
      id: employee.id,
      name: employee.name,
      role: employee.role,
      email: employee.email
    }

    res.status(200).cookie('jwt', token, {
      expires: new Date(Date.now() + 1 * 24 * 60 * 60 * 1000),
      secure: false,
      httpOnly: true
    })

    return res.json({
      ...result,
      message: "You are now logged in."
    })

  } else {
    return res.status(403).json({
      message: "Incorrect username or password."
    })
  }

}

7. Middleware kiểm tra token & vai trò

/**
 * @DESC Verify JWT Middleware
 */
const employeeAuth = (req, res, next) => {
  const authHeader = req.headers['authorization'];
  if (!authHeader) {
    return res.status(403).json({
      message: 'Missing authorization header.'
    });
  }
  const token = authHeader.split(' ')[1];
  jwt.verify(
    token,
    process.env.APP_SECRET,
    (err, decoded) => {
      if (err) return res.status(404).json({
        message: 'invalid token'
      }); //invalid token
      req.auth = decoded;
      next();
    }
  )

}

/**
 * @DESC Check Role Middleware
 */
const checkRole = roles => async (req, res, next) => {
  const email = req.auth?.email;
  const employee = await Employee.findOne({ email });
  !roles.includes(employee.role) ? res.status(401).json("Sorry you do not have access to this route") : next();
}
  • Lấy token từ Authorization header (định dạng Bearer <token>).

  • Nếu không có header → trả về lỗi 403.

  • Dùng jwt.verify() để kiểm tra token với process.env.APP_SECRET.

  • Nếu token không hợp lệ → trả về lỗi 404.

  • Nếu hợp lệ → gắn thông tin người dùng đã decode vào req.auth.

  • Gọi next() để tiếp tục sang middleware tiếp theo.

8. Định nghĩa route

Tạo các route như:

  • /signup/se, /signup/marketer, /signup/hr

  • /login/se, /login/marketer, /login/hr

  • /se-protected, /marketers-protected, /hr-protected

Các route riêng được bảo vệ bằng employeeAuthcheckRole(["role"])

9. Hoàn thiện

Dưới đây là code hoàn thiện của file server.js

require('dotenv').config()

const express = require('express');
const bp = require('body-parser');
const cookieParser = require('cookie-parser')
const mongoose = require('mongoose');
const cors = require('cors');

const userRouter = require('./routes/userRouter')
const app = express();

const PORT = process.env.PORT || 3000;

//Connecting our database
const MONGODB_URI = `mongodb://${process.env.MONGO_USERNAME}:${process.env.MONGO_PASSWORD}@127.0.0.1:${process.env.MONGO_PORT}/${process.env.MONGO_DATABASE}?authSource=admin`
console.log('MONGODB_URI', MONGODB_URI)
mongoose.connect(MONGODB_URI)
  .then(() => {
    console.log('MongoDB connected...');
  })

app.use(bp.json());
app.use(cookieParser());
app.use(bp.urlencoded({ extended: true }))

const corsOptions = {
  origin: 'http://localhost:3000',
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization', 'Access-Control-Allow-Credentials']
};

app.use(cors(corsOptions));
app.use('', userRouter);

app.listen(PORT, () => {
  console.log(`Server started on port ${PORT}`);
});

Dưới đây là file userRouter.js

const router = require('express').Router();
const {
  checkRole,
  employeeAuth,
  employeeLogin,
  employeeSignup,
  jwtAuth
} = require('../controllers/authFunctions');


// Software engineering Registeration Route
router.post("/register-se", (req, res) => {
  employeeSignup(req.body, "se", res);
})

//Marketer Registration Route
router.post("/register-marketer", async (req, res) => {
  await employeeSignup(req.body, "marketer", res);
});

//Human resource Registration route
router.post("/register-hr", async (req, res) => {
  await employeeSignup(req.body, "hr", res);
});

// Software engineers Login Route
router.post("/login-se", async (req, res) => {
  await employeeLogin(req.body, "se", res);
});

// Human Resource Login Route
router.post("/login-hr", async (req, res) => {
  await employeeLogin(req.body, "hr", res);
});

// Marketer Login Route
router.post("/login-marketer", async (req, res) => {
  await employeeLogin(req.body, "marketer", res);
});

//Software engineers protected route
router.get(
  "/se-protected",
  employeeAuth,
  checkRole(["se"]),
  async (req, res) => {
    return res.json({ message: `welcome ${req.auth.role} - ${req.auth.name}` });
  }
);


//Marketers protected route
router.get(
  "/marketers-protected",
  employeeAuth,
  checkRole(["marketer"]),
  async (req, res) => {
    return res.json({ message: `welcome ${req.auth.role} - ${req.auth.name}` });
  }
);


//HR personels protected route
router.get(
  "/hr-protected",
  employeeAuth,
  checkRole(["hr"]),
  async (req, res) => {
    return res.json({ message: `welcome ${req.auth.role} - ${req.auth.name}` });
  }
);

router.post("/protected", jwtAuth, (req, res) => {
  res.status(200).send("Here's the info you requested ");
});

module.exports = router;

🔐Testing app của chúng ta

Đăng ký người dùng

curl --location 'localhost:3000/register-se' \
--header 'Content-Type: application/json' \
--data-raw '{
    "name": "eminel",
    "email": "eminel@gmail.com",
    "password": "abc@123456"
}'
curl --location 'localhost:3000/register-marketer' \
--header 'Content-Type: application/json' \
--data-raw '{
    "name": "marketer 1",
    "email": "marketer1@gmail.com",
    "password": "abc@123456"
}'
curl --location 'localhost:3000/register-hr' \
--header 'Content-Type: application/json' \
--data-raw '{
    "name": "hr 1",
    "email": "hr1@gmail.com",
    "password": "abc@123456"
}'

Login người dùng

curl --location 'localhost:3000/login-se' \
--header 'Content-Type: application/json' \
--data-raw '{
    "email": "eminel@gmail.com",
    "password": "abc@123456"
}'
curl --location 'localhost:3000/login-hr' \
--header 'Content-Type: application/json' \
--data-raw '{
    "email": "hr1@gmail.com",
    "password": "abc@123456"
}'
curl --location 'localhost:3000/login-marketer' \
--header 'Content-Type: application/json' \
--data-raw '{
    "email": "marketer1@gmail.com",
    "password": "abc@123456"
}'

Truy cập các protected router

curl --location 'localhost:3000/se-protected' \
--header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjY4MjMwMjIxYTg1MmIzYmRjZmIyY2Y2ZCIsInJvbGUiOiJzZSIsIm5hbWUiOiJlbWluZWwiLCJlbWFpbCI6ImVtaW5lbEBnbWFpbC5jb20iLCJpYXQiOjE3NDcxMjc0MjUsImV4cCI6MTc0NzIxMzgyNX0.nMgFOcEwYlfc9qRdnPQYaQsSSWt7GlciTMEbEPi77Pg' \
--header 'Cookie: jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjY4MjMwMzE5YTg1MmIzYmRjZmIyY2Y3MSIsInJvbGUiOiJtYXJrZXRlciIsIm5hbWUiOiJtYXJrZXRlciAxIiwiZW1haWwiOiJtYXJrZXRlcjFAZ21haWwuY29tIiwiaWF0IjoxNzQ3MTI5MDgwLCJleHAiOjE3NDcyMTU0ODB9.Trp3BLJQ4rgFMPtriAr9Ry9vePh4ECQT2-rnYaGgHcE'

Nếu bạn sử dụng token của user khác truy cập thì sẽ nhận thông báo như sau

🔚 Kết luận

RBAC giúp hệ thống của bạn an toàn, rõ ràng và dễ quản lý khi có nhiều người dùng và vai trò khác nhau. Hãy áp dụng theo hướng modular và dễ mở rộng để quản lý lâu dài hiệu quả hơn.

Tải source tại đây

0
Subscribe to my newsletter

Read articles from Eminel directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Eminel
Eminel

Hello, my name is Eminel a software engineer specializing in scalable software architecture, microservices, and AI-powered platforms. Passionate about mentoring, performance optimization, and building solutions that drive business success.