Build a simple blog with Node.js, Express, MongoDB (Part 6)


At first, I want to introduce with you Lodash package https://www.npmjs.com/package/lodash

Lodash is a modern JavaScript utility library delivering modularity, performance & extras.

Why Lodash?

Lodash makes JavaScript easier by taking the hassle out of working with arrays, numbers, objects, strings, etc.
Lodash’s modular methods are great for:
  • Iterating arrays, objects, & strings
  • Manipulating & testing values
  • Creating composite functions
Read the docs https://lodash.com/docs

Apply lodash to our project

Run the following command to install lodash:
$ npm install lodash --save

We use _.pick(object, [paths]) to create an object composed of the picked object properties.

Add the following to server.js:
const _ = require('lodash');

Modify the code for POST and PATCH methods.
Change:
to:
var post = new Post(_.pick(req.body, ['title', 'content']));
and:
$set: _.pick(req.body, ['title', 'content'])

We'll also use lodash in other cases later. Now, I want to assign some tasks:

  1. Each post has the status published or unpublished (maybe more statuses in the future), default is unpublished.
    1. Only display a list of published posts for "GET /posts"
    2. Only display a specific published post for "GET /posts/:id"
  2. Only owner can use the "POST /posts", "PATCH /posts/:id" and "DELETE /posts/:id" routes.

Each post has the status published or unpublished

Add status key to Post:
status: {
  type: Number,
  enum: [0, 1],
  default: 0
}


  • enum: Array, creates a validator that checks if the value is in the given array. In the future, we will add more status to "enum"; now only 0 (unpublished) and 1 (published).
  • default: Any or function, sets a default value for the path. If the value is a function, the return value of the function is used as the default.
Update "POST /posts", "PATCH /posts/:id" routes:


then use Postman to test:

and use Robomongo to check the data:

It's the preparation for our tasks. Now, let's do them.

Display a list of published posts

It's easy, inside find(), add {status: 1}

Use Postman to test:

Display a specific published post

It's easy, inside findOne(), add "status: 1"

Use Postman to test:

Only owner can use the "POST /posts", "PATCH /posts/:id" and "DELETE /posts/:id"

To resolve this task, we will attach "X-CSRF-Token" to header when call each route.
To create and verify token we use jsonwebtoken package https://www.npmjs.com/package/jsonwebtoken
Run the following command to install:
$ npm install jsonwebtoken --save

We will create "POST /users/login" with email and password. If authenticated, we will receive the token to attach to header.
To validate email (and others in the future), we use validator package https://www.npmjs.com/package/validator
Run the following command to install:
$ npm install validator --save

The library to help you hash passwords in this tutorial is bcryptjs https://www.npmjs.com/package/bcryptjs.
Run the following command to install:
$ npm install bcrypt --save

Add the following to server.js:
const validator = require('validator');
const bcryptjs = require('bcryptjs');
const jwt = require('jsonwebtoken');


and the Schema for User:
var UserSchema = new mongoose.Schema({
  email: {
    type: String,
    trim: true,
    required: true,
    unique: true,
    minlength: 3,
    validate: {
      validator: validator.isEmail,
      message: '{VALUE} is not a valid email'
    }
  },
  salt: {
    type: String,
    trim: true,
    required: true
  },
  password: {
    type: String,
    trim: true,
    required: true,
    minlength: 6
  },
  tokens: [
    {
      token: {
        type: String,
        required: true
      },
      expired: {
        type: Number
      }
    }
  ]
});
var User = mongoose.model('User', UserSchema);


Look at the code above:
  • We use Schema, don't like the way with Post => We will use UserSchema.statics.* and Middleware in this tutorial
  • We use "validator.isEmail" to check the email valid or not
  • salt: random password salt string for each user, it means "password" field may have different values when 2 members have the same password
  • tokens field is an array, because we can login in desktop browser, mobile or desktop app, laptop, tablet, etc at the same time. Each device or browser will have the unique token string and expired time.
  • expired: we will use cronjob to delete the expired token in the future, don't care about this now.

We want to create "POST /users/login" with email and password. If authenticated, we will receive the token to attach to header. But we don't have any users to get the token now, so we create "POST /users" to create a new user (for security purposes, you can remove this route when go live or use "Http authentication" popup to prevent create a new user on your production).

Same as create a new post, we will have the following code:
app.post('/users', (req, res) => {
  var user = new User(_.pick(req.body, ['email', 'password']));
  user.save().then((doc) => {
    res.send(doc);
  }, (err) => {
    res.send('An error has been occurred while creating an user.');
  });
});


But in fact we never store password as plain text, we always store the hash, so we add before the line:
var User = mongoose.model('User', UserSchema);

the following code:
UserSchema.pre('save', function (next) {
  var user = this;
  if (user.isModified('password')) {
    bcryptjs.genSalt(10, (err, salt) => {
      user.salt = salt;
      bcryptjs.hash(user.password, salt, (err, hash) => {
        user.password = hash;
        next();
      });
    });
  } else {
    next();
  }
});


Let's open postman and test:

Open Robomongo to check:


Now, call route "POST /users/login" with email and password to get the token!
Add the following code:
UserSchema.statics.findByCredentials = function (email, password) {
  var User = this;
  return User.findOne({email}).then((user) => {
    if (!user) {
      return Promise.reject();
    }
    return new Promise((resolve, reject) => {
      bcryptjs.compare(password, user.password, (err, res) => {
        if (res) {
          resolve(user);
        } else {
          reject();
        }
      });
    });
  });
};
UserSchema.methods.generateCsrfToken = function () {
  var user = this;
  var expired = Math.floor(Date.now() / 1000) + 20 * 60;
  var token = jwt.sign({_id: user._id.toHexString(), iat: expired}, user.salt).toString();
  user.tokens.push({expired, token});
  return user.save().then(() => {
    return {token, expired};
  });
};


before:
var User = mongoose.model('User', UserSchema);

Then we can write "POST /users/login" route:
app.post('/users/login', (req, res) => {
  User.findByCredentials(req.body.email, req.body.password).then((user) => {
    user.generateCsrfToken().then((result) => {
      res.send({
        'X-CSRF-Token': result.token,
        expired: result.expired
      });
    });
  }).catch((e) => {
    res.send({
      status: 0,
      msg: 'The email address or password that you entered is not valid.'
    });
  });
});


Open postman and test:

Then open Robomongo to check:


Now, update our source code with the token to make sure only owner can use the "POST /posts", "PATCH /posts/:id" and "DELETE /posts/:id"

Add the following code:
UserSchema.statics.findByToken = function (token) {
  var User = this;
  var decoded;
  return User.findOne({'tokens.token': token}).then((user) => {
    try {
      decoded = jwt.verify(token, user.salt);
    } catch (e) {
      return Promise.reject();
    }
    return user;
  });
};


before:
var User = mongoose.model('User', UserSchema);

Add this function to server.js
var authenticate = (req, res, next) => {
  var token = req.header('X-CSRF-Token');
  User.findByToken(token).then((user) => {
    if (!user) {
      return Promise.reject();
    }
    req.user = user;
    req.token = token;
    next();
  }).catch((e) => {
    res.send({
      status: 0,
      msg: 'Invalid token.'
    });
  });
};


then update "POST /posts", "PATCH /posts/:id" and "DELETE /posts/:id"

Open Postman, then test with correct X-CSRF-Token:
and incorrect or empty X-CSRF-Token:

We will optimize code later, but now that's it.

See you then!

No comments:

Post a Comment