Express.js: Phân quyền theo vai trò với package node_acl

Hầu hết các ứng dụng web đều sử dụng một bộ các role để cho phép người dùng được truy cập vào tài nguyên mà họ được phép truy câp. Chúng ta sẽ đi tìm hiểu một chút về vấn đề này và cùng xây dựng một ví dụ nhỏ.

Access Control List(ACL)

Là một “tài liệu” mô tả việc phân quyền cho các user trong một hệ thống, thường được biểu diễn dưới dạng một bảng các quyền mà user có thể có:

User Read Write Publish
hoangdv 1 1 1
minhnt 1 1 0
jack 1 0 0

Như trong bảng này chúng ta có thể thấy mỗi người là một hàng và có các quyền cụ thể gán cho từng người. Khi người dùng thực hiện một hành động, hàng của người dùng và cột hành động tương ứng sẽ được kiểm tra để xác định người dùng có quyền truy cập hay không.

Role Based Access Control(RBAC)

Là một một phương pháp kiểm soát truy cập trong các ứng dụng, khi đó người dùng sẽ được phân các vai trò và các vai trò sẽ xác định các quyền truy cập của họ. Loại này thường được mô tả dạng cây (tree) hoặc biểu đồ để biểu diễn sự kế thừa các quyền try cập. Với bảng ACL ở trên biểu đồ RBAC tương ứng có thể là: 

Ở phương pháp này cho phép các vai trò được thừa hưởng các quyền từ các vai trò khác từ đó ta có thể dễ dàng thêm các đặc quyền mới vào toàn bộ hệ thống các quyền truy cập. Bằng việc tách biệt người dùng thành các thành phần được xác định trước, chúng ta sẽ dễ dàng mô hình hóa việc bảo mật cho ứng dụng.

Coding

Chúng ta đã có hiểu biết cơ bản về mô hình RBAC – Logic của mô hình khá đơn giản: Chúng ta sẽ xác định các vai trò và mỗi vai trò có các đặc quyền tương ứng. Khi kiểm tra truy cập bạn kiểm tra vai trò đó và kiểm tra nó có quyền đó không. Chúng ta sẽ lấy mô hình dưới đây để làm ví dụ:

Name Read Write Publish
manager 1 1 1
writer 1 1 0
guest 1 0 0
Name Role
hoangdv manager
minhnt writer
jack guest

Chúng ta sẽ dùng package node_acl để hiện thực hóa mô hình phân quyền ở trên.

Khởi tạo đối tượng acl

Package hỗ trợ backend bằng: memory, redis, mongodb (ngoài ra còn một số backend của các bên thứ 3 tự custom) Ở ví dụ này chúng ta sẽ sử dụng memoryBackend, đồng nghĩa với việc nếu ứng dụng của bạn bị khởi động lại thì toàn bộ thông tin phân quyền sẽ bị mất.

let acl = new node_acl(new node_acl.memoryBackend(), {
  debug: (msg) => {
    console.log('-DEBUG-', msg);
  }
});



Với các db khác, việc chờ kết nối tới các backend (mongo, redis…) thường là bất đồng bộ, các bạn sử dụng callback (chờ việc kết nối hoàn thành sau đó thực hiện các công việc), để đơn giản chúng ta có thể sử dụng cú pháp async/await.
Ví dụ với mongo:

var mongodb = require('mongodb');
var MongoClient = mongodb.MongoClient;
var url = 'mongodb://localhost:27017/my_database_name';

MongoClient.connect(url, function (err, db) {
  if (err) {
    console.log('Unable to connect to the mongoDB server. Error:', err);
  } else {
    console.log('Connection established to', url);

    acl = new node_acl(new acl.mongodbBackend(db, prefix));

    // Định nghĩa các quyền truy cập cho các route

    db.close();
  }
});

Kết hợp khi sử dụng mongoose:

const mongoose = require('mongoose');

const dbInstance = mongoose.connect(config.DB_URL, {});
acl = new acl(new acl.mongodbBackend(dbInstance.connection.db, "acl_"));

mongoose.connection.on('open', function () {
  console.log('Connected to mongo server');
  // Định nghĩa các quyền truy cập cho các route
});

 

Định nghĩa các quyền truy cập cho các route của express app

Phương thức allow cho phép tạo ra các vai trò và quyền truy cập cho vai trò tương ứng. resources là tài nguyên được phân quyền permissions là các quyền được áp dụng lên tài nguyên tương ứng. Là một string hoặc [string]. Các permission mặc định sẽ là http verb (req.method.toLowerCase()).

acl.allow([
  {
    roles: 'manager',
    allows: [
      {
        resources: '/posts/publish',
        permissions: '*'
      }
    ]
  },
    {
    roles: 'writer',
    allows: [
      {
        resources: '/posts',
        permissions: 'post'
      }
    ]
  },
  {
    roles: 'guest',
    allows: [
      {
        resources: '/posts',
        permissions: 'get'
      }
    ]
  }
]);

Các vai trò được kế thừa quyền từ các vai trò khác

Như trong ví dụ chúng ta đã mô tả: manager sẽ có quyền của mình và toàn bộ quyền của các nhóm khác, writer thì có quyền của mình và quyền của vai trò guest

acl.addRoleParents('writer', 'guest');
acl.addRoleParents('manager', 'writer');

Phân quyền cho người dùng vào các vai trò

Chúng ta sẽ sử dụng định danh của một người dùng (vd: userId) để chỉ định một người dùng được phân vào nhóm nào. Thông tin này sẽ được Backend của package acl lưu lại.

// grant "manager" role
acl.addUserRoles("hoangdv", "manager");

// grant "writer" role
acl.addUserRoles("minhnt", "writer");

// grant "guest" role
acl.addUserRoles("jack", "guest");

Middleware cho route các tài nguyên ứng

Sau khi đã định nghĩa các vai trò và thêm người dùng vào các vai trò chúng ta sẽ thêm middleware cho các route của express app để thực hiện việc phân quyền. Package có sắn một phương thức middleware: (numPathComponents?: number, userId?: Value | GetUserId, actions?: strings) => express.RequestHandler; hỗ trợ việc kiểm tra người dùng có quyền truy cập tài nguyên tương ứng không. Phương thức acl.middleware có 3 tham số không bắt buộc:

  • numPathComponents : Số các phần trong originalUrl sẽ được lấy để kiểm tra quyền truy cập
    url = req.originalUrl.split('?')[0];
    if(!numPathComponents){
      resource = url;
    }else{
      resource = url.split('/').slice(0,numPathComponents+1).join('/');
    }
  • userId : là một string, number hoặc một function trả lại thông tin định danh của user đã dùng để cấu hình ở bước Phân quyền cho người dùng vào các vai trò. Nếu chúng truyền vào một function, function này sẽ nhận vào 2 tham số req, res của express request, chú ý hàm này phải là một hàm đồng bộ trả về giá trịnh định danh của người dùng. Nếu truyền tham số này một giá trị falsy thì các giá trị mặc định sẽ được lấy là req.session.userId || req.user.id
  • actions : Quyền truy cập, mặc định sẽ là req.method.toLowerCase()Chúng ta có thể custom lại middleware cho việc này, khi đó chúng ta sẽ sử dụng phương thức isAllowed: (userId: Value, resources: strings, permissions: strings, cb?: AllowedCallback) => Promise<boolean>; để kiểm tra quyền truy cập của người dùng trên tài nguyên tương ứng. Với ví dụ của chúng ta, chúng ta sẽ có các route:
    // Only for guests and higher
    app.get('/posts', acl.middleware(1, getUserId), (req, res) => {
      res.send('Read post!');
    });
    
    // Only for writer and higher
    app.post('/posts', acl.middleware(1, getUserId), (req, res) => {
      res.send('Post created!');
    });
    
    // Only for manager
    app.post('/posts/publish', acl.middleware(0, getUserId), (req, res) => {
      res.send('Post published!');
    });
    
    function getUserId(req) {
      return req.query.uid; // (yaoming) just for fun
    }

     

Toàn bộ file ví dụ:

const express = require('express');
const node_acl = require('acl');

let app = express();
app.set('port', process.env.PORT || 3000);

app.use((err, req, res, next) => {
 if (!err) return next();
 req.status(403).json({ message: err.msg, error: err.errorCode });
});

let acl = new node_acl(new node_acl.memoryBackend(), {
 debug: (msg) => {
   console.log('-DEBUG-', msg);
 }
});

// This creates a set of roles which have permissions on
//  different resources.
acl.allow([
 {
   roles: 'trumcuoi',
   allows: [
     {
       resources: '/allow',
       permissions: '*'
     },
     {
       resources: '/disallow',
       permissions: '*'
     },
   ]
 },
 {
   roles: 'manager',
   allows: [
     {
       resources: '/posts/publish',
       permissions: '*'
     }
   ]
 },
 {
   roles: 'writer',
   allows: [
     {
       resources: '/posts',
       permissions: 'post'
     }
   ]
 }, {
   roles: 'guest',
   allows: [
     {
       resources: '/posts',
       permissions: 'get'
     }
   ]
 }
]);

// Inherit roles
//  Every writer is allowed to do what guests do
//  Every manager is allowed to do what writer do
acl.addRoleParents('writer', 'guest');
acl.addRoleParents('manager', 'writer');

initRoutes();

function initRoutes() {
 // Defining routes ( resources )

 // Simple overview of granted permissions
 app.get('/info', (req, res) => {
   acl.allowedPermissions(getUserId(req), ['/posts', '/posts/publish', '/allow/*', '/disallow/*'], (err, permission) => {
     res.json(permission);
   });
 });

 app.get('/user', (req, res) => {
   acl.userRoles(getUserId(req), (err, roles) => {
     res.json(roles);
   })
 });

 // Only for guests and higher
 app.get('/posts', acl.middleware(1, getUserId), (req, res) => {
   res.send('Read post!');
 });

 // Only for writer and higher
 app.post('/posts', acl.middleware(1, getUserId), (req, res) => {
   res.send('Post created!');
 });

 // Only for manager
 app.post('/posts/publish', acl.middleware(0, getUserId), (req, res) => {
   res.send('Post published!');
 });

 // Setting a new role
 app.get('/allow/:user/:role', acl.middleware(1, getUserId), (req, res) => {
   acl.addUserRoles(req.params.user, req.params.role);
   res.send(req.params.user + ' is a ' + req.params.role);
 });

 // Unsetting a role
 app.get('/disallow/:user/:role', acl.middleware(1, getUserId), (req, res) => {
   acl.removeUserRoles(req.params.user, req.params.role);
   res.send(req.params.user + ' is not a ' + req.params.role + ' anymore.');
 });
}

// grant "trumcuoi" role for default user
acl.addUserRoles("boss", "trumcuoi");

// grant "manager" role
acl.addUserRoles("hoangdv", "manager");

// grant "writer" role
acl.addUserRoles("minhnt", "writer");

// grant "guest" role
acl.addUserRoles("jack", "guest");

// Provide logic for getting the logged-in user
//  This is a job for your authentication layer
function getUserId(req) {
 return req.query.uid; // (yaoming) just for fun
}

app.listen(app.get('port'), () => {
 console.log('ACL example listening on port ' + app.get('port'));
});

Khi chúng ta chạy ứng dụng và thực hiện truy cập:

GET /posts?uid=jack => ok

GET /posts?uid=minhnt => ok

GET /posts?uid=hoangdv => ok

POST /posts?uid=jack => fail

POST /posts?uid=minhnt => ok

POST /posts?uid=hoangdv => ok

POST /posts/publish?uid=jack => fail

POST /posts/publish?uid=minhnt => fai

POST /posts/publish?uid=hoangdv => ok

Kết luận

Hy vọng bài viết sẽ giúp mọi người có thêm các từ khóa và các ý tưởng để xây dựng các ý tưởng cho mình.

 

Bài viết được viết bởi cùng tác giả tại trang viblo.asia