Node.js Authentication and Authorization with JWT: Building a Secure Web Application
Taiwo Shobo
Posted on October 18, 2023
Table of Contents
- Introduction
- Prerequisites
- Set up file structure
- Install the necessary dependencies
- Set up the database
- Set up express server
- Create the user model
- Create Joi schema for data validation
- Create auth middleware
- Create the controller
- Create the routes
- Serve the express application
-
Test with Postman
- 13.1 Create request
- Conclusion
Introduction
What is JSON Web Token (JWT)
JWT(JSON Web Token) is a token format. It is self-contained and signed. It offers a practical method of data transfer. While JWT is not secure, using it can ensure message authenticity as long as you can verify the payload's integrity and confirm the signature. Stateless authentication using JWT is often used in simple cases involving non-complex systems.
In this article, we’ll be implementing authentication with JWT in a NodeJS web application.
Here’s the example of JWT
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImUzMTgyZmEzLTYwOGEtNDUwMC04MDMzLTU2YWE4ODIyZDNhMiIsImlhdCI6MTY5NzQ5OTMzMywiZXhwIjoxNjk4MTA0MTMzfQ.Hl9HJlpBSxsIbHUwIWen90HwyxbIBlwIABaZiXUuv4s
In this tutorial, we will learn how to build authenticated and authorized applications in Nodejs. Check out the final repository on GitHub
Prerequisites
- Basic knowledge of Javascript and ES6 syntax
- Installation of Nodejs on your system
- Basic knowledge of MongoDB
- Installation of Postman on your system
Set up file structure
Create a folder anywhere on your computer and name it
Open it in any text editor of your choice (Am using VS Code to open mine), open the terminal and run:
npm init -y
Create files and directories using this command below, assuming you have installed Git on your system:
mkdir config models routes middleware controllers validators
touch config/database.js models/user.model.js routes/user.route.js middleware/auth.js controllers/user.controller.js validators/user.validator.js
Also, create the server file, the environment variable and the gitignore file in your root directory:
touch server.js .env .gitignore
Go ahead to setup a file structure like this below:
Install the necessary dependencies
Our project requires multiple npm packages. Below, you'll find a list of these packages along with brief explanations of how each one contributes to our objectives.
- Express.js: A node.js framework called Express.js makes web application development simple.
MongoDB: The official MongoDB driver for Node.js is Mongodb.
Mongoose: mongoose is an object modeling tool designed to function in an asynchronous mongoose setting. To create database schemas and communicate with the database, we will use Mongoose.
Bcryptjs: Hash user passwords before putting them in the database.
JSON Web Token (JWT): "We will use JWT for permission and authentication."
Joi: Joi is JavaScript's most potent schema description language and data validator. With the aid of this package, you may create secure routes only accessible by logged-in users.
Nodemon: Nodemon will restart the express server whenever we modify our code.
UUID: The UUID package offers tools for creating standard UUIDs that are cryptographically safe.
Dotenv: This zero-dependency module loads environment variables into a process by reading them from the .env file.
-
Cookie-parser: A middleware that parses cookies associated with the client request object.
npm install express mongoose bcryptjs jsonwebtoken joi uuid dotenv cookie-parser
Add the development dependencies with this command:
npm install nodemon -D
Set up the database
In this tutorial, we will use the MongoDB atlas for our database. Head over to MongoDB Atlas and click on Start free to create an account. After creating an account, MongoDB requires extra configuration. For more details, see the official documentation
// config/database.js
const mongoose = require('mongoose')
exports.connectDB = async () => {
try {
await mongoose.connect(process.env.DB_ONLINE_URI)
console.log(`Connected to database`)
} catch (error) {
console.log(error.message)
}
}
This code exports a function connectDB that connects to a MongoDB database using Mongoose. It uses an environment variable to retrieve the database URI and logs a success message if the connection is established, or logs any encountered error messages.
Set up express server
const express = require('express')
const { connectDB } = require('./config/database')
const dotenv = require('dotenv').config()
const cookieParser = require('cookie-parser')
const PORT = process.env.PORT
// Instantiating the mongodb database
connectDB()
// Instantiating the express application
const server = express()
server.get('/', (req, res) => {
return res.json({
message: 'This the Home page',
})
})
// Express inbuilt middleware
server.use(express.json()) // Used in passing application/json data
server.use(express.urlencoded({ extended: true})) // Used in passing form
server.use(cookieParser()) // Used in setting the cookies parser
// Creating the server
server.listen(PORT, () => console.log('Server is running on port ' + PORT))
- The code actively sets up an Express web server.
- Define a simple route for the root URL
- Configures Express to use various built-in middleware.
- Connects to a MongoDB database using the connectDB function.
- The server actively listens on the port specified by the PORT environment variable.
Create the user model
We’ll define our schema for the user details when signing up for the first time. We will be using mongoose to create UserSchema.
Add the following snippet to user.model.js
inside the model
folder.
const mongoose = require('mongoose')
const { v4 } = require('uuid')
const bcrypt = require('bcryptjs')
const jwt = require('jsonwebtoken')
const { Schema, model } = mongoose
const userSchema = new Schema(
{
_id: { type: String, default: v4 },
name: {
type: String,
required: [true, 'Please provide your name'],
},
email: {
type: String,
required: [true, 'Please provide a valid email'],
},
password: {
type: String,
required: [true, 'Please provide a password'],
select: false,
},
role: {
type: String,
enum: {
values: ['user', 'author', 'contributor'],
message: 'Please select your role',
},
default: 'user',
},
},
{
timestamps: true,
}
)
userSchema.pre('save', function (next) {
if (this.isModified('password')) {
this.password = bcrypt.hashSync(this.password, 12)
}
next()
})
userSchema.methods.comparePassword = async function (enterPassword) {
return bcrypt.compareSync(enterPassword, this.password)
}
userSchema.methods.jwtToken = function () {
const user = this
return jwt.sign({ id: user._id }, 'random string', {
expiresIn: '1h',
})
}
const User = model('User', userSchema)
module.exports = User
Define a mongoose schema and model:
The code defines a Mongoose schema for a user object. The schema specifies the structure of a user document in the MongoDB database. Key fields in the schema include _id, name, email, password, and role. The _id field is automatically generated using the uuid. Some fields have validation rules like required and enum.
Middleware for password hashing:
The middleware function is registered using userSchema.pre('save', ...). This function is called before saving a user document to the database. It checks if the password field has been modified (e.g., during user registration or when changing a password) and then hashes the password using bcrypt.
Custom method for user model:
The schema defines two custom methods that can be called on user documents:
- comparePassword: This method is used to compare a plaintext password (provided by a user during login) with the hashed password stored in the database. It uses bcrypt.compareSync for this purpose.
- jwtToken: This method generates a JSON Web Token (JWT) for the user, typically used for user authentication. It signs a payload containing the user's _id and sets an expiration time of 1 hour.
This model represents the "User" collection in the MongoDB database.
Finally, the User model is exported for use in other parts of the application. This allows other parts of the code to interact with the MongoDB database using the defined schema and model.
Create Joi schema for data validation
We'll use Joi schema to validate the data our users send. We'll create a function that takes user data as a parameter for validation.
const Joi = require('joi')
const userSignUp = Joi.object({
name: Joi.string().min(4).max(60).required(),
email: Joi.string()
.email({ minDomainSegments: 2, tlds: { allow: ['com', 'net'] } })
.required(),
password: Joi.string()
.pattern(
new RegExp(
/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[$@$!#.])[A-Za-z\d$@$#!%*?&.]{8,40}/
),
{
name: 'At least one uppercase, one lowercase, one special character, and minimum of 8 and maximum of 40 characters',
}
)
.required(),
role: Joi.string().valid('user', 'author', 'contributor').required(),
})
const loginUser = Joi.object({
email: Joi.string()
.email({ minDomainSegments: 2, tlds: { allow: ['com', 'net'] } })
.required(),
password: Joi.string()
.pattern(
new RegExp(
/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[$@$!#.])[A-Za-z\d$@$#!%*?&.]{8,40}/
),
{
name: 'At least one uppercase, one lowercase, one special character, and minimum of 8 and maximum of 40 characters',
}
)
.required(),
})
exports.validateUserSignup = (data) => {
const { err, value } = userSignUp.validateAsync(data)
return { err: err, value }
}
exports.validateUserLogin = (data) => {
const { err, value } = loginUser.validateAsync(data)
return { err: err, value }
}
The above code defines two Joi validation schemas, userSignUp and loginUser, to validate user sign-up and login data. The validateUserSignup and validateUserLogin functions validate user input against these schemas and return validation results or errors.
Create auth middleware
Middleware is a software/ piece of code that acts as a bridge between the database and the application, especially on a network. For the case of this project, we want to ensure that when a request is sent to the server, some code(middleware) is run before the request hits the server and returns a response. We want to check if a person who is trying to access a specific resource is authorized to access it.
// middleware/auth.js
const jwt = require('jsonwebtoken')
const User = require('../models/user.model')
exports.isAuthenticated = async (req, res, next) => {
let token
if (
req.headers.authorization &&
req.headers.authorization.startsWith('Bearer')
) {
token = req.headers.authorization.split(' ')[1]
}
if (!token) {
return res.status(401).json({ message: 'User not authorized' })
}
const decoded = jwt.verify(token, 'random string')
req.user = await User.findById(decoded.id)
next()
}
Create the controller
// controllers/user.controller.js
const User = require('../models/user.model')
const {
validateUserSignup,
validateUserLogin,
} = require('../validators/user.validator')
exports.createUser = async (req, res) => {
try {
const { err } = validateUserSignup(req.body) // Validate the information from the request body
if (err) return res.status(400).json({ message: err.message })
const userExist = await User.findOne({ email: req.body.email }) // Checking if the user exist
if (userExist) return res.status(400).json({ message: 'User exist' })
const { name, email, password, role } = req.body
const user = await User.create({ name, email, password, role }) //Creating the user
if (!user) return res.status(400).json({ message: 'Cannot create user' })
const token = await user.jwtToken()
const options = {
expiresIn: 3000,
httpOnly: true,
}
return res.status(200).cookies('token', token, options).json({
message: 'Signup successful',
token,
})
} catch (error) {
console.log('Unable to create a User')
}
}
exports.loginUser = async (req, res) => {
try {
const { err } = validateUserLogin(req.body)
if (err) return res.status(400).json({ message: err.message }) // Validate the users input
// find the email of the user
const user = await User.findOne({ email: req.body.email }).select(
'+password'
)
// console.log(user)
const isMatched = await user.comparePassword(req.body.password)
if (!isMatched)
return res.status(400).json({ message: 'Incorrect password or email' })
const token = await user.jwtToken()
const options = {
httpOnly: true,
}
return res.status(200).cookie('token', token, options).json({
message: 'Login successful',
token,
})
} catch (error) {
console.log(error.message)
}
}
exports.userProfile = async (req, res, next) => {
const user = await User.findById(req.user.id)
if (!user) return res.status(200).json({ message: 'User not found' })
return res.status(200).json({ message: 'Successfully', data: user })
}
exports.logOut = async (req, res) => {
try {
res.cookie('token', 'none', {
expires: new Date(Date.now()),
})
return res
.status(200)
.json({ success: true, message: 'User is logout successfully' })
} catch (error) {
console.log(error.message)
}
}
The above code actively handles user registration, login, user profile retrieval, and user logout. It incorporates input validation using Joi schemas and interacts with the "User" model and previously defined JWT-related functions.
Create the routes
It’s finally time to create our different routes. Below is the list of endpoints we shall be creating.
const express = require('express')
const {
createUser,
loginUser,
userProfile,
logOut,
} = require('../controllers/user.controller')
const { isAuthenticated } = require('../middleware/auth')
const router = express.Router()
// Routes created
router.post('/register', createUser)
router.post('/login', loginUser)
router.get('/me', isAuthenticated, userProfile)
router.post('/logout', isAuthenticated, logOut)
module.exports = router
Serve the express application
const express = require('express')
const { connectDB } = require('./config/database')
const dotenv = require('dotenv').config()
const cookieParser = require('cookie-parser')
const PORT = process.env.PORT
// Instantiating the mongodb database
connectDB()
// Instantiating the express application
const server = express()
server.get('/', (req, res) => {
return res.json({
message: 'This the Home page',
})
})
// Importing our routes
const user = require('./routes/user.route')
// Express Inbuilt middleware
server.use(express.json()) // Used in passing application/json data
server.use(express.urlencoded({ extended: false })) // Used in passing form
server.use(cookieParser()) // Used in setting the cookies parser
// Routes for API
server.use('/api/v1', user)
// Creating the server
server.listen(PORT, () => console.log('Server is running on port ' + PORT))
Now you just need to run the project by using the following command
npm run start
Test with Postman
Open Postman and create a post request to http://localhost:4000/api/v1/register as below:
Create request
Create a get request to http://localhost:4000/api/v1/me as below; Copy the jwt generated and paste in the authorization header to access the user profile
Remove or delete the token from the header, you will get this:
Conclusion
This article covered JWT, authorization, authentication, and how to create an API in Node.js that uses JWT tokens for authentication.
Posted on October 18, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
October 18, 2023