Dec 17, 2017

Token Based Authentication with Node.js, Express, Mongoose and Passport

This tutorial explains how to implement REST API and Token based authentication in Node.js, Express, Mongoose environment. We are going to use JWT (JSON Web Token) + bcrypt (password hashing algo)+ Passport (authentication middleware to integrate different login strategies) combination. So jsonwebtoken, bcrypt-nodejs and passport-jwt javascript libraries will be used. It is assumed you are familiar with Node.js, MongoDB and ES6 basics.

Environment:

Node 8.11.3

NPM 5.6.0

mongoose 5.2.x

express-generator 4.16.0

express 4.16.0

passport 0.4.0

passport-jwt 4.0.0

jsonwebtoken 8.3.0

bcrypt-nodejs 0.0.3

babel-cli 6.26.3

babel-preset-es2015 6.24.1

Setup New Express Application

The easiest way to setup express application is to use express generator. Let's run following command


npm install express-generator -g
express rest-auth-sample

It will create rest-auth-sample folder and generate express application in it. Let's install the package and run the application by following commands:


cd rest-auth-sample
npm install
npm start

Now on browser, http://localhost:3000 will show the default express page.

Install Packages

run the following command to install the packages:


npm install mongoose bcrypt-nodejs jsonwebtoken passport passport-jwt --save

We are going to use ES6 syntaxes so need babel, run the following commands:


npm install babel-cli babel-preset-es2015 babel-plugin-add-module-exports shx --save-dev

Setup Configuration

Add config/db.js file with following:


export default {
    'secret':'my secret',
    'database': process.env.NODE_ENV == 'test' ? 'mongodb://localhost/rest-api-test' : 'mongodb://localhost/rest-api'
  };

Models

We are going to create two models - user and project.

Create Models folder and add user.model.js


import mongoose from 'mongoose';
import bcrypt from 'bcrypt-nodejs';
const Schema = mongoose.Schema;
mongoose.set('useCreateIndex', true);

var UserSchema = new Schema({
  username: {
        type: String,
        unique: true,
        required: true
    },
  password: {
        type: String,
        required: true
    },
},
{
    timestamps: { createdAt: "created_at", updatedAt: "updated_at" } 
});

UserSchema.pre('save', function (next) {
    var user = this;
    if (this.isModified('password') || this.isNew) {
        bcrypt.genSalt(10, function (err, salt) {
            if (err) {
                return next(err);
            }
            bcrypt.hash(user.password, salt, null, function (err, hash) {
                if (err) {
                    return next(err);
                }
                user.password = hash;
                next();
            });
        });
    } else {
        return next();
    }
});

UserSchema.methods.comparePassword = function (passw, cb) {
    bcrypt.compare(passw, this.password, function (err, isMatch) {
        if (err) {
            return cb(err);
        }
        cb(null, isMatch);
    });
};


export default mongoose.model('User', UserSchema);

bcrypt is used for password hash. Similarly add following for Models/project.model.js


import mongoose from 'mongoose';
const Schema = mongoose.Schema;

const ProjectSchema = new Schema({
  title: {
    type: String,
    required: true
  },
  summary: {
    type: String,
    required: true
  },
  description: {
    type: String,
    required: true
  },
  submitDate: {
    type: Date,
    required: true
  },
  submittedBy:{
    type: String,
    required: true
  }
});

const model = mongoose.model('Project', ProjectSchema);
export const cleanCollection = () => model.deleteMany({}).exec();
export default model;

Controllers

Add a controllers/auth.controller.js file and use following code:


import config from '../config/db';
import User from "../models/user.model";
const jwt = require('jsonwebtoken');

class Auth {
  signUp(req, res) {
    if (!req.body.username || !req.body.password) {
      res.json({success: false, msg: 'Please pass username and password.'});
    } else {
      var newUser = new User({
        username: req.body.username,
        password: req.body.password
      });
      // save the user
      newUser.save((err) => {
        if (err) {
          return res.json({success: false, msg: 'Username already exists.'});
        }
        res.json({success: true, msg: 'Successful created new user.'});
      });
    }
  }
  signIn(req, res) {
    User.findOne({
      username: req.body.username
    }, (err, user) => {
      if (err) throw err;
  
      if (!user) {
        res.status(401).send({success: false, msg: 'Authentication failed. User not found.'});
      } else {
        // check if password matches
        user.comparePassword(req.body.password,  (err, isMatch) => {
          if (isMatch && !err) {
            // if user is found and password is right create a token
            var token = jwt.sign(user.toJSON(), config.secret,{ expiresIn: '30m' });
            // return the information including token as JSON
            res.json({success: true, token: 'JWT ' + token});
          } else {
            res.status(401).send({success: false, msg: 'Authentication failed. Wrong password.'});
          }
        });
      }
    });
  }

 
}

export default new Auth();

Similarly add controllers/project.controller.js file


import Project from "../models/project.model";
class ProjectController{
  async add(req, res) {    
        if (req.user && req.user.username) {          
            const newProject = new Project({
                title: req.body.title,
                summary: req.body.summary,
                description: req.body.description,
                submitDate: req.body.submitDate,
                submittedBy: req.user.username
            });
            try{ 
                let result = await newProject.save();
                res.json({success: true, msg: 'New project is created successfully.'});
            }
            catch(err){
                return res.json({success: false, msg: 'Save project failed.'});
            }
        } else {
          return res.status(403).send({success: false, msg: 'Unauthorized.'});
        }
      }

async get(req, res) { 
        if (req.user && req.user.username) {
            try{
                let projects = await Project.find({submittedBy: req.user.username}).lean().exec();
                return res.json(projects);    
            }
            catch(err){
                return next(err);
            }
        } else {
          return res.status(403).send({success: false, msg: 'Unauthorized.'});
        }
      }
}

export default new ProjectController();

Setup Passport

In config folder, add file passport.js with following code


import passport from 'passport';
import { Strategy, ExtractJwt } from "passport-jwt";
import User from '../models/user.model';
import config from '../config/db'; // get db config file

class passportManager {
    initialize(){
        var opts = {
            jwtFromRequest : ExtractJwt.fromAuthHeaderWithScheme("jwt"),
            secretOrKey : config.secret
        }
        passport.use(new Strategy(opts, function(jwt_payload, done) {
            User.findOne({id: jwt_payload.id}, function(err, user) {
                if (err) {
                    return done(err, false);
                }
                if (user) {
                    done(null, user);
                } else {
                    done(null, false);
                }
            });
        }));
        return passport.initialize();
    }
    authenticate(req, res, next){
        passport.authenticate('jwt', { session: false}, (err, user, info) => {
          if (err) { return next(err); }
          if (!user) {
              if (info.name === "TokenExpiredError") {
                  return res.status(401).json({ message: "Your token has expired." });
              } else {
                  return res.status(401).json({ message: info.message });
              }
          }
          req.user = user;
          return next();
        })(req, res, next);
      };

}
export default new passportManager();

Setup Routers

Express generators generates two files in routes folder index.js and users.js. Rename to index.route.js and users.route.js for better naming conventions.

Add auth.route.js file:


import authController from "../controllers/auth.controller";
import express from 'express';
const router = express.Router();

router.post('/signup', authController.signUp);
router.post('/signin', authController.signIn);

export default router;

Add project.route.js file:



import express from 'express';
import projectController from "../controllers/project.controller";
import passportManager from '../config/passport';
const router = express.Router();
router.route('/')
.get(passportManager.authenticate, projectController.get)
.post(passportManager.authenticate, projectController.add);

export default router;

As project routes must be accessed by authenticated users so authenticate middleware is called before controller method.

Let's add index.js file in routes folder to manage all routes file



import express from 'express';
import indexRouter from './index.route';
import usersRouter from './users.route';
import authRouter from './auth.route';
import projectRouter from './project.route';


const router = express.Router();

router.use('/', indexRouter);
router.use('/users', usersRouter);
router.use('/api/auth', authRouter);
router.use('/api/project', projectRouter);

export default router;

app.js

Update app.js generated by express generator with following:


import createError from 'http-errors';
import express from 'express';
import path from 'path';
import cookieParser from 'cookie-parser';
import logger from 'morgan';
import mongoose from 'mongoose';
import passportManager from './config/passport';
import config from './config/db';
import router from './routes';

const app = express();

//mongoose setup
mongoose.Promise = Promise;
mongoose.connect(config.database, { useNewUrlParser: true });
mongoose.connection.on('error', () => {
  throw new Error(`unable to connect to database: ${mongoUri}`);
});

// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'jade');

app.use(logger('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));

// routes setup
app.use('/',router);

// catch 404 and forward to error handler
app.use((req, res, next) =>{
  next(createError(404));
});

// error handler
app.use((err, req, res, next) =>{
  // set locals, only providing error in development
  res.locals.message = err.message;
  res.locals.error = req.app.get('env') === 'development' ? err : {};

  // render the error page
  res.status(err.status || 500);
  res.render('error');
});

app.use((req, res, next) =>{
  res.header("Access-Control-Allow-Origin", "*");
  res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
  next();
});

app.use(passportManager.initialize());

export default app;

Babel

As we are using ES6 so need to consider backwards compatible version of JavaScript in current and older browsers or environments. Thanks Babel to help us there. Babel is a transpiler (translates code in one language to another computer language at the same abstraction level) that can turn our ES6 code into ES5. We have already installed the related packages. Let's configure it.

Add .babelrc file and use following configuration


{
    "presets": ["es2015"],
    "plugins": [
      "add-module-exports"
    ]
}

In package.json, use following scripts:


  "scripts": {
    "clean": "shx rm -rf dist",
    "copy": "shx mkdir dist && shx cp -r public views dist/",
    "compile": "npm run clean && npm run copy && babel \"./{,!(dist|node_modules)/**/}*.js\" bin/www -d dist",
    "start": "npm run compile && node ./dist/bin/www"
  }

Shx provides an easy way to execute simple Unix-like, cross-platform commands in npm package scripts. Basically ES6 code is converted and put into dist folder and public client side files/folders are also copied in dist folder. dist folder is used to execute the application.

Let's run the app by following command and check REST APIs


npm run start

I am using Postman (Chrome extension) to test REST APIs.

To create new user:

REST API POSTMAN

Let's check login api:

REST API POSTMAN

On successful login, JWT token is returned. I will use it to test project APIs

Let's create a new project:

REST API POSTMAN

In the header, I am passing JWT token

REST API POSTMAN

To get list of projects:

REST API POSTMAN

If you try to use API without authorization header, you will get following response:


{
    "message": "No auth token"
}

For Invalid token, you will get following response:


{
    "message": "jwt malformed"
}

Source Code

The full source code is available on GitHub.

Conclusion

This tutorial covers:

- How to setup express based application

- How to develop REST APIs

- How to implement token based authentication using Passport, JWT and bcrypt

- How to configure ES6 application with Babel

- How to test REST APIs with Postman

If you have any suggestion or question, feel free to leave a comment below.

Enjoy Node.js!