Role-based access control is a secure authentication mechanism. You can use it to restrict access to specific resources to users who have certain roles.
This type of authentication helps system administrators control permissions according to users’ designated roles. This level of granular control adds a layer of security, allowing apps to prevent unauthorized access.
Implementing Role-Based Access Control Mechanism Using Passport.js and JWTs
Role-based access control (RBAC) is a popular mechanism used to enforce access restrictions in applications based on user roles and permissions. There are various methods available to implement the RBAC mechanism.
Two popular approaches include using dedicated RBAC libraries like AcessControl or leveraging existing authentication libraries to implement the mechanism.
In this case, JSON Web Tokens (JWTs) provide a secure way to transmit authentication credentials, while Passport.js simplifies the authentication process by providing flexible authentication middleware.
Using this approach, you can assign roles to users and encode them in the JWT when they authenticate. You can then use the JWT to verify the user's identity and roles in subsequent requests, allowing for role-based authorization and access control.
Both approaches have their advantages and can be effective in implementing RBAC. The choice between which method to implement will depend on your project's specific requirements.
You can download this project's code from its GitHub repository.
Set Up an Express.js Project
To get started, set up an Express.js project locally. Once you set up the project, go ahead, and install these packages:
npm install cors dotenv mongoose cookie-parser jsonwebtoken mongodb \
passport passport-local
Next, create a MongoDB database or set up a cluster on MongoDB Atlas. Copy the database connection URI and add it to a .env file in your project’s root directory of your project:
CONNECTION_URI="connection URI"
Configure the Database Connection
In the root directory, create a new utils/db.js file, and add the code below to establish the connection to the MongoDB cluster running on Atlas using Mongoose.
const mongoose = require('mongoose');
const connectDB = async () => {
try {
await mongoose.connect(process.env.CONNECTION_URI);
console.log("Connected to MongoDB!");
} catch (error) {
console.error("Error connecting to MongoDB:", error);
}
};
module.exports = connectDB;
Define the Data Model
In the root directory, create a new model/user.model.js file, and add the following code to define a data model for users' data using Mongoose.
const mongoose = require('mongoose');
const userSchema = new mongoose.Schema({
username: String,
password: String,
role: String
});
module.exports = mongoose.model('User', userSchema);
Create the Controller for the API Endpoints
Create a new controllers/user.controller.js file in the root directory and add the code below.
First, make these imports:
const User = require('../models/user.model');
const passport = require('passport');
const { generateToken } = require('../middleware/auth');
require('../middleware/passport')(passport);
Next, define the logic to manage user registration and login functionality:
exports.registerUser = async (req, res) => {
const { username, password, role } = req.body;
try {
await User.create({ username, password, role });
res.status(201).json({ message: 'User registered successfully' });
} catch (error) {
console.log(error);
res.status(500).json({ message: 'An error occurred!' });
}
};
exports.loginUser = (req, res, next) => {
passport.authenticate('local', { session: false }, (err, user, info) => {
if (err) {
console.log(err);
return res.status(500).json({
message: 'An error occurred while logging in'
});
}
if (!user) {
return res.status(401).json({
message: 'Invalid login credentials'
});
}
req.login(user, { session: false }, (err) => {
if (err) {
console.log(err);
return res.status(500).json({
message: 'An error occurred while logging in'
});
}
const { _id, username, role } = user;
const payload = { userId: _id, username, role };
const token = generateToken(payload);
res.cookie('token', token, { httpOnly: true });
return res.status(200).json({ message: 'Login successful' });
});
})(req, res, next);
};
The registerUser function handles the registration of a new user by extracting the username, password, and role from the request body. It then creates a new user entry in the database and responds with a success message or an error if any occurs during the process.
On the other hand, the loginUser function facilitates user login by utilizing the local authentication strategy provided by Passport.js. It authenticates the user's credentials and returns a token upon successful login which is then stored in a cookie for subsequent authenticated requests. If any errors occur during the login process, it will return an appropriate message.
Finally, add the code that implements the logic fetching all users' data from the database. We'll use this endpoint as the restricted route to ensure that only authorized users with the role of admin can access this endpoint.
exports.getUsers = async (req, res) => {
try {
const users = await User.find({});
res.json(users);
} catch (error) {
console.log(error);
res.status(500).json({ message: 'An error occurred!' });
}
};
Set Up a Passport.js Local Authentication Strategy
To authenticate users after they provide their login credentials, you need to set up a local authentication strategy.
Create a new middleware/passport.js file in the root directory and add the following code.
const LocalStrategy = require('passport-local').Strategy;
const User = require('../models/user.model');
module.exports = (passport) => {
passport.use(
new LocalStrategy(async (username, password, done) => {
try {
const user = await User.findOne({ username });
if (!user) {
return done(null, false);
}
if (user.password !== password) {
return done(null, false);
}
return done(null, user);
} catch (error) {
return done(error);
}
})
);
};
This code defines a local passport.js strategy to authenticate users based on their provided username and password.
At first, it queries the database to find a user with a matching username and then proceeds to validate their password. Consequently, it returns the authenticated user object if the login process is successful.
Create a JWT Verification Middleware
Inside the middleware directory, create a new auth.js file, and add the following code to define a middleware that generates and verifies JWTs.
const jwt = require('jsonwebtoken');
const secretKey = process.env.SECRET_KEY;
const generateToken = (payload) => {
const token = jwt.sign(payload, secretKey, { expiresIn: '1h' });
return token;
};
const verifyToken = (requiredRole) => (req, res, next) => {
const token = req.cookies.token;
if (!token) {
return res.status(401).json({ message: 'No token provided' });
}
jwt.verify(token, secretKey, (err, decoded) => {
if (err) {
return res.status(401).json({ message: 'Invalid token' });
}
req.userId = decoded.userId;
if (decoded.role !== requiredRole) {
return res.status(403).json({
message: 'You do not have the authorization and permissions to access this resource.'
});
}
next();
});
};
module.exports = { generateToken, verifyToken };
The generateToken function creates a JWT with a specified expiration time, while the verifyToken function checks if the token is present and valid. In addition, it also verifies that the decoded token contains the required role, essentially, ensuring that only users with the authorized role and permissions have access.
To uniquely sign the JWTs, you need to generate a unique secret key and add it to your .env file as shown below.
SECRET_KEY="This is a sample secret key."
Define the API Routes
In the root directory, create a new folder and name it routes. Inside this folder, create a new userRoutes.js, and add the following code.
const express = require('express');
const router = express.Router();
const userControllers = require('../controllers/userController');
const { verifyToken } = require('../middleware/auth');
router.post('/api/register', userControllers.registerUser);
router.post('/api/login', userControllers.loginUser);
router.get('/api/users', verifyToken('admin'), userControllers.getUsers);
module.exports = router;
This code defines the HTTP routes for a REST API. The users route specifically, servers as the protected route. By limiting access to users with the admin role, you effectively enforce role-based access control.
Update the Main Server File
Open your server.js file and update it as follows:
const express = require('express');
const cors = require('cors');
const cookieParser = require('cookie-parser');
const app = express();
const port = 5000;
require('dotenv').config();
const connectDB = require('./utils/db');
const passport = require('passport');
require('./middleware/passport')(passport);
connectDB();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cors());
app.use(cookieParser());
app.use(passport.initialize());
const userRoutes = require('./routes/userRoutes');
app.use('/', userRoutes);
app.listen(port, () => {
console.log(`Server is running on port ${port}`);
});
Finally, start the development server to run the application.
node server.js
Leverage RBAC Mechanism to Elevate Your Authentication Systems
Implementing role-based access control is an effective way to enhance the security of your applications.
While incorporating existing authentication libraries to establish an efficient RBAC system is a great approach, leveraging RBAC libraries to explicitly define user roles and assign permissions provides an even more robust solution, ultimately, enhancing the overall security of your application.