DEV Community

Cover image for Node.js Authentication and Authorization with JWT: Building a Secure Web Application
Taiwo Shobo
Taiwo Shobo

Posted on

Node.js Authentication and Authorization with JWT: Building a Secure Web Application

Table of Contents

  1. Introduction
  2. Prerequisites
  3. Set up file structure
  4. Install the necessary dependencies
  5. Set up the database
  6. Set up express server
  7. Create the user model
  8. Create Joi schema for data validation
  9. Create auth middleware
  10. Create the controller
  11. Create the routes
  12. Serve the express application
  13. Test with Postman
  14. 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

Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

Create files and directories using this command below, assuming you have installed Git on your system:

mkdir config models routes middleware controllers validators
Enter fullscreen mode Exit fullscreen mode
touch config/database.js models/user.model.js routes/user.route.js middleware/auth.js controllers/user.controller.js validators/user.validator.js
Enter fullscreen mode Exit fullscreen mode

Also, create the server file, the environment variable and the gitignore file in your root directory:

touch server.js .env .gitignore
Enter fullscreen mode Exit fullscreen mode

Go ahead to setup a file structure like this below:

Image description

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 
Enter fullscreen mode Exit fullscreen mode

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode
  • 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

Enter fullscreen mode Exit fullscreen mode

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 }
}
Enter fullscreen mode Exit fullscreen mode

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()
}

Enter fullscreen mode Exit fullscreen mode

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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:

Image description

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

Image description

Remove or delete the token from the header, you will get this:

Image description

Conclusion

This article covered JWT, authorization, authentication, and how to create an API in Node.js that uses JWT tokens for authentication.

Top comments (3)

Collapse
 
dotenv profile image
Dotenv

💛🌴

Collapse
 
yusu911 profile image
Yusuf

Impressive write up Taiwo. We'll done.

Collapse
 
thatprivacyguy profile image
Alexander Hanff • Edited

You have an error in your createUser function due to using .cookies instead of .cookie:

   return res.status(200).cookies("token", token, options).json({
      message: "Signup successful",
      token,
    });

Enter fullscreen mode Exit fullscreen mode

Should be:

   return res.status(200).cookie("token", token, options).json({
      message: "Signup successful",
      token,
    });

Enter fullscreen mode Exit fullscreen mode

This prevents the signup process from completing (it adds the user to the database but never returns the success message or jwt token).