Published:

Node + Mongo - Boilerplate API with Email Sign Up, Verification, Authentication & Forgot Password

Tutorial built with Node.js and MongoDB

In this tutorial we'll cover how to build a boilerplate sign up and authentication API with Node.js and MongoDB that includes:

  • Email sign up and verification
  • Authentication and role based authorization with support for two roles (User & Admin)
  • Forgot password and reset password functionality
  • Account management (CRUD) routes with role based access control
  • Swagger api documentation route

There are no users registered in the node boilerplate api by default, in order to authenticate you must first register and verify an account. The api sends a verification email after registration with a token to verify the account. Email SMTP settings must be set in the config.json file for email to work correctly, you can create a free test account in one click at https://ethereal.email/ and copy the options below the title Nodemailer configuration.

The first user registered is assigned to the Admin role and subsequent users are assigned to the regular User role. Admins have full access to CRUD routes for managing all users, while regular users can only modify their own account.

The boilerplate api project is available on GitHub at https://github.com/cornflourblue/node-mongo-signup-verification-api.


Node + Mongo Tutorial Contents

The tutorial is organised into the following main sections:


Running the Node + Mongo API Locally

  1. Install NodeJS and NPM from  https://nodejs.org.
  2. Install MongoDB Community Server from  https://www.mongodb.com/download-center/community.
  3. Run MongoDB, instructions are available on the install page for each OS at https://docs.mongodb.com/manual/administration/install-community/
  4. Download or clone the project source code from https://github.com/cornflourblue/node-mongo-signup-verification-api
  5. Install all required npm packages by running npm install or npm i from the command line in the project root folder (where the package.json is located).
  6. Configure SMTP settings for email within the smtpOptions property in the /src/config.json file. For testing you can create a free account in one click at https://ethereal.email/ and copy the options below the title Nodemailer configuration.
  7. Start the api by running npm start (or npm run start-dev to start with nodemon) from the command line in the project root folder, you should see the message Server listening on port 4000, and you can view the Swagger API documentation at http://localhost:4000/api-docs.
  8. Follow the instructions below to test with Postman or hook up with an example React application.

NOTE: Before running in production also make sure that you update the secret property in the config.json file, it is used to sign and verify JWT tokens for authentication, change it to a random string to ensure nobody else can generate a JWT with the same secret and gain unauthorized access to your api. A quick and easy way is join a couple of GUIDs together to make a long random string (e.g. from https://www.guidgenerator.com/).


Running a React App with the Node API

For full details about the boilerplate React app see the post React Boilerplate - Email Sign Up with Verification, Authentication & Forgot Password. But to get up and running quickly just follow the below steps.

  1. Download or clone the React tutorial code from https://github.com/cornflourblue/react-signup-verification-boilerplate
  2. Install all required npm packages by running npm install or npm i from the command line in the project root folder (where the package.json is located).
  3. Remove or comment out the 2 lines below the comment // setup fake backend located in the /src/index.jsx file.
  4. Start the application by running npm start from the command line in the project root folder, this will launch a browser displaying the application and it should be hooked up with the Node + Mongo API that you already have running.


Testing the Node Boilerplate API with Postman

Postman is a great tool for testing APIs, you can download it at https://www.getpostman.com/.

Below are instructions on how to use Postman to:


How to register a new account with Postman

To register a new account with the api follow these steps:

  1. Open a new request tab by clicking the plus (+) button at the end of the tabs.
  2. Change the http request method to "POST" with the dropdown selector on the left of the URL input field.
  3. In the URL field enter the address to the register route of your local API - http://localhost:4000/accounts/register
  4. Select the "Body" tab below the URL field, change the body type radio button to "raw", and change the format dropdown selector to "JSON".
  5. Enter a JSON object containing the required user properties in the "Body" textarea, e.g:
    {
        "title": "Mr",
        "firstName": "Jason",
        "lastName": "Watmore",
        "email": "[email protected]",
        "password": "my-super-secret-password",
        "confirmPassword": "my-super-secret-password",
        "acceptTerms": true
    }
  6. Click the "Send" button, you should receive a "200 OK" response with a "registration successful" message in the response body.

Here's a screenshot of Postman after the request is sent and the new user has been registered:

And this is a screenshot of the verification email received with the token to verify the account:

Back to top


How to verify an account with Postman

To verify an account with the api follow these steps:

  1. Open a new request tab by clicking the plus (+) button at the end of the tabs.
  2. Change the http request method to "POST" with the dropdown selector on the left of the URL input field.
  3. In the URL field enter the address to the authenticate route of your local API - http://localhost:4000/accounts/verify-email
  4. Select the "Body" tab below the URL field, change the body type radio button to "raw", and change the format dropdown selector to "JSON".
  5. Enter a JSON object containing the token received in the verification email (in the previous step) in the "Body" textarea, e.g:
    {
        "token": "REPLACE THIS WITH YOUR TOKEN"
    }
  6. Click the "Send" button, you should receive a "200 OK" response with a "verification successful" message in the response body.

Here's a screenshot of Postman after the request is sent and the account has been authenticated:

Back to top


How to access an account if you forgot the password

To re-enable access to an account with a forgotten password you need to submit the email address of the account to the /account/forgot-password route, the route will then send a token to the email which will allow you to reset the password of the account in the next step.

Follow these steps in Postman if you forgot the password for an account:

  1. Open a new request tab by clicking the plus (+) button at the end of the tabs.
  2. Change the http request method to "POST" with the dropdown selector on the left of the URL input field.
  3. In the URL field enter the address to the authenticate route of your local API - http://localhost:4000/accounts/forgot-password
  4. Select the "Body" tab below the URL field, change the body type radio button to "raw", and change the format dropdown selector to "JSON".
  5. Enter a JSON object containing the email of the account with the forgotten password in the "Body" textarea, e.g:
    {
        "email": "[email protected]"
    }
  6. Click the "Send" button, you should receive a "200 OK" response with the message "Please check your email for password reset instructions" in the response body.

Here's a screenshot of Postman after the request is sent and the email has been sent:

And this is a screenshot of the email received with the token to reset the password the account:

Back to top


How to reset the password of an account with Postman

To reset the password of an account with the api follow these steps:

  1. Open a new request tab by clicking the plus (+) button at the end of the tabs.
  2. Change the http request method to "POST" with the dropdown selector on the left of the URL input field.
  3. In the URL field enter the address to the authenticate route of your local API - http://localhost:4000/accounts/reset-password
  4. Select the "Body" tab below the URL field, change the body type radio button to "raw", and change the format dropdown selector to "JSON".
  5. Enter a JSON object containing the password reset token received in the email from the forgot password step, along with a new password and matching confirmPassword, into the "Body" textarea, e.g:
    {
        "token": "REPLACE THIS WITH YOUR TOKEN",
        "password": "new-super-secret-password",
        "confirmPassword": "new-super-secret-password"
    }
  6. Click the "Send" button, you should receive a "200 OK" response with a "password reset successful" message in the response body.

Here's a screenshot of Postman after the request is sent and the account password has been reset:

Back to top


How to authenticate with Postman

To authenticate an account with the api and get a JWT token follow these steps:

  1. Open a new request tab by clicking the plus (+) button at the end of the tabs.
  2. Change the http request method to "POST" with the dropdown selector on the left of the URL input field.
  3. In the URL field enter the address to the authenticate route of your local API - http://localhost:4000/accounts/authenticate
  4. Select the "Body" tab below the URL field, change the body type radio button to "raw", and change the format dropdown selector to "JSON".
  5. Enter a JSON object containing the account email and password in the "Body" textarea:
    {
        "email": "[email protected]",
        "password": "my-super-secret-password"
    }
  6. Click the "Send" button, you should receive a "200 OK" response with the account details including a JWT token in the response body, make a copy of the token value because we'll be using it in the next steps to make authenticated requests.

Here's a screenshot of Postman after the request is sent and the account has been authenticated:

Back to top


How to get a list of all accounts with Postman

To get a list of all accounts from the api follow these steps:

Note: this is an secure request that requires a JWT authentication token from the authenticate step. Restricted to admin users.

  1. Open a new request tab by clicking the plus (+) button at the end of the tabs.
  2. Change the http request method to "GET" with the dropdown selector on the left of the URL input field.
  3. In the URL field enter the address to the users route of your local API - http://localhost:4000/accounts
  4. Select the "Authorization" tab below the URL field, change the type to "Bearer Token" in the type dropdown selector, and paste the JWT token from the previous authenticate step into the "Token" field.
  5. Click the "Send" button, you should receive a "200 OK" response containing a JSON array with all of the account records in the system.

Here's a screenshot of Postman after making an authenticated request to get all accounts:

Back to top


How to update an account with Postman

To update an account with the api follow these steps:

Note: this is an secure request that requires a JWT authentication token from the authenticate step. Admin users can update any account including role, regular users are restricted to their own account and cannot update role. Omitted or empty properties are not updated.

  1. Open a new request tab by clicking the plus (+) button at the end of the tabs.
  2. Change the http request method to "PUT" with the dropdown selector on the left of the URL input field.
  3. In the URL field enter the address to the /accounts/{id} route with the id of the account you want to update, e.g - http://localhost:4000/accounts/5eb8bcc7cc532510aa9f9d41
  4. Select the "Authorization" tab below the URL field, change the type to "Bearer Token" in the type dropdown selector, and paste the JWT token from the previous authenticate step into the "Token" field.
  5. Select the "Body" tab below the URL field, change the body type radio button to "raw", and change the format dropdown selector to "JSON".
  6. Enter a JSON object in the "Body" textarea containing the properties you want to update, for example to update the first and last names:
    {
        "firstName": "Frank",
        "lastName": "Murphy"
    }
  7. Click the "Send" button, you should receive a "200 OK" response with the updated account details in the response body.

Here's a screenshot of Postman after the request is sent and the account has been updated:

Back to top


How to delete an account with Postman

To delete an account with the api follow these steps:

Note: this is an secure request that requires a JWT authentication token from the authenticate step. Admin users can delete any account, regular users are restricted to their own account.

  1. Open a new request tab by clicking the plus (+) button at the end of the tabs.
  2. Change the http request method to "DELETE" with the dropdown selector on the left of the URL input field.
  3. In the URL field enter the address to the /accounts/{id} route with the id of the account you want to delete, e.g - http://localhost:4000/accounts/5eb8bcc7cc532510aa9f9d41
  4. Select the "Authorization" tab below the URL field, change the type to "Bearer Token" in the type dropdown selector, and paste the JWT token from the previous authenticate step into the "Token" field.
  5. Click the "Send" button, you should receive a "200 OK" response with the message "Account deleted successfully" in the response body.

Here's a screenshot of Postman after the request is sent and the account has been deleted:

Back to top


Node.js Boilerplate API Project Structure

The project is structured into "feature folders" (accounts) "non-feature / shared component folders" (_helpers, _middleware). Shared component folders contain code that can be used by multiple features and other parts of the application, and are prefixed with an underscore to group them together so it's easier to see what's what at a glance.

The example only contains the single accounts feature, but this can be easily extended to handle any other feature by copying the accounts folder and following the same pattern.

 

Helpers Folder

Path: /_helpers

The helpers folder contains all the bits and pieces that don't fit into other folders but don't justify having a folder of their own.

 

Mongo Database Wrapper

Path: /_helpers/db.js

The MongoDB wrapper connects to MongoDB using Mongoose and exports an object containing all of the database model objects in the application (currently only Account). It provides an easy way to access any part of the database from a single point.

It also contains the isValidId() utility function to enable checking if an id is a valid Mongo ObjectId before attempting to run a query.

const config = require('config.json');
const mongoose = require('mongoose');
const connectionOptions = { useCreateIndex: true, useNewUrlParser: true, useUnifiedTopology: true, useFindAndModify: false };
mongoose.connect(process.env.MONGODB_URI || config.connectionString, connectionOptions);
mongoose.Promise = global.Promise;

module.exports = {
    Account: require('../accounts/account.model'),
    isValidId
};

function isValidId(id) {
    return mongoose.Types.ObjectId.isValid(id);
}
 

Role Object / Enum

Path: /_helpers/role.js

The role object defines the all the roles in the example application, I created it to use like an enum to avoid passing roles around as strings, so instead of 'Admin' and 'User' we can use Role.Admin and Role.User.

module.exports = {
    Admin: 'Admin',
    User: 'User'
}
 

Send Email Helper

Path: /_helpers/send-email.js

The send email helper is a lightweight wrapper around the nodemailer module to simplify sending emails from anywhere in the application. It is used by the account service to send account verification and password reset emails.

const nodemailer = require('nodemailer');
const config = require('config.json');

module.exports = sendEmail;

function sendEmail({ to, subject, html, from = config.emailFrom }) {
    const transporter = nodemailer.createTransport(config.smtpOptions);
    transporter.sendMail({ from, to, subject, html });
}
 

Swagger API Docs Route Handler (/api-docs)

Path: /_helpers/swagger.js

The Swagger docs route handler uses the Swagger UI Express module to serve auto-generated Swagger UI documentation based on the swagger.yaml file from the /api-docs path of the api. The route handler is bound to the /api-docs path in the main server.js file.

For more info on swagger-ui-express see https://www.npmjs.com/package/swagger-ui-express.

const express = require('express');
const router = express.Router();
const swaggerUi = require('swagger-ui-express');
const YAML = require('yamljs');
const swaggerDocument = YAML.load('./swagger.yaml');

router.use('/', swaggerUi.serve, swaggerUi.setup(swaggerDocument));

module.exports = router;
 

Express.js Middleware Folder

Path: /_middleware

The middleware folder contains Express.js middleware functions that can be used by different routes / features within the application.

 

Authorize Middleware

Path: /_middleware/authorize.js

The authorize middleware can be added to any route to restrict access to authenticated users with the specified roles. If the roles parameter is omitted (i.e. authorize()) then the route will be accessible to all authenticated users regardless of role. It is used by the accounts controller to restrict access to account CRUD routes.

The authorize function returns an array containing two middleware functions:

  • The first (expressJwt({ secret })) authenticates the request by validating the JWT token in the "Authorization" http request header. On successful authentication a user object is attached to the req object that contains the data from the JWT token, which in this case includes the user id (req.user.id).
  • The second authorizes the request by checking that the authenticated account still exists and is authorized to access the requested route based on its role.

If either authentication or authorization fails then a 401 Unauthorized response is returned.

const expressJwt = require('express-jwt');
const { secret } = require('config.json');
const db = require('_helpers/db');

module.exports = authorize;

function authorize(roles = []) {
    // roles param can be a single role string (e.g. Role.User or 'User') 
    // or an array of roles (e.g. [Role.Admin, Role.User] or ['Admin', 'User'])
    if (typeof roles === 'string') {
        roles = [roles];
    }

    return [
        // authenticate JWT token and attach user to request object (req.user)
        expressJwt({ secret }),

        // authorize based on user role
        async (req, res, next) => {
            const account = await db.Account.findById(req.user.id);

            if (!account || (roles.length && !roles.includes(account.role))) {
                // account no longer exists or role not authorized
                return res.status(401).json({ message: 'Unauthorized' });
            }

            // authentication and authorization successful
            req.user.role = account.role;
            next();
        }
    ];
}
 

Global Error Handler Middleware

Path: /_middleware/error-handler.js

The global error handler is used catch all errors and remove the need for duplicated error handling code throughout the application. It's configured as middleware in the main server.js file.

By convention errors of type 'string' are treated as custom (app specific) errors, this simplifies the code for throwing custom errors since only a string needs to be thrown (e.g. throw 'Invalid token'). Further to this if a custom error ends with the words 'not found' a 404 response code is returned, otherwise a standard 400 response is returned. See the account service for some examples of custom errors thrown by the api, errors are caught in the accounts controller for each route and passed to next(err) which passes them to this global error handler.

module.exports = errorHandler;

function errorHandler(err, req, res, next) {
    switch (true) {
        case typeof err === 'string':
            // custom application error
            const is404 = err.toLowerCase().endsWith('not found');
            const statusCode = is404 ? 404 : 400;
            return res.status(statusCode).json({ message: err });
        case err.name === 'ValidationError':
            // mongoose validation error
            return res.status(400).json({ message: err.message });
        case err.name === 'UnauthorizedError':
            // jwt authentication error
            return res.status(401).json({ message: 'Invalid Token' });
        default:
            return res.status(500).json({ message: err.message });
    }
}
 

Validate Request Middleware

Path: /_middleware/validate-request.js

The validate request middleware function validates the body of a request against a Joi schema object.

It used by schema middleware functions in controllers to validate the request against the schema for a specific route (e.g. authenticationSchema() in the accounts controller).

module.exports = validateRequest;

function validateRequest(req, next, schema) {
    const options = {
        abortEarly: false, // include all errors
        allowUnknown: true, // ignore unknown props
        stripUnknown: true // remove unknown props
    };
    const { error, value } = schema.validate(req.body, options);
    if (error) {
        next(`Validation error: ${error.details.map(x => x.message).join(', ')}`);
    } else {
        req.body = value;
        next();
    }
}
 

Accounts Feature Folder

Path: /accounts

The accounts folder contains all code that is specific to the accounts feature of the api.

 

Mongoose Account Model

Path: /accounts/account.model.js

The account model uses Mongoose to define the schema for the accounts collection in the MongoDB database. The exported Mongoose model object gives full access to perform CRUD (create, read, update, delete) operations on accounts in MongoDB, see the account service below for examples of it being used (via the db helper).

schema.set('toJSON', { ... }); configures which account properties are included when converting MongoDB records to JSON objects.

  • virtuals: true includes the Mongoose virtual id property which is a copy of the MongoDB _id property.
  • versionKey: false excludes the Mongoose version key (__v).
  • transform: function (doc, ret) { ... } removes the MongoDB _id property and password hash.
const mongoose = require('mongoose');
const Schema = mongoose.Schema;

const schema = new Schema({
    email: { type: String, unique: true, required: true },
    passwordHash: { type: String, required: true },
    title: { type: String, required: true },
    firstName: { type: String, required: true },
    lastName: { type: String, required: true },
    acceptTerms: { type: Boolean },
    role: { type: String, required: true },
    verificationToken: { type: String },
    isVerified: { type: Boolean, default: false },
    resetToken: { type: String },
    resetTokenExpiry: { type: Date },
    dateCreated: { type: Date, default: Date.now },
    dateUpdated: { type: Date }
});

schema.set('toJSON', {
    virtuals: true,
    versionKey: false,
    transform: function (doc, ret) {
        // remove these props when object is serialized
        delete ret._id;
        delete ret.passwordHash;
    }
});

module.exports = mongoose.model('Account', schema);
 

Account Service

Path: /accounts/account.service.js

The account service contains the core business logic for the account sign up, verification, authentication and forgot password flows, as well as CRUD methods for managing account data. The service encapsulates all interaction with the mongoose account model and exposes a simple set of methods which are used by the accounts controller below.

The top of the file contains the exported service object with just the method names to make it easy to see all the methods at a glance, the rest of the file contains the implementation functions followed by a few local helper functions.

const config = require('config.json');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const crypto = require("crypto");
const sendEmail = require('_helpers/send-email');
const db = require('_helpers/db');
const Role = require('_helpers/role');
const Account = db.Account;

module.exports = {
    authenticate,
    register,
    verifyEmail,
    forgotPassword,
    validateResetToken,
    resetPassword,
    getAll,
    getById,
    create,
    update,
    delete: _delete
};

async function authenticate({ email, password }) {
    const account = await Account.findOne({ email, isVerified: true });
    if (account && bcrypt.compareSync(password, account.passwordHash)) {
        // return basic details and auth token
        const token = jwt.sign({ sub: account.id, id: account.id }, config.secret);
        return { ...basicDetails(account), token };
    }
}

async function register(params, origin) {
    // validate
    if (await Account.findOne({ email: params.email })) {
        // send already registered error in email to prevent account enumeration
        return sendAlreadyRegisteredEmail(params.email, origin);
    }

    // create account object
    const account = new Account(params);

    // first registered account is an admin
    const isFirstAccount = (await Account.countDocuments({})) === 0;
    account.role = isFirstAccount ? Role.Admin : Role.User;
    account.verificationToken = generateToken();
    account.isVerified = false;

    // hash password
    if (params.password) {
        account.passwordHash = hash(params.password);
    }

    // save account
    await account.save();

    // send email
    sendVerificationEmail(account, origin);
}

async function verifyEmail({ token }) {
    const account = await Account.findOne({ verificationToken: token });
    
    if (!account) throw 'Verification failed';
    
    account.isVerified = true;
    await account.save();
}

async function forgotPassword({ email }, origin) {
    const account = await Account.findOne({ email });
    
    // always return ok response to prevent email enumeration
    if (!account) return;
    
    // create reset token that expires after 24 hours
    account.resetToken = generateToken();
    account.resetTokenExpiry = new Date(Date.now() + 24*60*60*1000).toISOString();
    account.save();

    // send email
    sendPasswordResetEmail(account, origin);
}

async function validateResetToken({ token }) {
    const account = await Account.findOne({ 
        resetToken: token,
        resetTokenExpiry: { $gt: new Date() }
    });
    
    if (!account) throw 'Invalid token';
}

async function resetPassword({ token, password }) {
    const account = await Account.findOne({ 
        resetToken: token,
        resetTokenExpiry: { $gt: new Date() }
    });
    
    if (!account) throw 'Invalid token';
    
    // update password and remove reset token
    account.passwordHash = hash(password);
    account.isVerified = true;
    account.resetToken = undefined;
    account.resetTokenExpiry = undefined;
    await account.save();
}

async function getAll() {
    const accounts = await Account.find();
    return accounts.map(x => basicDetails(x));
}

async function getById(id) {
    const account = await getAccount(id);
    return basicDetails(account);
}

async function create(params) {
    // validate
    if (await Account.findOne({ email: params.email })) {
        throw 'Email "' + params.email + '" is already registered';
    }

    const account = new Account(params);
    account.isVerified = true;

    // hash password
    if (params.password) {
        account.passwordHash = hash(params.password);
    }

    // save account
    await account.save();

    return basicDetails(account);
}

async function update(id, params) {
    const account = await getAccount(id);

    // validate
    if (account.email !== params.email && await Account.findOne({ email: params.email })) {
        throw 'Email "' + params.email + '" is already taken';
    }

    // hash password if it was entered
    if (params.password) {
        params.passwordHash = hash(params.password);
    }

    // copy params to account and save
    Object.assign(account, params);
    account.dateUpdated = Date.now();
    await account.save();

    return basicDetails(account);
}

async function _delete(id) {
    const account = await getAccount(id);
    await account.remove();
}

// helper functions

async function getAccount(id) {
    if (!db.isValidId(id)) throw 'Account not found';
    const account = await Account.findById(id);
    if (!account) throw 'Account not found';
    return account;
}

function hash(password) {
    return bcrypt.hashSync(password, 10);
}

function generateToken() {
    return crypto.randomBytes(40).toString('hex');
}

function basicDetails(account) {
    const { id, title, firstName, lastName, email, role, dateCreated, dateUpdated } = account;
    return { id, title, firstName, lastName, email, role, dateCreated, dateUpdated };
}

function sendVerificationEmail(account, origin) {
    let message;
    if (origin) {
        const verifyUrl = `${origin}/account/verify-email?token=${account.verificationToken}`;
        message = `<p>Please click the below link to verify your email address:</p>
                   <p><a href="${verifyUrl}">${verifyUrl}</a></p>`;
    } else {
        message = `<p>Please use the below token to verify your email address with the <code>/account/verify-email</code> api route:</p>
                   <p><code>${account.verificationToken}</code></p>`;
    }

    sendEmail({
        to: account.email,
        subject: 'Sign-up Verification API - Verify Email',
        html: `<h4>Verify Email</h4>
               <p>Thanks for registering!</p>
               ${message}`
    });
}

function sendAlreadyRegisteredEmail(email, origin) {
    let message;
    if (origin) {
        message = `<p>If you don't know your password please visit the <a href="${origin}/account/forgot-password">forgot password</a> page.</p>`;
    } else {
        message = `<p>If you don't know your password you can reset it via the <code>/account/forgot-password</code> api route.</p>`;
    }

    sendEmail({
        to: email,
        subject: 'Sign-up Verification API - Email Already Registered',
        html: `<h4>Email Already Registered</h4>
               <p>Your email <strong>${email}</strong> is already registered.</p>
               ${message}`
    });
}

function sendPasswordResetEmail(account, origin) {
    let message;
    if (origin) {
        const resetUrl = `${origin}/account/reset-password?token=${account.resetToken}`;
        message = `<p>Please click the below link to reset your password, the link will be valid for 1 day:</p>
                   <p><a href="${resetUrl}">${resetUrl}</a></p>`;
    } else {
        message = `<p>Please use the below token to reset your password with the <code>/account/reset-password</code> api route:</p>
                   <p><code>${account.resetToken}</code></p>`;
    }

    sendEmail({
        to: account.email,
        subject: 'Sign-up Verification API - Reset Password',
        html: `<h4>Reset Password Email</h4>
               ${message}`
    });
}
 

Express.js Accounts Controller

Path: /accounts/accounts.controller.js

The accounts controller defines all /accounts routes for the api, the route definitions are grouped together at the top of the file and the implementation functions are below. The controller is bound to the /accounts path in the main server.js file.

Routes that require authorization include the middleware function authorize() and optionally include a role parameter (e.g. authorize(Role.Admin), if a role is specified then the route is restricted to users in that role, otherwise the route is restricted to all authenticated users regardless of role. The auth logic is located in the authorize middleware.

The route functions getById, update and _delete include an extra custom authorization check to prevent non-admin users from accessing accounts other than their own. So regular user accounts (Role.User) have CRUD access to their own account but no others, and admin accounts (Role.Admin) have full CRUD access to all accounts.

Routes that require schema validation include a middleware function with the naming convention <route>Schema (e.g. authenticateSchema). Each schema validation function defines a schema for the request body using the Joi (@hapi/joi) library and calls validateRequest(req, next, schema) to ensure the request body is valid. If validation succeeds the request continues to the next middleware function (the route function), otherwise an error is returned with details of why validation failed. For more info about Joi schema validation see https://hapi.dev/module/joi/

Express is the web server used by the api, it's one of the most popular web application frameworks for Node.js. For more info see https://expressjs.com/.

const express = require('express');
const router = express.Router();
const Joi = require('@hapi/joi');
const validateRequest = require('_middleware/validate-request');
const authorize = require('_middleware/authorize')
const Role = require('_helpers/role');
const accountService = require('./account.service');

// routes
router.post('/authenticate', authenticateSchema, authenticate);
router.post('/register', registerSchema, register);
router.post('/verify-email', verifyEmailSchema, verifyEmail);
router.post('/forgot-password', forgotPasswordSchema, forgotPassword);
router.post('/validate-reset-token', validateResetTokenSchema, validateResetToken);
router.post('/reset-password', resetPasswordSchema, resetPassword);
router.get('/', authorize(Role.Admin), getAll);
router.get('/:id', authorize(), getById);
router.post('/', authorize(Role.Admin), createSchema, create);
router.put('/:id', authorize(), updateSchema, update);
router.delete('/:id', authorize(), _delete);

module.exports = router;

function authenticateSchema(req, res, next) {
    const schema = Joi.object({
        email: Joi.string().required(),
        password: Joi.string().required()
    });
    validateRequest(req, next, schema);
}

function authenticate(req, res, next) {
    accountService.authenticate(req.body)
        .then(account => account ? res.json(account) : res.status(400).json({ message: 'Email or password is incorrect' }))
        .catch(err => next(err));
}

function registerSchema(req, res, next) {
    const schema = Joi.object({
        title: Joi.string().required(),
        firstName: Joi.string().required(),
        lastName: Joi.string().required(),
        email: Joi.string().email().required(),
        password: Joi.string().min(6).required(),
        confirmPassword: Joi.string().valid(Joi.ref('password')).required(),
        acceptTerms: Joi.boolean().valid(true).required()
    });
    validateRequest(req, next, schema);
}

function register(req, res, next) {
    accountService.register(req.body, req.get('origin'))
        .then(() => res.json({ message: 'Registration successful, please check your email for verification instructions' }))
        .catch(err => next(err));
}

function verifyEmailSchema(req, res, next) {
    const schema = Joi.object({
        token: Joi.string().required()
    });
    validateRequest(req, next, schema);
}

function verifyEmail(req, res, next) {
    accountService.verifyEmail(req.body)
        .then(() => res.json({ message: 'Verification successful, you can now login' }))
        .catch(err => next(err));
}

function forgotPasswordSchema(req, res, next) {
    const schema = Joi.object({
        email: Joi.string().email().required()
    });
    validateRequest(req, next, schema);
}

function forgotPassword(req, res, next) {
    accountService.forgotPassword(req.body, req.get('origin'))
        .then(() => res.json({ message: 'Please check your email for password reset instructions' }))
        .catch(err => next(err));
}

function validateResetTokenSchema(req, res, next) {
    const schema = Joi.object({
        token: Joi.string().required()
    });
    validateRequest(req, next, schema);        
}

function validateResetToken(req, res, next) {
    accountService.validateResetToken(req.body)
        .then(() => res.json({ message: 'Token is valid' }))
        .catch(err => next(err));
}

function resetPasswordSchema(req, res, next) {
    const schema = Joi.object({
        token: Joi.string().required(),
        password: Joi.string().min(6).required(),
        confirmPassword: Joi.string().valid(Joi.ref('password')).required()
    });
    validateRequest(req, next, schema);
}

function resetPassword(req, res, next) {
    accountService.resetPassword(req.body)
        .then(() => res.json({ message: 'Password reset successful, you can now login' }))
        .catch(err => next(err));
}

function getAll(req, res, next) {
    accountService.getAll()
        .then(accounts => res.json(accounts))
        .catch(err => next(err));
}

function getById(req, res, next) {
    // users can get their own account and admins can get any account
    if (req.params.id !== req.user.id && req.user.role !== Role.Admin) {
        return res.status(401).json({ message: 'Unauthorized' });
    }

    accountService.getById(req.params.id)
        .then(account => account ? res.json(account) : res.sendStatus(404))
        .catch(err => next(err));
}

function createSchema(req, res, next) {
    const schema = Joi.object({
        title: Joi.string().required(),
        firstName: Joi.string().required(),
        lastName: Joi.string().required(),
        email: Joi.string().email().required(),
        password: Joi.string().min(6).required(),
        confirmPassword: Joi.string().valid(Joi.ref('password')).required(),
        role: Joi.string().valid(Role.Admin, Role.User).empty('').required()
    });
    validateRequest(req, next, schema);
}

function create(req, res, next) {
    accountService.create(req.body)
        .then(account => res.json(account))
        .catch(err => next(err));
}

function updateSchema(req, res, next) {
    const schemaRules = {
        title: Joi.string().empty(''),
        firstName: Joi.string().empty(''),
        lastName: Joi.string().empty(''),
        email: Joi.string().email().empty(''),
        password: Joi.string().min(6).empty(''),
        confirmPassword: Joi.string().valid(Joi.ref('password')).empty('')
    };
    
    // only admins can update role
    if (req.user.role === Role.Admin) {
        schemaRules.role = Joi.string().valid(Role.Admin, Role.User).empty('');
    }

    const schema = Joi.object(schemaRules).with('password', 'confirmPassword');
    validateRequest(req, next, schema);
}

function update(req, res, next) {
    // users can update their own account and admins can update any account
    if (req.params.id !== req.user.id && req.user.role !== Role.Admin) {
        return res.status(401).json({ message: 'Unauthorized' });
    }

    accountService.update(req.params.id, req.body)
        .then(account => res.json(account))
        .catch(err => next(err));
}

function _delete(req, res, next) {
    // users can delete their own account and admins can delete any account
    if (req.params.id !== req.user.id && req.user.role !== Role.Admin) {
        return res.status(401).json({ message: 'Unauthorized' });
    }

    accountService.delete(req.params.id)
        .then(() => res.json({ message: 'Account deleted successfully' }))
        .catch(err => next(err));
}
 

Api Config

Path: /config.json

The api config file contains configuration data for the api, it includes the connectionString to the MongoDB database, the secret used for signing and verifying JWT tokens, the emailFrom address used to send emails, and the smtpOptions used to connect and authenticate with an email server.

Configure SMTP settings for email within the smtpOptions property. For testing you can create a free account in one click at https://ethereal.email/ and copy the options below the title Nodemailer configuration.

IMPORTANT: The secret property is used to sign and verify JWT tokens for authentication, change it with your own random string to ensure nobody else can generate a JWT with the same secret to gain unauthorized access to your api. A quick and easy way is join a couple of GUIDs together to make a long random string (e.g. from https://www.guidgenerator.com/).

{
    "connectionString": "mongodb://localhost/node-mongo-signup-verification-api",
    "secret": "THIS IS USED TO SIGN AND VERIFY JWT TOKENS, REPLACE IT WITH YOUR OWN SECRET, IT CAN BE ANY STRING",
    "emailFrom": "[email protected]",
    "smtpOptions": {
        "host": "[ENTER YOUR OWN SMTP OPTIONS OR CREATE FREE TEST ACCOUNT IN ONE CLICK AT https://ethereal.email/]",
        "port": 587,
        "auth": {
            "user": "",
            "pass": ""
        }
    }
}
 

Package.json

Path: /package.json

The package.json file contains project configuration information including package dependencies which get installed when you run npm install.

The scripts section contains scripts that are executed by running the command npm run <script name>, the start script can also be run with the shortcut command npm start.

The start script starts the api normally using node, and the start-dev script starts the api in development mode using nodemon which automatically restarts the server when a file is changed.

For more info see https://docs.npmjs.com/files/package.json.

{
    "name": "node-mongo-signup-verification-api",
    "version": "1.0.0",
    "description": "NodeJS + MongoDB API for Email Sign Up with Verification, Authentication & Forgot Password",
    "license": "MIT",
    "repository": {
        "type": "git",
        "url": "https://github.com/cornflourblue/node-mongo-signup-verification-api.git"
    },
    "scripts": {
        "start": "node ./server.js",
        "start-dev": "nodemon ./server.js"
    },
    "dependencies": {
        "@hapi/joi": "^17.1.1",
        "bcryptjs": "^2.4.3",
        "body-parser": "^1.19.0",
        "cors": "^2.8.5",
        "express": "^4.17.1",
        "express-jwt": "^5.3.3",
        "jsonwebtoken": "^8.5.1",
        "mongodb": "^3.5.7",
        "mongoose": "^5.9.11",
        "nodemailer": "^6.4.6",
        "rootpath": "^0.1.2",
        "swagger-ui-express": "^4.1.4",
        "yamljs": "^0.3.0"
    },
    "devDependencies": {
        "nodemon": "^2.0.3"
    }
}
 

Server Startup File

Path: /server.js

The server.js file is the entry point into the Node api, it configures application middleware, binds controllers to routes and starts the Express web server for the api.

require('rootpath')();
const express = require('express');
const app = express();
const cors = require('cors');
const bodyParser = require('body-parser');
const errorHandler = require('_middleware/error-handler');

app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());
app.use(cors());

// api routes
app.use('/accounts', require('./accounts/accounts.controller'));

// swagger docs route
app.use('/api-docs', require('_helpers/swagger'));

// global error handler
app.use(errorHandler);

// start server
const port = process.env.NODE_ENV === 'production' ? (process.env.PORT || 80) : 4000;
app.listen(port, () => {
    console.log('Server listening on port ' + port);
});
 

Swagger API Documentation

Path: /swagger.yaml

The Swagger YAML file describes the entire Node API using the OpenAPI Specification format, it includes descriptions of all routes and HTTP methods on each route, request and response schemas, path parameters, and authentication methods.

The YAML documentation is used by the swagger.js helper to automatically generate and serve interactive Swagger UI documentation on the /api-docs route of the api. To preview the Swagger UI documentation without running the api simply copy and paste the below YAML into the swagger editor at https://editor.swagger.io/.

openapi: 3.0.0
info:
  title: Node.js Sign-up and Verification API
  description: Node.js + MongoDB - API with email sign-up, verification, authentication and forgot password
  version: 1.0.0

servers:
  - url: http://localhost:4000
    description: Local development server

paths:
  /accounts/authenticate:
    post:
      summary: Authenticate a user account and return a JWT token
      description: Accounts must be verified before authenticating.
      operationId: authenticate
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                email:
                  type: string
                  example: "[email protected]"
                password:
                  type: string
                  example: "pass123"
              required:
                - email
                - password
      responses:
        "200":
          description: Account details and a JWT authentication `token`
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    example: "5eb12e197e06a76ccdefc121"
                  title:
                    type: string
                    example: "Mr"
                  firstName:
                    type: string
                    example: "Jason"
                  lastName:
                    type: string
                    example: "Watmore"
                  email:
                    type: string
                    example: "[email protected]"
                  role:
                    type: string
                    example: "Admin"
                  dateCreated:
                    type: string
                    example: "2020-05-05T09:12:57.848Z"
                  token:
                    type: string
                    example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1ZWIxMmUxOTdlMDZhNzZjY2RlZmMxMjEiLCJpZCI6IjVlYjEyZTE5N2UwNmE3NmNjZGVmYzEyMSIsImlhdCI6MTU4ODc1ODE1N30.xR9H0STbFOpSkuGA9jHNZOJ6eS7umHHqKRhI807YT1Y"
        "400":
          description: The email or password is incorrect
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: "Email or password is incorrect"
  /accounts/register:
    post:
      summary: Register a new user account and send a verification email
      description: The first account registered in the system is assigned the `Admin` role, other accounts are assigned the `User` role.
      operationId: register
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title:
                  type: string
                  example: "Mr"
                firstName:
                  type: string
                  example: "Jason"
                lastName:
                  type: string
                  example: "Watmore"
                email:
                  type: string
                  example: "[email protected]"
                password:
                  type: string
                  example: "pass123"
                confirmPassword:
                  type: string
                  example: "pass123"
                acceptTerms:
                  type: boolean
              required:
                - title
                - firstName
                - lastName
                - email
                - password
                - confirmPassword
                - acceptTerms
      responses:
        "200":
          description: The registration request was successful and a verification email has been sent to the specified email address
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: "Registration successful, please check your email for verification instructions"
  /accounts/verify-email:
    post:
      summary: Verify a new account with a verification token received by email after registration
      operationId: verifyEmail
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                token:
                  type: string
                  example: "3c7f8d9c4cb348ff95a0b74a1452aa24fc9611bb76768bb9eafeeb826ddae2935f1880bc7713318f"
              required:
                - token
      responses:
        "200":
          description: Verification was successful so you can now login to the account
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: "Verification successful, you can now login"
        "400":
          description: Verification failed due to an invalid token
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: "Verification failed"
  /accounts/forgot-password:
    post:
      summary: Submit email address to reset the password on an account
      operationId: forgotPassword
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                email:
                  type: string
                  example: "[email protected]"
              required:
                - email
      responses:
        "200":
          description: The request was received and an email has been sent to the specified address with password reset instructions (if the email address is associated with an account)
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: "Please check your email for password reset instructions"
  /accounts/validate-reset-token:
    post:
      summary: Validate the reset password token received by email after submitting to the /accounts/forgot-password route
      operationId: validateResetToken
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                token:
                  type: string
                  example: "3c7f8d9c4cb348ff95a0b74a1452aa24fc9611bb76768bb9eafeeb826ddae2935f1880bc7713318f"
              required:
                - token
      responses:
        "200":
          description: Token is valid
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: "Token is valid"
        "400":
          description: Token is invalid
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: "Invalid token"
  /accounts/reset-password:
    post:
      summary: Reset the password for an account
      operationId: resetPassword
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                token:
                  type: string
                  example: "3c7f8d9c4cb348ff95a0b74a1452aa24fc9611bb76768bb9eafeeb826ddae2935f1880bc7713318f"
                password:
                  type: string
                  example: "newPass123"
                confirmPassword:
                  type: string
                  example: "newPass123"
              required:
                - token
                - password
                - confirmPassword
      responses:
        "200":
          description: Password reset was successful so you can now login to the account with the new password
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: "Password reset successful, you can now login"
        "400":
          description: Password reset failed due to an invalid token
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: "Invalid token"
  /accounts:
    get:
      summary: Get a list of all accounts
      description: Restricted to admin users.
      operationId: getAllAccounts
      security:
        - bearerAuth: []
      responses:
        "200":
          description: An array of all accounts
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: string
                      example: "5eb12e197e06a76ccdefc121"
                    title:
                      type: string
                      example: "Mr"
                    firstName:
                      type: string
                      example: "Jason"
                    lastName:
                      type: string
                      example: "Watmore"
                    email:
                      type: string
                      example: "[email protected]"
                    role:
                      type: string
                      example: "Admin"
                    dateCreated:
                      type: string
                      example: "2020-05-05T09:12:57.848Z"
                    dateUpdated:
                      type: string
                      example: "2020-05-08T03:11:21.553Z"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
    post:
      summary: Create a new account
      description: Restricted to admin users.
      operationId: createAccount
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title:
                  type: string
                  example: "Mr"
                firstName:
                  type: string
                  example: "Jason"
                lastName:
                  type: string
                  example: "Watmore"
                email:
                  type: string
                  example: "[email protected]"
                password:
                  type: string
                  example: "pass123"
                confirmPassword:
                  type: string
                  example: "pass123"
                role:
                  type: string
                  enum: [Admin, User]
              required:
                - title
                - firstName
                - lastName
                - email
                - password
                - confirmPassword
                - role
      responses:
        "200":
          description: Account created successfully, verification is not required for accounts created with this endpoint. The details of the new account are returned.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    example: "5eb12e197e06a76ccdefc121"
                  title:
                    type: string
                    example: "Mr"
                  firstName:
                    type: string
                    example: "Jason"
                  lastName:
                    type: string
                    example: "Watmore"
                  email:
                    type: string
                    example: "[email protected]"
                  role:
                    type: string
                    example: "Admin"
                  dateCreated:
                    type: string
                    example: "2020-05-05T09:12:57.848Z"
        "400":
          description: Email is already registered
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: "Email '[email protected]' is already registered"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
  /accounts/{id}:
    parameters:
      - in: path
        name: id
        description: Account id
        required: true
        example: "5eb12e197e06a76ccdefc121"
        schema:
          type: string
    get:
      summary: Get a single account by id
      description: Admin users can access any account, regular users are restricted to their own account.
      operationId: getAccountById
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Details of the specified account
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    example: "5eb12e197e06a76ccdefc121"
                  title:
                    type: string
                    example: "Mr"
                  firstName:
                    type: string
                    example: "Jason"
                  lastName:
                    type: string
                    example: "Watmore"
                  email:
                    type: string
                    example: "[email protected]"
                  role:
                    type: string
                    example: "Admin"
                  dateCreated:
                    type: string
                    example: "2020-05-05T09:12:57.848Z"
                  dateUpdated:
                    type: string
                    example: "2020-05-08T03:11:21.553Z"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
    put:
      summary: Update an account
      description: Admin users can update any account including role, regular users are restricted to their own account and cannot update role.
      operationId: updateAccount
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title:
                  type: string
                  example: "Mr"
                firstName:
                  type: string
                  example: "Jason"
                lastName:
                  type: string
                  example: "Watmore"
                email:
                  type: string
                  example: "[email protected]"
                password:
                  type: string
                  example: "pass123"
                confirmPassword:
                  type: string
                  example: "pass123"
                role:
                  type: string
                  enum: [Admin, User]
      responses:
        "200":
          description: Account updated successfully. The details of the updated account are returned.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    example: "5eb12e197e06a76ccdefc121"
                  title:
                    type: string
                    example: "Mr"
                  firstName:
                    type: string
                    example: "Jason"
                  lastName:
                    type: string
                    example: "Watmore"
                  email:
                    type: string
                    example: "[email protected]"
                  role:
                    type: string
                    example: "Admin"
                  dateCreated:
                    type: string
                    example: "2020-05-05T09:12:57.848Z"
                  dateUpdated:
                    type: string
                    example: "2020-05-08T03:11:21.553Z"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
    delete:
      summary: Delete an account
      description: Admin users can delete any account, regular users are restricted to their own account.
      operationId: deleteAccount
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Account deleted successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: "Account deleted successfully"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "401":
          $ref: "#/components/responses/UnauthorizedError"

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
  responses:
    UnauthorizedError:
      description: Access token is missing or invalid, or the user does not have access to perform the action
      content:
        application/json:
          schema:
            type: object
            properties:
              message:
                type: string
                example: "Invalid Token"
    NotFoundError:
      description: Not Found
      content:
        application/json:
          schema:
            type: object
            properties:
              message:
                type: string
                example: "Not Found"

 

Subscribe or Follow Me For Updates

Subscribe to my YouTube channel or follow me on Twitter or GitHub to be notified when I post new content.

 


Supported by