DEV Community

Antonio
Antonio

Posted on • Updated on

Building a JavaScript Auth system using TDD (part 2)

This is the second part of this series about building a JavaScript authentication system using TDD. In the first part we created an Express app that exposed two endpoints for registering new users (with some validations) and login in. As we didn't sore the user details in a database, we were not able to implement a proper login validation so that's what we'll do in this article. Let's go!

Store user details in MongoDB

First thing to do is to obtain the connection details to a Mongo database. You can install it locally or you can use a Mongo Atlas instance. With either of those options we'll just need the host, database, username and password. In my case I have MongDB installed in my PC so my host and database are "127.0.0.1:27017/authSys" (I created the database with the Mongo CLI).  To keep all these details in the same place, let's create a config folder with a local.js file in it. In this file we'll export an object with the database connection details. 

/**
 * config/local.js
 * exports an object with configuration params
 */

module.exports = {
  APP_PORT: "1337",
  DB_HOST: "YOUR_MONGO_HOST/DATABASE",
  DB_USER: "MONGO_USER",
  DB_PASS: "MONGO_PASS",
  JWT_KEY: "thisIsMyJwtKeyUsedToEncodeTheTokens"
}

As you can see, I've also included the JWT key we configured in the first part of this article, which was hardcoded in ourapp.js file. Now in our app.js let's remove the the hardcoded JWT_KEY and load all our environment variables from the config file:

/**
 * app.js
 * exports an Express app as a function
 */

..................

//load ENV Variables from config file
const config = require('./config/local');
process.env.APP_PORT = config.APP_PORT;
process.env.DB_HOST = config.DB_HOST;
process.env.DB_USER = config.DB_USER;
process.env.DB_PASS = config.DB_PASS
process.env.JWT_KEY = config.JWT_KEY;
...................

Before changing anything else, let's run our tests to make sure that this change hasn't cause any damage :)

Original tests pass ok

Our app will interact with the database using the mongoose module and we'll use the bcrypt module to encrypt the user password before saving it. We can install both via NPM running npm install mongoose bcrypt.

Next we have to import the mongoose module in our app.js and pass the connection details to the connect() method, which returns a promise. In our case, we'll just log a console message to inform if the connection was successful or if it failed. If so, we'll stop our app.

/**
 * app.js
 * exports an Express app as a function
 */

..................

//interact with MongoDB
const mongoose = require('mongoose');
//compose connection details
let dbConn = "mongodb://" + process.env.DB_USER + ":" + process.env.DB_PASS + "@" + process.env.DB_HOST;
//connect to the database
mongoose.connect(dbConn, {useNewUrlParser: true}).then( () => {
  console.log('Connected to the database');
}).catch( err => {
  console.log('Error connecting to the database: ' + err);
  process.exit();
})
...................

Now if we start our app with node app.js (or npm start if we've added it to our package.json file) we'll see our app connects to the database:

app connects to the database

To make sure our user details are stored in the database, let's modify the 'User registration' test we created in the first part of the article and expect to receive the user details, which will contain the id and the date it was created:

  it('/register should return 201 and confirmation for valid input', (done) => {
    //mock valid user input
    let user_input = {
      "name": "John Wick",
      "email": "john@wick.com",
      "password": "secret"
    }
    //send /POST request to /register
    chai.request(app).post('/register').send(user_input).then(res => {
      //validate
      expect(res).to.have.status(201);
      expect(res.body.message).to.be.equal('User registered');
      
      //new validations to confirm user is saved in database
      expect(res.body.user._id).to.exist;
      expect(res.body.user.createdAt).to.exist;

      //done after all assertions pass
      done();
    }).catch(err => {
      console.log(err);
    });
  })

Now that we've added new validations, our test fail so let's fix it. In order to store the user details in our database we have to define a schema and a model which will detail the different attributes our user will have. In our case it will be the name, email and password, as these are the ones we'll send from our test, and in addition we'll save an id that will help us uniquely identify the user, and the date is was created and updated. The mongoose module we just installed contains the functions we need to define both the schema and the model. Create a new file user.js inside the api folder with the following code:

/**
 * api/user.js
 * Defines the User Schema and exports a mongoose Model
 */

const mongoose = require('mongoose');
const userSchema = mongoose.Schema({
  _id: mongoose.Schema.Types.ObjectId,
  name: {type: String, required: true},
  email: {type: String, required: true, unique: true},
  password: {type: String, required: true}

}, 
{
  timestamps: true
});

module.exports = mongoose.model('User', userSchema, 'users');

Notice that we have to define the type of field (strings and an ObjectId) and that we can also define if it's required or unique. You can find more information about that in the mongoose schema documentation.

In the first part we included a validation to make sure that all expected fields are being received and if that was ok, we returned a 201 code and a message 'User created'. Now we're going to save the user details in a User model, send it to our database and, only if it's correctly saved, we'll send the response. 

We'll have to import the mongoose module and the model we just created in our routes.js. Then use the new User() constructor and assign to the user's attributes the fields we've received in our request body. Then we'll use the save() method to store it in the database. This methods returns a Promise so if it's resolved, we'll send our response (including the user we've just created), and if it's rejected, we'll send the error details back. Our complete register route would look like this:


/**
 * /api/routes.js
 * exports an express router.
 */ 

..............................

//database
const mongoose = require('mongoose');
//import User
const User = require('./user');

router.post('/register', (req, res, next) => {
  let hasErrors = false ;
  let errors = [];
  
  if(!req.body.name){
  //validate name presence in the request
    errors.push({'name': 'Name not received'})
    hasErrors = true;
  }
  if(!req.body.email){
    //validate email presence in the request
    errors.push({'email': 'Email not received'})
    hasErrors = true;
  }
  if(!req.body.password){
    //validate password presence in the request
    errors.push({'password': 'Password not received'})
    hasErrors = true;
  }

  if(hasErrors){
    //if there is any missing field
    res.status(401).json({
      message: "Invalid input",
      errors: errors
    });

  }else{
  //if all fields are present
    //create the user with the model
    const new_user = new User({
      //assign request fields to the user attributes
      _id : mongoose.Types.ObjectId(),
      name: req.body.name,
      email: req.body.email,
      password: req.body.password
    });
    //save in the database
    new_user.save().then(saved_user => {
    //return 201, message and user details
      res.status(201).json({
        message: 'User registered',
        user: saved_user,
        errors: errors
      });
    }).catch(err => {
    //failed to save in database
      errors.push(new Error({
        db: err.message
      }))
      res.status(500).json(errors);
    })
  }

});

Now the assertions we added to our User registration test will pass.... once. If we run our tests multiple times, we'll try to store the same user each time and, as in our model we defined the email as unique, it'll throw an error if we try to store again. To avoid this happening we can just delete all users from our table before running our test suite. We can just add a before()  block at the beginning of our test.js and use the deleteMany() function of our User model:

  /**
 * test/test.js
 * All endpoint tests for the auth API
 */


...................

//import User model
const User = require('../api/user')


describe('App basic tests', () => {
  
  before( (done) => {
    //delete all users 
    User.find().deleteMany().then( res => {
      console.log('Users removed');
      done();
    }).catch(err => {
      console.log(err.message);
    });
  });

  ................

}

Another option is to delete our users after the tests. We can do it at the end in an after() block. In any case, now we can run our tests as many times as we want. 

Encrypting the password

We should always encrypt our user's passwords so in case someone access our database, they will not be able to use the details to login into our system. We can easily encrypt the passwords using the bcrypt module, which we can install with npm install bcrypt. A good test we can do to make sure we're encrypting the password is to check that the password we sent to our back end is not the same to the one we receive. Let's go ahead and add this assertion to our 'User registration' test:

/**
 * test/test.js
 * All endpoint tests for the auth API
 */

....................

it('/register should return 201 and confirmation for valid input', (done) => {
    //mock valid user input
    let user_input = {
      "name": "John Wick",
      "email": "john@wick.com",
      "password": "secret"
    }
    //send /POST request to /register
    chai.request(app).post('/register').send(user_input).then(res => {
      //validate
      expect(res).to.have.status(201);
      expect(res.body.message).to.be.equal('User registered');
      console.log(res.body.user);
      //new validations to confirm user is saved in database
      expect(res.body.user._id).to.exist;
      expect(res.body.user.createdAt).to.exist;
      //validation to confirm password is encrypted
      expect(res.body.user.password).to.not.be.eql(user_input.password);

      //done after all assertions pass
      done();
    }).catch(err => {
      console.log(err);
    });
  })

If we run our test now, it will fail with the message "AssertionError: expected 'secret' to not deeply equal 'secret'". Let's go ahead and fix this in our routes.js file. First we need to import the bcrypt module and then we need to use the hash() function before we store the details of the user in the database. As detailed in the bcrypt documentation, there are a couple different ways to hash our password. I'll use the second one which receives the password we want to hash and the number of salt rounds (I'll use 10). Then it returns the hashed password or an error in a callback function. If there are no errors, we'll just have to assign the hashed password to our User model and save it in our database as we did before. It will look like this:

/**
 * /api/routes.js
 * exports an express router.
 */ 

.........................

//to encrypt
const bcrypt = require('bcrypt');


..................
 if(hasErrors){
    //if there is any missing field
    res.status(401).json({
      message: "Invalid input",
      errors: errors
    });

  }else{
  //if all fields are present
    //encrypt user password
    bcrypt.hash(req.body.password, 10, (err, hashed_password) => {
      if(err){
        //error hashing the password
        errors.push({
          hash: err.message
        });
        return res.status(500).json(errors);
      }else{
        //if password is hashed
        //create the user with the model
        const new_user = new User({
          //assign request fields to the user attributes
          _id : mongoose.Types.ObjectId(),
          name: req.body.name,
          email: req.body.email,
          password: hashed_password
        });
        //save in the database
        new_user.save().then(saved_user => {
        //return 201, message and user details
          res.status(201).json({
            message: 'User registered',
            user: saved_user,
            errors: errors
          });
        }).catch(err => {
        //failed to save in database
          errors.push(new Error({
            db: err.message
          }))
          res.status(500).json(errors);
        })
      }
    });
  }

If we run our test now, we'd be back to green :)

Validating email and password in login

Now that we're storing our user's details in the database, we can properly validate them. In the first part of this article our login route was just checking if the email and password were hardcoded values (req.body.email == 'john@wick.com' && req.body.password == 'secret') but now we can check if the provided details matches with any of the records in our database. In addition, as we're storing the password encrypted, we'll have to use the bcrypt module again to confirm if the provided password matches with the one received in our requests. Our response will be the same so in this case, we'll not need to modify our test:

 it('should return 200 and token for valid credentials', (done) => {
    //mock invalid user input
    const valid_input = {
      "email": "john@wick.com",
      "password": "secret"
    }
    //send request to the app
    chai.request(app).post('/login')
      .send(valid_input)
        .then((res) => {
          //assertions
          expect(res).to.have.status(200);
          expect(res.body.token).to.exist;
          expect(res.body.message).to.be.equal("Auth OK");
          expect(res.body.errors.length).to.be.equal(0);
          done();
        }).catch(err => {
          console.log(err.message);
        })
  });

In the login route of our routes.js file first thing we'll do is to try to find a user with the same email to the one we're received in the request body using the findOne() method of our User model. This method receives an object with the field we're searching for and the value ({'email': req.body.email}). If we find it, we'll use the bcrypt.compare() method to validate if the password matches and, if it's valid we'll send the same response we're sending before, which includes a 200 message, an 'Auth OK' message and a token. Our login route would be like this:

/**
 * /api/routes.js
 * exports an express router.
 */ 

...................

router.post('/login', (req, res, next) => {
  let hasErrors = false ;
  let errors = [];

  //validate presence of email and password
  if(!req.body.email){
    errors.push({'email': 'Email not received'})
    hasErrors = true;
  }
  if(!req.body.password){
    errors.push({'password': 'Password not received'})
    hasErrors = true;
  }

  if(hasErrors){
  //return error code an info
    res.status(422).json({
      message: "Invalid input",
      errors: errors
    });

  }else{
  //check if credentials are valid
    //try to find user in database by email
    User.findOne({'email': req.body.email}).then((found_user, err) => {
      if(!found_user){
        //return error, user is not registered
        res.status(401).json({
          message: "Auth error, email not found"
        });
      }else{
        //validate password
        bcrypt.compare(req.body.password, found_user.password, (err, isValid) => {
          if(err){
            //if compare method fails, return error
            res.status(500).json({
              message: err.message
            }) 
          }
          if(!isValid){
            //return error, incorrect password
            res.status(401).json({
              message: "Auth error"
            }) 
          }else{
            //generate JWT token. jwt.sing() receives payload, key and opts.
            const token = jwt.sign(
              {
                email: req.body.email, 
              }, 
              process.env.JWT_KEY, 
              {
                expiresIn: "1h"
              }
            );
            //validation OK
            res.status(200).json({
              message: 'Auth OK',
              token: token,
              errors: errors
            })
          }
        });
      }
    });
  
  }
});

Now that we're able to properly store our users data and log in, let's use the token we receive upon login to access a protected route.

Using JWT to access protected routes

As usual, first thing we'll do is to define a new test. As this test will target a new endpoint, I'll create a new describe() block. We want to access the endpoint '/protected' sending a valid token and we expect to receive a 200 code, a welcome message that includes the user's name, and the user's email. In order to get a valid token we'd need to login with valid credentials so our test will have two requests: the login and the protected:

/**
 * test/test.js
 * All endpoint tests for the auth API
 */

...................
describe('Protected route', () => {

  it('should return 200 and user details if valid token provided', (done) => {
    //mock login to get token
    const valid_input = {
      "email": "john@wick.com",
      "password": "secret"
    }
    //send login request to the app to receive token
    chai.request(app).post('/login')
      .send(valid_input)
        .then((login_response) => {
          //add token to next request Authorization headers as Bearer adw3R£$4wF43F3waf4G34fwf3wc232!w1C"3F3VR
          const token = 'Bearer ' + login_response.body.token;
          chai.request(app).get('/protected')
            .set('Authorization', token)
            .then(protected_response => {
              //assertions
              expect(protected_response).to.have.status(200);
              expect(protected_response.body.message).to.be.equal('Welcome, your email is john@wick.com ');
              expect(protected_response.body.user.email).to.exist;
              expect(protected_response.body.errors.length).to.be.equal(0);

              done();
            }).catch(err => {
              console.log(err.message);
            });
        }).catch(err => {
          console.log(err.message);
        });
  })

  after((done) => {
    //stop app server
    console.log('All tests completed, stopping server....')
    process.exit();
    done();
  });

});

The request to the /login endpoint is similar to the one we send in the login test but the one we send to the /protected endpoint is a little different. We're adding our token in the  'Authorization' header using the set() method and adding 'Bearer ' to it to identify the type of authentication. As usual, this test will now fail with a 404 error as the /protected endpoint is not defined yet. Lets fix that.

Back to our routes.js let's add our /protected route and return just a basic response:

/**
 * /api/routes.js
 * exports an express router.
 */ 

.......................

router.get('/protected', (req, res, next)=> {
  res.status(200).json({
    message: 'Welcome!',
    errors: [],
  })
})

Obviously this is not checking if the token is valid so we could add that validation here but, thinking long term and, if we want to reuse this code in other projects, extracting the token validation to another file, a middleware, will be a better idea. Express middlewares are functions with access to the request and response objects and next function, which triggers the following middleware or function. You can read more about them in the express documentation. In our middleware we'll validate our token using the verify() function from jsonwebtoken and, if it's not valid we'll return an error but if it's valid we'll trigger the next function. 

/**
 * /api/middleware/check-auth.js
 * Exports an arrow funtion used as middleware by the express app.
 * Validates presence of a valid auth token in request header
 */
const jwt = require('jsonwebtoken');

module.exports = (req, res, next) => {
  try{
    //get the token from header. Remove 'Bearer ' with split()[].
    const token = req.headers.authorization.split(" ")[1];
    //verify method verifies and decodes the token
    const decoded = jwt.verify(token, process.env.JWT_KEY)
    //add userData from the JWT to the request
    req.userData = decoded;
    next();
  }catch(err){
    res.status(401).json({
      message: 'Auth failed',
    });
  }

}

This way we can attach this middleware to multiple routes in our app. For now, let's just add it to our protected route:

/**
 * /api/routes.js
 * exports an express router.
 */ 

.......................

//import check-auth middleware
const checkAuth = require('./middleware/check-auth');

router.get('/protected', checkAuth, (req, res, next)=> {
  res.status(200).json({
    message: 'Welcome, your email is ' + req.userData.email,
    user: req.userData,
    errors: [],
  })
})

And now our test should pass.

Conclusion

I hope this article helps you understand how to use the mongoose module to define schemas and store the data in our database, use the bcrypt module to encrypt passwords and validate them upon login, and how to create Express middlewares to separate the logic of our back end as I've done with the one to check the JWT. All of this while using a TDD approach to make sure that if any change in our code breaks an existing tests, we can easily identify it and fix it. 

This is just a basic authentication system that could be improved with many more validations or even change the project structure to include a controller so our routes files is simplified. I leave those improvements to you. Remember that you can find all the code of this article in the following repo.

This article was originally posted in my website. Feel free to pay me a visit and give me some feedback in the contact section.

Happy coding!


Discussion (2)

Collapse
smuschel profile image
smuschel

Interesting article, thanks a lot for that. One minor thing I noticed: the link to part one in the first paragraph doesn't work. That links to 'uf4no.com/articles/building-a-java...' but should probably link to 'uf4no.com/articles/building-a-java...'

Collapse
uf4no profile image
Antonio Author

Ops, thanks for pointing that out. Links fixed :)