In this tutorial, I want to share how to implement dynamic role based access control (RBAC) system in express js (node js) API with Postgres, and Sequelize ORM with ES6+.
There are many resources out there on creating a user account with role field in the user table. The limitation with this is that a user can only have one role at a time.
Some software products such as management systems might require a user to share multiple roles and sometimes have direct permissions to perform an action.
Let's explore how to create a user account with multiple roles and direct permissions for specific actions.
For this implementation, we are going to continue from my previous tutorial on how to set up Express JS REST API, Postgres, and Sequelize ORM with ES6+ with a some tweaks.
Clone this repository for the tutorial.
Let's create util functions for hashing password and json responses. Create utils folder in src folder and add two files: hashing.js & sendResponse.js
In hashing.js, add the following codes:
import crypto from 'crypto';
export const hash = (string) => crypto.createHash('sha256').update(string).digest('base64');
export const hash_compare = (first_item, second_item) => Object.is(first_item, second_item);
Add the following to sendResponse.js:
export const sendErrorResponse = (res, code, errorMessage, e = null) => res.status(code).send({
status: 'error',
error: errorMessage,
e: e?.toString(),
});
export const sendSuccessResponse = (res, code, data, message = 'Successful') => res.status(code).send({
status: 'success',
data,
message,
});
Replace the codes in src/controllers/AuthController.js with
import {Op} from 'sequelize';
import model from '../models';
import {sendErrorResponse, sendSuccessResponse} from "../utils/sendResponse";
import {hash} from "../utils/hashing";
const {User} = model;
export default {
async signUp(req, res) {
const {email, password, name, phone} = req.body;
try {
let user = await User.findOne({where: {[Op.or]: [{phone}, {email}]}});
if (user) {
return sendErrorResponse(res, 422, 'User with that email or phone already exists');
}
const settings = {
notification: {
push: true,
email: true,
},
};
user = await User.create({
name,
email,
password: hash(password),
phone,
settings
});
return sendSuccessResponse(res, 201, {
user: {
id: user.id,
name: user.name,
email: user.email,
}
}, 'Account created successfully');
} catch (e) {
console.error(e);
return sendErrorResponse(res, 500, 'Could not perform operation at this time, kindly try again later.', e)
}
}
}
In the routes folder, add a file authRouter.js and add the following code:
import express from 'express';
import AuthController from '../controllers/AuthController';
const router = express.Router();
router.post('/register', AuthController.signUp);
export default router;
Replace the code in src/routes/index.js with:
import authRouter from "./authRouter";
import express from "express";
import { sendErrorResponse } from "../utils/sendResponse";
export default (app) => {
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use('/api/v1/auth', authRouter);
app.all('*', (req, res) => sendErrorResponse(res, 404, 'Route does not exist'));
};
Let's implement the API login. I prefer to persist the token in the database. The benefit of this is that a user will know the devices with active session and can choose to destroy the session. It's the approach used in telegram messaging app.
Run the sequelize command to create the token model and migration
sequelize model:generate --name PersonalAccessToken --attributes name:string,token:string,last_used_at:string,last_ip_address:string
Update the PersonalAccessToken model with the following code:
import { Model } from 'sequelize';
const PROTECTED_ATTRIBUTES = ['token'];
export default (sequelize, DataTypes) => {
class PersonalAccessToken extends Model {
toJSON() {
// hide protected fields
const attributes = { ...this.get() };
// eslint-disable-next-line no-restricted-syntax
for (const a of PROTECTED_ATTRIBUTES) {
delete attributes[a];
}
return attributes;
}
/**
* Helper method for defining associations.
* This method is not a part of Sequelize lifecycle.
* The `models/index` file will call this method automatically.
*/
static associate(models) {
// define association here
PersonalAccessToken.belongsTo(models.User, {
foreignKey: 'user_id',
as: 'owner',
onDelete: 'CASCADE',
});
models.User.hasMany(PersonalAccessToken, {
foreignKey: 'user_id',
as: 'tokens',
onDelete: 'CASCADE',
});
}
}
PersonalAccessToken.init({
user_id: {
type: DataTypes.INTEGER,
allowNull: false,
},
name: DataTypes.STRING,
token: {
type: DataTypes.STRING,
unique: true,
allowNull: false,
},
last_used_at: DataTypes.DATE,
last_ip_address: DataTypes.STRING
}, {
sequelize,
modelName: 'PersonalAccessToken',
});
return PersonalAccessToken;
};
We just added the associations for the token and the user model and can then create our login controller.
Add this function to the user model before the return statement:
/**
* Create a new personal access token for the user.
*
* @return Object
* @param device_name
*/
User.prototype.newToken = async function newToken(device_name = 'Web FE') {
const plainTextToken = Random(40);
const token = await this.createToken({
name: device_name,
token: hash(plainTextToken),
});
return {
accessToken: token,
plainTextToken: `${token.id}|${plainTextToken}`,
};
};
Remember to import the hashing utils in the user model. Add a random token generator and import as well. Create src/utils/Random.js and add the code:
import crypto from 'crypto';
export default (length = 6, type = 'alphanumeric') => {
if (!(length >= 0 && Number.isFinite(length))) {
throw new TypeError('Expected a `length` to be a non-negative finite number');
}
let characters;
switch (type) {
case 'numeric':
characters = '0123456789'.split('');
break;
case 'url-safe':
characters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~'.split('');
break;
case 'alphanumeric':
default:
characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'.split('');
break;
}
// Generating entropy is faster than complex math operations, so we use the simplest way
const characterCount = characters.length;
const maxValidSelector = (Math.floor(0x10000 / characterCount) * characterCount) - 1; // Using values above this will ruin distribution when using modular division
const entropyLength = 2 * Math.ceil(1.1 * length); // Generating a bit more than required so chances we need more than one pass will be really low
let string = '';
let stringLength = 0;
while (stringLength < length) { // In case we had many bad values, which may happen for character sets of size above 0x8000 but close to it
const entropy = crypto.randomBytes(entropyLength);
let entropyPosition = 0;
while (entropyPosition < entropyLength && stringLength < length) {
const entropyValue = entropy.readUInt16LE(entropyPosition);
entropyPosition += 2;
if (entropyValue > maxValidSelector) { // Skip values which will ruin distribution when using modular division
// eslint-disable-next-line no-continue
continue;
}
string += characters[entropyValue % characterCount];
// eslint-disable-next-line no-plusplus
stringLength++;
}
}
return string;
};
Let's create login method in the src/controllers/AuthController.js file
async login(req, res) {
const { login, password, device_name } = req.body;
try {
const user = await User.findOne({ where: { email: login } });
if (!user) return sendErrorResponse(res, 404, 'Incorrect login credentials. Kindly check and try again');
const checkPassword = hash_compare(hash(password), user.password);
if (!checkPassword) {
return sendErrorResponse(res, 400, 'Incorrect login credentials. Kindly check and try again');
}
if (user.status !== 'active') {
return sendErrorResponse(res, 401, 'Your account has been suspended. Contact admin');
}
const token = await user.newToken();
return sendSuccessResponse(res, 200, {
token: token.plainTextToken,
user: {
name: user.name,
id: user.id,
email: user.email,
},
}, 'Login successfully');
} catch (e) {
console.error(e);
return sendErrorResponse(res, 500, 'Server error, contact admin to resolve issue', e);
}
}
Use postman to test login
With the sign up and login features completed, let's dive into creating models for Roles and Permission. You can later add an endpoint for the user to login of other device, that's beyond the scope of this tutorial.
We are going to create Role, Permission, UserRole, RolePermission and UserPermission models and migrations.
Check repo for models and relationship.
Next, add this static method to PersonalAccessToken model:
/***
* Verify the token and retrieve the authenticated user for the incoming request.
* @param authorizationToken
* @returns {Promise<{user}>}
*/
static async findToken(authorizationToken) {
if (authorizationToken) {
let accessToken;
if (!authorizationToken.includes('|')) {
accessToken = await this.findOne({ where: { token: hash(authorizationToken) }, include: 'owner' });
} else {
const [id, kToken] = authorizationToken.split('|', 2);
const instance = await this.findByPk(id, { include: 'owner' });
if (instance) {
accessToken = hash_compare(instance.token, hash(kToken)) ? instance : null;
}
}
if (!accessToken) return { user: null, currentAccessToken: null };
accessToken.last_used_at = new Date(Date.now());
await accessToken.save();
return { user: accessToken.owner, currentAccessToken: accessToken.token };
}
return { user: null, currentAccessToken: null };
}
Let's create a seeder for our default roles and permissions using the command
sequelize seed:generate --name roles-permissions-admin-user
Add the following to the seeder file located at src/database/seeders:
import { hash } from '../../utils/hashing';
import model from '../../models';
import Constants from '../../utils/constants';
const { User, Role, Permission } = model;
export default {
// eslint-disable-next-line no-unused-vars
up: async (queryInterface, Sequelize) => {
/**
* Add seed commands here.
*
* Example:
* await queryInterface.bulkInsert('People', [{
* name: 'John Doe',
* isBetaMember: false
* }], {});
*/
await Role.bulkCreate([
{ name: Constants.ROLE_SUPER_ADMIN },
{ name: Constants.ROLE_ADMIN },
{ name: Constants.ROLE_MODERATOR },
{ name: Constants.ROLE_AUTHENTICATED },
]);
await Permission.bulkCreate([
{ name: Constants.PERMISSION_VIEW_ADMIN_DASHBOARD },
{ name: Constants.PERMISSION_VIEW_ALL_USERS },
]);
const superAdminUser = await User.create({
name: 'John Doe',
email: 'johndoe@node.com',
password: hash('Password111'),
phone: '+2348123456789',
});
const superAdminRole = await Role.findOne({ where: { name: Constants.ROLE_SUPER_ADMIN } });
const superAdminPermissions = await Permission.findAll({
where: {
name: [
Constants.PERMISSION_VIEW_ADMIN_DASHBOARD,
Constants.PERMISSION_VIEW_ALL_USERS,
],
},
});
await superAdminUser.addRole(superAdminRole);
await superAdminRole.addPermissions(superAdminPermissions);
},
// eslint-disable-next-line no-unused-vars
down: async (queryInterface, Sequelize) => {
/**
* Add commands to revert seed here.
*
* Example:
* await queryInterface.bulkDelete('People', null, {});
*/
await Role.destroy();
await Permission.destroy();
await User.destroy();
},
};
Here, we've created a super admin user, default roles and permissions and sync the models.
Update the user model with the following:
User.prototype.hasRole = async function hasRole(role) {
if (!role || role === 'undefined') {
return false;
}
const roles = await this.getRoles();
return !!roles.map(({ name }) => name)
.includes(role);
};
User.prototype.hasPermission = async function hasPermission(permission) {
if (!permission || permission === 'undefined') {
return false;
}
const permissions = await this.getPermissions();
return !!permissions.map(({ name }) => name)
.includes(permission.name);
};
User.prototype.hasPermissionThroughRole = async function hasPermissionThroughRole(permission) {
if (!permission || permission === 'undefined') {
return false;
}
const roles = await this.getRoles();
// eslint-disable-next-line no-restricted-syntax
for await (const item of permission.roles) {
if (roles.filter(role => role.name === item.name).length > 0) {
return true;
}
}
return false;
};
User.prototype.hasPermissionTo = async function hasPermissionTo(permission) {
if (!permission || permission === 'undefined') {
return false;
}
return await this.hasPermissionThroughRole(permission) || this.hasPermission(permission);
};
Next, we create a middleware for the route. We are going to create two middleware files, one for basic authentication and another for the permissions.
In the src folder, create another folder called middleware and add Auth.js and canAccess.js files to it.
Paste the following as the content for Auth.js file:
import { sendErrorResponse } from '../utils/sendResponse';
import model from '../models';
const { PersonalAccessToken } = model;
export default async (req, res, next) => {
try {
if (!req.headers.authorization) {
return sendErrorResponse(res, 401, 'Authentication required');
}
const bearerToken = req.headers.authorization.split(' ')[1] || req.headers.authorization;
const { user, currentAccessToken } = await PersonalAccessToken.findToken(bearerToken);
if (!user) {
return sendErrorResponse(res, 401, 'Authentication Failed');
}
if (user.status !== 'active') return sendErrorResponse(res, 403, 'Your account is either suspended or inactive. Contact admin to re-activate your account.');
req.currentAccessToken = currentAccessToken;
req.userData = user;
next();
} catch (e) {
console.error(e);
return sendErrorResponse(res, 401, 'Authentication Failed', e);
}
};
and canAccess.js
import { sendErrorResponse } from '../utils/sendResponse';
import model from '../models';
const { Role, Permission } = model;
export default (permission) => async (req, res, next) => {
const access = await Permission.findOne({
where: { name: permission },
include: [{ attributes: ['id', 'name'], model: Role, as: 'roles', through: { attributes: [] } }],
});
if (await req.userData.hasPermissionTo(access)) {
return next();
}
console.error('You do not have the authorization to access this.');
return sendErrorResponse(res, 403, 'You do not have the authorization to access this');
};
Finally, let's add our middlewares to the route by creating an adminRouter.js file in routes folder and add the following:
import express from 'express';
import Auth from '../middlewares/Auth';
import can from '../middlewares/canAccess';
import Constants from '../utils/constants';
import AdminController from "../controllers/AdminController";
import { sendSuccessResponse } from "../utils/sendResponse";
const router = express.Router();
router.get('/users', Auth, can(Constants.PERMISSION_VIEW_ALL_USERS), AdminController.users);
router.get('/dashboard', Auth, can(Constants.PERMISSION_VIEW_ADMIN_DASHBOARD), (req, res) => {
return sendSuccessResponse(res, 200, '', 'Admin dashboard access allowed.')
});
export default router;
Note, I created an AdminController file and used it in the route. You can organize your code however you want.
The basic thing is that each route has a permission tag and that permission can be assigned to a role or directly to a user for the middleware to allow or deny.
See complete API server codes on github.
The API documention is available here
If you have issues, questions or contribution, kindly do so in the comment section below.
That's how to implement Dynamic Role based Access Control (RBAC) system in Express js (Node js) API. The code is not production ready, you are free to improve on it and use it.
Add logout controller, validation for data, endpoint for creating and assigning roles and permissions and other features your app will need.
Thank you.
Top comments (2)
Thank you it really useful
Graet