Building ๐ Secure Node.js Authentication and Authorization with Passport and JWT
Introduction
Node.js has become a popular choice for building web applications, and securing these applications is of utmost importance. In this tutorial, we'll embark on a journey to create a robust authentication and authorization system using Passport and JSON Web Tokens (JWT) in a Node.js environment.
Prerequisites
Before we dive into the code, make sure you have the following installed:
Setting Up the Project
Make the project directory
cd Documents mkdir Node-Authorization cd Node-Authorization code .
Install dependencies and Libraries
npm init -y //initialize npm install express mongoose body-parser cors passport passport-jwt npm install jsonwebtoken bcrypt dotenv nodemon
MVC architecture to be followed
Environment Variables โ๏ธ
Environment variables are used to store sensitive information like database connection strings and secret keys. The dotenv
library is used to load these variables from a .env
file.
PORT=8000
MONG_URI= //Your mongo URL
APP_SECRET=qwertyuiop //any key of your choice
These variables are accessed using process.env
throughout the application.
Middleware
Inside your middleware folder, create a passport.js
file. This file will contain our authentication middleware.
const passport = require('passport')
const User = require('../models/user')
require('dotenv').config()
const {Strategy, ExtractJwt} = require('passport-jwt')
const opts = {
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: process.env.APP_SECRET
}
module.exports = passport => {
passport.use(
new Strategy(opts, async (payload, done) => {
await User.findById(payload.user_id)
.then(user => {
if (user) {
return done(null, user);
}
return done(null, false);
})
.catch(err => {
return done(null, false);
});
})
);
};
Importing Dependencies
passport
: Imports the Passport.js library, which is used for authentication in Node.js applications.User
: Imports the Mongoose model for the User schema, presumably defined in'../models/user'
.dotenv
: Loads environment variables from a.env
file intoprocess.env
.
Configuring Passport with JWT Strategy:
Strategy
andExtractJwt
: Destructures theStrategy
andExtractJwt
objects from thepassport-jwt
module.Strategy
is a class for handling JWT authentication strategies, andExtractJwt
is a utility for extracting JWTs from different sources.JWT Strategy options:
opts
: Defines options for the JWT strategy.jwtFromRequest
: Specifies how to extract the JWT from the incoming request. In this case, it uses the Bearer token from the Authorization header (fromAuthHeaderAsBearerToken()
).secretOrKey
: Provides the secret key used to verify the signature of the JWT. It's expected to be stored in the environment variableAPP_SECRET
.
Passport Middleware Configuration:
module.exports
: Exports a function that configures Passport with the JWT strategy.passport.use(...)
: Sets up a new instance of theStrategy
with the provided options and a callback function for handling authentication.new Strategy(opts, async (payload, done) => { ... })
: Creates a new JWT strategy.payload
: The decoded payload of the JWT, which typically contains user information.done
: A callback function to indicate whether the authentication was successful or not.The callback function attempts to find a user in the database using
User.findById(payload.user_id)
. If a user is found, it's passed todone(null, user)
, indicating successful authentication. If no user is found,done(null, false)
is called.
Starting with the server
index.js
const express = require('express')
const mongoose = require('mongoose')
const cors = require('cors')
const bodyParser = require('body-parser')
require('dotenv').config()
const userRoutes = require('./routes/users')
const passport = require('passport')
const app = express()
app.use(cors())
app.use(bodyParser.json())
app.use(passport.initialize())
require('./middlewares/passport')(passport)
app.use("/api", userRoutes)
mongoose.connect(process.env.MONG_URI)
.then(() => {
app.listen(process.env.PORT, () => {
console.log('Connected to DB and listening to port', process.env.PORT)
})
})
.catch((error) => {
console.log(error)
})
Libraries and Functions Used ๐
MongoDB and Mongoose ๐
MongoDB is a NoSQL database, and Mongoose is an ODM (Object Data Modeling) library for MongoDB and Node.js. Mongoose simplifies interactions with MongoDB and provides a schema-based solution.
Express.js ๐
Express.js is a fast, unopinionated, minimalist web framework for Node.js. It simplifies the process of building robust web applications.
dotenv ๐
dotenv is a zero-dependency module that loads environment variables from a
.env
file intoprocess.env
.body-parser and cors ๐
Explanation: body-parser is a middleware that parses incoming request bodies. cors is a middleware that enables Cross-Origin Resource Sharing.
Our index.js
file is the starting point of our server, it connects with the database (MongoDB) and also gives us the starting route.
Model-Schema ๐
In the model's folder, create a file user.js
. This file will contain our Schema for the database. We use Mongoose for the schema.
const mongoose = require('mongoose')
const Schema = mongoose.Schema
const UserSchema = new Schema({
name: {
type: String,
required: true
},
email: {
type: String,
required: true
},
role: {
type: String,
default: "user",
emu: ["user", "admin", "superadmin"]
},
username: {
type: String,
required: true
},
password: {
type: String,
required: true
}
}, {timestamps: true})
module.exports = mongoose.model('users', UserSchema)
The UserSchema
is created using the new Schema({...}, options)
syntax. This schema represents the structure of a user document in the MongoDB collection.
name
: A required field of type String, representing the user's name.email
: A required field of type String, representing the user's email address.role
: A field of type String with a default value of "user" and restricted to values specified in theenum
array, which includes "user", "admin", and "super-admin".username
: A required field of type String, representing the user's username.password
: A required field of type String, representing the user's password.
The { timestamps: true }
option automatically adds createdAt
and updatedAt
fields to the documents, recording when they were created and last updated.
UTILS
This is our utilisation or controller folder, create an auth.js
file in the folder.
const User = require('../models/user')
const jwt = require('jsonwebtoken')
const bcrypt = require('bcrypt')
require('dotenv').config()
const passport = require('passport')
const userRegister = async(userDets, role, res) => {
try {
let usernameNotTaken = await (validateUsername(userDets.username))
if(!usernameNotTaken){
return res.status(400).json({Error: "Username is already taken."})
}
let emailNotTaken = await (validateEmail(userDets.email))
if(!emailNotTaken){
return res.status(400).json({Error: "Email is already taken."})
}
const password = await bcrypt.hash(userDets.password, 12)
const newUser = new User({
...userDets,
password,
role
})
await newUser.save()
return res.status(201).json(newUser)
}
catch(err) {
console.log(err)
return res.status(500).json({Error: "Unable to create your account."})
}
}
const userLogin = async (userCreds, role, res) => {
let {username, password} = userCreds
const user = await User.findOne({username})
if(!user) {
return res.status(404).json({Error: "Username not found, invalid login creds."})
}
if(user.role != role) {
return res.status(403).json({Error: "Make sure if you are logging from the correct portal"})
}
let isMatch = await bcrypt.compare(password, user.password)
if(isMatch){
let token = jwt.sign({
user_id: user._id,
role: user.role,
username: user.username,
email: user.email
}, process.env.APP_SECRET, {expiresIn: "7 Days"})
let result = {
role: user.role,
username: user.username,
email: user.email,
token: `Bearer ${token}`,
expiresIn: 168
}
return res.status(200).json({
...result,
Message: "Logged in succesfully",
success: true
})
}
else {
return res.status(403).json({Error: "Incorrect Password."})
}
}
const userAuth = passport.authenticate('jwt', {session: false})
const checkRole = roles => (req,res,next) => {
if(roles.includes(req.user.role)){
return next()
}
return res.status(401).json({Error: "Unauthorized"})
}
const validateUsername = async username => {
let user = await User.findOne({username})
return user ? false : true
}
const validateEmail = async email => {
let user = await User.findOne({email})
return user ? false : true
}
const serializeUser = user => {
return {
username: user.username,
email: user.email,
name: user.name,
_id: user._id,
updatedAt: user.updatedAt,
createdAt: user.createdAt
}
}
module.exports = {
userRegister,
userLogin,
userAuth,
serializeUser,
checkRole
}
This code defines a set of functions related to user authentication and authorization, utilizing a MongoDB database, JWTs (JSON Web Tokens), bcrypt for password hashing, and Passport.js for authentication. Let's break down the main components:
User Registration (
userRegister
)userRegister
is an asynchronous function that registers a new user.It first checks if the username and email are available by calling
validateUsername
andvalidateEmail
functions.If both username and email are available, the user's password is hashed using bcrypt.
A new user object is created with the hashed password and other details.
The new user is saved to the MongoDB database, and the user object is returned in the response.
User Login (
userLogin
)userLogin
handles user login.It looks up the user by the provided username.
If the user is not found or the user's role does not match the expected role, it returns an error response.
It uses bcrypt to compare the provided password with the hashed password stored in the database.
If the passwords match, a JWT is generated and returned in the response, along with user details.
Passport Authentication Middleware (
userAuth
)userAuth
is a Passport.js middleware that uses the JWT strategy for authentication.It is applied to routes that require authentication, ensuring that only users with valid JWTs can access protected routes.
Role-Based Access Control Middleware (
checkRole
)checkRole
is a middleware that performs role-based access control.It takes an array of roles as an argument and checks if the authenticated user's role is included in that array.
If the user has the required role, the next middleware or route handler is called. Otherwise, an unauthorized error is returned.
Validation Functions (
validateUsername
andvalidateEmail
)validateUsername
andvalidateEmail
are utility functions that check if a username or email is already taken by querying the database.They return
false
if the username or email is already in use, indicating that the user cannot register with that username or email.
Serialization Function (
serializeUser
)serializeUser
takes a user object and returns a simplified version with selected properties.It is commonly used to format the user data before sending it as a response.
The exported module contains all these functions, providing a set of utilities for user registration, login, authentication middleware, role-based access control, and user data serialization.
Routes
In the routes folder, create a user.js
file. This file will contain all our routes which will be role specific.
const express = require('express')
const {
userRegister,
userLogin,
userAuth,
serializeUser,
checkRole
} = require('../utils/auth')
const router = express.Router()
router.post("/user/register", async (req,res) => {
await userRegister(req.body, 'user', res)
})
router.post("/admin/register", async (req,res) => {
await userRegister(req.body, 'admin', res)
})
router.post("/superadmin/register", async (req,res) => {
await userRegister(req.body, 'superadmin', res)
})
router.post("/user/login", async (req,res) => {
await userLogin(req.body, 'user', res)
})
router.post("/admin/login", async (req,res) => {
await userLogin(req.body, 'admin', res)
})
router.post("/superadmin/login", async (req,res) => {
await userLogin(req.body, 'superadmin', res)
})
router.get("/profile", userAuth, async (req,res) => {
//console.log(req.user)
return res.json(serializeUser(req.user))
})
router.get("/user/protected", userAuth, checkRole(['user']), async (req,res) => {
return res.json(serializeUser(req.user))
})
router.get("/admin/protected", userAuth, checkRole(['admin']), async (req,res) => {
return res.json(serializeUser(req.user))
})
router.get("/superadmin/protected", userAuth, checkRole(['superadmin']), async (req,res) => {
return res.json(serializeUser(req.user))
})
router.get("/superadmin-admin/protected", userAuth, checkRole(['superadmin', 'admin']), async (req,res) => {
return res.json(serializeUser(req.user))
})
module.exports = router
This code sets up an Express.js router to handle routes related to user registration, login, profile, and protected resources with different user roles. Let's break it down:
Express Router Setup
express.Router()
: This creates an instance of an Express router, which is used to define and organize routes.require('../utils/auth')
: Imports functions (userRegister
,userLogin
,userAuth
,serializeUser
,checkRole
) from a file or module located at../utils/auth
. These functions are presumably utilities related to user authentication and authorization.
User Registration Routes
router.post("/user/register", async (req, res) => { await userRegister(req.body, 'user', res); }); router.post("/admin/register", async (req, res) => { await userRegister(req.body, 'admin', res); }); router.post("/superadmin/register", async (req, res) => { await userRegister(req.body, 'superadmin', res); });
router.post
("/user/register", ...)
,router.post
("/admin/register", ...)
,router.post
("/superadmin/register", ...)
: Define POST routes for user registration with different roles (user
,admin
,superadmin
).The route handlers call the
userRegister
function with the request body, role, and response objects.
User Login Routes
router.post("/user/login", async (req, res) => { await userLogin(req.body, 'user', res); }); router.post("/admin/login", async (req, res) => { await userLogin(req.body, 'admin', res); }); router.post("/superadmin/login", async (req, res) => { await userLogin(req.body, 'superadmin', res); });
Similar to registration routes, these routes handle user login with different roles (
user
,admin
,superadmin
) using theuserLogin
function.Profile Route
router.get("/profile", userAuth, async (req, res) => { return res.json(serializeUser(req.user)); });
router.get("/profile", userAuth, ...)
: Defines a GET route for fetching the user profile.userAuth
: Middleware for authenticating users using Passport.js and JWT strategy.The route handler calls
serializeUser
to format the user data and sends it in the response.
Protected Routes Based on User Roles
router.get("/user/protected", userAuth, checkRole(['user']), async (req, res) => { return res.json(serializeUser(req.user)); }); router.get("/admin/protected", userAuth, checkRole(['admin']), async (req, res) => { return res.json(serializeUser(req.user)); }); router.get("/superadmin/protected", userAuth, checkRole(['superadmin']), async (req, res) => { return res.json(serializeUser(req.user)); }); router.get("/superadmin-admin/protected", userAuth, checkRole(['superadmin', 'admin']), async (req, res) => { return res.json(serializeUser(req.user)); });
These routes are designed to access protected resources based on user roles.
Each route is protected by the
userAuth
middleware, ensuring that only authenticated users with valid JWTs can access them.checkRole
middleware restricts access to specific roles (user
,admin
,superadmin
).
In the package.json()
file:
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "nodemon index.js"
},
Add the "dev": "nodemon index.js", this gives us an advantage in that after every run we don't have to restart our server again, nodemon takes care of it.
Output
Here, a user is registered using his credentials, and a hashed password is generated.
In this, the user is logged in and a token is generated.
This one is a /superadmin/protected
route. This is accessible to super admins only.
This is a /user/protected
router which can be accessed by a user and hence using the token as value for the Authorization
key in the header a user can access this route.
Externals
GitHub repo:
Twitter:
Youtube Demo:
Conclusion
In this tutorial, we've covered the setup of a secure authentication and authorization system using Passport and JWT in a Node.js application. The detailed explanations for authentication middleware and role-based access control should help you understand how each route is protected and who can access them.
Feel free to explore and expand on this foundation to meet the specific needs of your project.
Happy coding!