Lesson 23. Building a user login and hashing passwords

In lesson 22, you added flash messages to your controller actions and views. In this lesson, you dive deeper into the User model by creating a sign-up and login form. Then you add a layer of security to your application by hashing users’ passwords and saving your users’ login state. Next, you add some more validations at the controller level with the help of the express-validator package. By the end of this lesson, a user should be able to create an account, have their password saved securely in your database, and log in or log out as they like.

This lesson covers

  • Creating a user log-in form
  • Hashing data in your database with bcrypt
Consider this

You deliver a prototype of your recipe application in which users can create accounts and store their unencrypted passwords in your database. You’re reasonably concerned that your database might get hacked or (even more embarrassing) that you might show user passwords in plain text to all users. Luckily, security is a big concern in the programming world, and tools and security techniques are available to protect sensitive data from being exposed. bcrypt is one such tool you’ll use to mask passwords in your database so that they can’t be hacked easily in the future.

23.1. Implementing the user login form

Before you dive into the logic that will handle users logging into the recipe application, establish what their sign-up and login forms will look like.

The sign-up form will look and behave like the form in new.ejs. Because most users will create their own accounts through a sign-up form, you’ll refer to the create view and create action for new user registrations. The form you need but don’t have yet is the user login form. This form takes two inputs: email and password.

First, create a basic user login view, and connect it with a new route and controller actions. Then create a new login.ejs view in the users folder with the code from the next listing. Notice the important addition here: the /users/login action in the form tag. You need to create a route to handle POST requests to that path.

Listing 23.1. Creating a user login form in login.ejs
<form action="/users/login" method="POST">            1
  <h2>Login:</h2>
  <label for="inputEmail">Email address</label>
  <input type="email" name="email" id="inputEmail"
 placeholder="Email address" required>
  <label for="inputPassword">Password</label>
  <input type="password" name="password" id="inputPassword"
 placeholder="Password" required>
  <button type="submit">Login</button>
</form>

  • 1 Add a form for user login.

Next, add the login route by adding the code in listing 23.2 to main.js. The first route allows you to see the login form when a GET request is made to the /users/login path. The second route handles POST requests to the same path. In this case, you route the request to the authenticate action, followed by the redirectView action to load a page.

Note

You’ll want to add these routes above the lines where you have your show and edit routes; otherwise, Express.js will mistake the word login in the path for a user ID and try to find that user. When you add the route above those lines, your application will identify the full path as the login route before looking for a user ID in the URL.

Listing 23.2. Adding the login route to main.js
router.get("/users/login", usersController.login);              1
router.post("/users/login", usersController.authenticate,
 usersController.redirectView);                              2

  • 1 Add a route to handle GET requests made to the /users/login path.
  • 2 Add a route to handle POST requests to the same path.

Create the necessary controller actions in your users controller to get the login form working. Add the code from listing 23.3 to usersController.js.

The login action renders the login view for user login. The authenticate action finds one user with the matching email address. Because this attribute is unique in the database, it should find that single user or no user at all. Then the form password is compared with the database password and redirected to that user’s show page if the passwords match. As in previous actions, set the res.locals.redirect variable to a path that the redirectView action will handle for you. Also set a flash message to let the user know they’ve logged in successfully, and pass the user object as a local variable to that user’s show page. By calling next here, you invoke the next middleware function, which is redirectView. If no user is found, but no error occurred in the search for a user, set an error flash message, and set the redirect path to take the user back to the login form to try again.

If an error occurs, log it to the console, and pass the error to the next middleware function that handles errors (in your errors controller).

Listing 23.3. Adding login and authenticate actions to usersController.js
login: (req, res) => {                                          1
  res.render("users/login");
},

authenticate: (req, res, next) => {                             2
  User.findOne({
    email: req.body.email
  })                                                            3
      .then(user => {
        if (user && user.password === req.body.password){
          res.locals.redirect = `/users/${user._id}`;
          req.flash("success", `${user.fullName}'s logged in successfully!`);
          res.locals.user = user;
          next();
    } else {
      req.flash("error", "Your account or password is incorrect.
 Please try again or contact your system administrator!");
      res.locals.redirect = "/users/login";
      next();
    }
  })
      .catch(error => {                                         4
        console.log(`Error logging in user: ${error.message}`);
        next(error);
      });
}

  • 1 Add the login action.
  • 2 Add the authenticate action.
  • 3 Compare the form password with the database password.
  • 4 Log errors to the console, and redirect.

At this point, you should be able to relaunch your Node.js application and visit the users/login URL to see the form in figure 23.1. Try logging in with the email address and password of a user in your database.

Figure 23.1. Example of user login page in your browser

If you type an incorrect email or password, you’re redirected to the login screen with a flash message like the one in figure 23.2. If you log in successfully, your screen will look like figure 23.3.

Figure 23.2. Failed user login page in your browser

Figure 23.3. Successful user login page in your browser

You have a problem, though: the passwords are still being saved in plain text. In the next section, I talk about ways to hash that information.

Quick check 23.1

Q1:

Why does the placement of the /users/login route matter in main.js?

QC 23.1 answer

1:

Because you have routes that handle parameters in the URL, if those routes (such as /users/:id) come first, Express.js will treat a request to /users/login as a request to the user’s show page, where login is the :id. Order matters: if the /users/login route comes first, Express.js will match that route before checking the routes that handle parameters.

 

23.2. Hashing passwords

Encryption is the process of combining some unique key or passphrase with sensitive data to produce a value that represents the original data but is otherwise useless. The process includes hashing data, the original value of which can be retrieved with a private key used for the hashing function. This hashed value is stored in the database instead of the sensitive data. When you want to encrypt new data, pass that data through the encryption algorithm. When you want to retrieve that data or compare it with, say, a user’s input password, the application can use the same unique key and algorithm to decrypt the data.

bcrypt is a sophisticated hashing function that allows you to combine certain unique keys in your application to store data such as passwords in your database. Fortunately, you can use a few Node.js packages to implement bcrypt hashing. First, install the bcrypt package by typing npm i [email protected] -S in a new terminal window. Next, require bcrypt into the module where you’ll perform the hashing. Hashing can occur in the usersController, but a better approach is to create a Mongoose pre-save hook in the User model. Require bcrypt in user.js with const bcrypt = require("bcrypt"). Then add the code in listing 23.4 to your User model, above the module.exports line but after your schema definition.

Note

You’ll only be hashing passwords, not encrypting them, because you technically don’t want to retrieve the original value of a password. In fact, your application should have no knowledge of a user’s password. The application should be able only to hash a password. Later, hash password attempts, and compare the hashed values. I talk more about this topic later in this section.

The Mongoose pre and post hooks are great ways to run some code on the User instance before and after the user is saved to the database. Attach the hook to the userSchema, which (like other middleware) takes next as a parameter. The bcrypt.hash method takes a password and a number. The number represents the level of complexity against which you’d like to hash your password, and 10 is generally accepted as a reliable number. When the hashing of the password is complete, the next part of the promise chain accepts the resulting hash (your hashed password).

Assign the user’s password to this hash, and call next, which saves the user to the database. If any errors occur, they’ll be logged and passed to the next middleware.

Note

Because you lose context within this pre-hook when you run bcrypt.hash, I suggest preserving this in a variable that can be accessed within the hashing function.

passwordComparison is your custom method on the userSchema, allowing you to compare passwords from a form’s input with the user’s stored and hashed password. To perform this check asynchronously, use the promise library with bcrypt. bcrypt.compare returns a Boolean value comparing the user’s password with the inputPassword. Then return the promise to whoever called the passwordComparison method.

Listing 23.4. Adding a hashing pre hook in user.js
userSchema.pre("save", function(next) {                            1
  let user = this;

  bcrypt.hash(user.password, 10).then(hash => {                    2
    user.password = hash;
    next();
  })
    .catch(error => {
      console.log(`Error in hashing password: ${error.message}`);
      next(error);
    });
});

userSchema.methods.passwordComparison = function(inputPassword){   3
  let user = this;
  return bcrypt.compare(inputPassword, user.password);             4
};

  • 1 Add a pre hook to the user schema.
  • 2 Hash the user’s password.
  • 3 Add a function to compare hashed passwords.
  • 4 Compare the user password with the stored password.
Note

A pre hook on save is run any time the user is saved: on creation and after an update via the Mongoose save method.

The final step is rewriting the authenticate action in usersController.js to compare passwords with bcrypt.compare. Replace the code block for the authenticate action with the code in listing 23.5.

First, explicitly query for one user by email. If a user is found, assign the result to user. Then check whether a user was found or null is returned. If a user with the specified email address is found, call your custom passwordComparison method on the user instance, passing the form’s input password as an argument.

Because passwordComparison returns a promise that resolves with true or false, nest another then to wait for a result. If passwordsMatch is true, redirect to the user’s show page. If a user with the specified email doesn’t exist or the input password is incorrect, return to the login screen. Otherwise, throw an error, and pass it in your next object. Any errors thrown or occurring during this process are caught and logged.

Listing 23.5. Modifying the authenticate action in usersController.js
authenticate: (req, res, next) => {
  User.findOne({email: req.body.email})                             1
      .then(user => {
        if (user) {                                                 2
          user.passwordComparison(req.body.password)                3
              .then(passwordsMatch => {
                if (passwordsMatch) {                               4
                  res.locals.redirect = `/users/${user._id}`;
                  req.flash("success", `${user.fullName}'s logged in
 successfully!`);
                  res.locals.user = user;
                } else {
                  req.flash("error", "Failed to log in user account:
 Incorrect Password.");
                  res.locals.redirect = "/users/login";
                }
                next();                                             5
              });
        } else {
          req.flash("error", "Failed to log in user account: User
 account not found.");
          res.locals.redirect = "/users/login";
          next();
    }
  })
      .catch(error => {                                             6
        console.log(`Error logging in user: ${error.message}`);
        next(error);
      });
}

  • 1 Query for one user by email.
  • 2 Check whether a user is found.
  • 3 Call the password comparison method on the User model.
  • 4 Check whether the passwords match.
  • 5 Call the next middleware function with redirect path and flash message set.
  • 6 Log errors to console and pass to the next middleware error handler.

Relaunch your Node.js application, and create a new user. You’ll need to create new accounts moving forward because previous account passwords weren’t securely hashed with bcrypt. If you don’t, bcrypt will try to hash and compare your input password with a plain-text password. After the account is created, try logging in again with the same password at /users/login. Then change the password field in the user’s show page to display the password on the screen. Visit a user’s show page to see the new hashed password in place of the old plain-text one (figure 23.4).

Figure 23.4. Show hashed password in user’s show page in browser

Note

You can also verify that passwords are hashed at the database level by entering the MongoDB shell with mongo in a new terminal window and then typing use recipe_db and db.users.find({}). Alternatively, you can use the MongoDB Compass software to see the new records in this database.

Now when you log in for a user with a hashed password, you should be redirected to that user’s show page upon successful authentication. If you type an incorrect password, you get a screen like figure 23.5.

Figure 23.5. Incorrect password screen in browser

In the next section, you add some more security to the create and update actions by adding validation middleware before those actions are called.

Quick check 23.2

Q1:

True or false: bcrypt’s compare method compares the plain-text password in your database with the plain-text value from the user’s input.

QC 23.2 answer

1:

False. The only password value in the database is a hashed password, so there’s no plain-text value to compare against. The comparison works by hashing the user’s new input and comparing the newly created hashed value with the stored hash value in the database. This way, the application still won’t know your actual password, but if two hashed passwords match, you can safely say that your input matched the original password you set up.

 

23.3. Adding validation middleware with express-validator

So far, your application offers validation at the view and model levels. If you try to create a user account without an email address, your HTML forms should prevent you from doing so. If you get around the forms, or if someone tries to create an account via your application programming interface (API), as you see in unit 6, your model schema restrictions should prevent invalid data from entering your databases—though more validation can’t hurt. In fact, if you could add more validation before your models are reached in the application, you could save a lot of computing time and machine energy spent making Mongoose queries and redirecting pages.

For those reasons, you’ll validate middleware, and as is true of most common needs in Node.js, some packages are available to help you build those middleware functions. The package you’ll install is express-validator, which provides a library of methods you can use to check whether incoming data follows a certain format and methods that modify that data to remove unwanted characters. You can use express-validator to check whether some input data is entered in the format of a U.S. phone number, for example.

You can install this package by typing npm i express-validator -S in your project folder in terminal. When this package is installed, require it with const expressValidator = require("express-validator") in main.js, and tell your Express.js app to use it by adding router.use(expressValidator()). You need to add this line after the line where express.json() and express.urlencoded() middleware is introduced, because the request body must be parsed before you can validate it.

Then you can add this middleware to run directly before the call to the create action in the usersController. To accomplish this task, you need to create a validate action between the path and create action in the POST route to /users/create in main.js, as shown in listing 23.6. Between the path, /users/create, and the usersController.create action, you introduce a middleware function called validate. Through this validate action, you’ll determine whether data meets your requirements to continue to the create action.

Listing 23.6. Adding the validate middleware to the users create route in main.js
router.post("/users/create", usersController.validate,
 usersController.create, usersController.redirectView);      1

  • 1 Add the validate middleware to the users create route.

Finally, create the validate action in usersController.js to handle requests before they reach the create action. In this action, you add the following:

  • Validators—Check whether incoming data meets certain criteria.
  • Sanitizers—Modify incoming data by removing unwanted elements or casting the data type before it enters the database.

Add the code in listing 23.7 to your usersController.js.

The first validation function uses the request and response, and it may pass on to the next function in the middleware chain, so you need the next parameter. Start with a sanitization of the email field, using express-validator's normalizeEmail method to convert all email addresses to lowercase and then trim whitespace away. Follow with the validation of email to make sure that it follows the email-format requirements set by express-validator.

The zipCode validation ensures that the value isn’t empty and is an integer, and that the length is exactly five digits. The last validation checks that the password field isn’t empty. req.getValidationResult collects the results of the previous validations and returns a promise with those error results.

If errors occur, you can collect their error messages and add them to your request’s flash messages. Here, you’re joining the series of messages with " and " in one long String. If errors have occurred in the validations, set req.skip = true. Here, set is the new custom property you’re adding to the request object. This value tells your next middleware function, create, not to process your user data because of validation errors and instead to skip to your redirectView action. For this code to work, you need to add if (req.skip) next() as the first line in the create action. This way, when req.skip is true, you continue to the next middleware immediately.

In the event of validation errors, render the new view again. Your flashMessages also indicate to the user what errors occurred with her input data.

Listing 23.7. Creating a validate controller in usersController.js
validate: (req, res, next) => {                                    1
  req.sanitizeBody("email").normalizeEmail({
    all_lowercase: true
    }).trim();                                                     2
  req.check("email", "Email is invalid").isEmail();
  req.check("zipCode", "Zip code is invalid")
.notEmpty().isInt().isLength({
    min: 5,
    max: 5
  }).equals(req.body.zipCode);                                     3
  req.check("password", "Password cannot be empty").notEmpty();    4

  req.getValidationResult().then((error) => {                      5
    if (!error.isEmpty()) {
      let messages = error.array().map(e => e.msg);
      req.skip = true;                                             6
      req.flash("error", messages.join(" and "));                  7
      res.locals.redirect = "/users/new";                          8
      next();
    } else {
      next();                                                      9
    }
  });
}

  • 1 Add the validate function.
  • 2 Remove whitespace with the trim method.
  • 3 Validate the zipCode field.
  • 4 Validate the password field.
  • 5 Collect the results of previous validations.
  • 6 Set skip property to true.
  • 7 Add error messages as flash messages.
  • 8 Set redirect path for the new view.
  • 9 Call the next middleware function.
Note

You can take many creative approaches to repopulating form data. You may find that some packages are helpful in assisting with this task. When you find the technique that works best for you, change all the forms in your application to handle repopulating data.

You’re ready to give these validations a shot. Launch your application, and create a new user in ways that should fail your validations. You may need to remove the required tags from your HTML forms first if you want to test the notEmpty validations. Your failed password and zipCode validations should send you to a screen resembling figure 23.6.

Figure 23.6. Failed express-validator validation messages

Because express-validator uses the validator package, you can get more information about the sanitizers to use at https://github.com/chriso/validator.js#sanitizers.

Quick check 23.3

Q1:

What’s the difference between a sanitizer and a validator?

QC 23.3 answer

1:

A sanitizer cleans data by trimming whitespace, changing the case, or removing unwanted characters. A validator tests data quality to ensure that the way it was entered meets your database requirements.

 

Summary

In this lesson, you implemented a hashing function for your users’ passwords. Then you created a login form and action by using the bcrypt.compare method to match hashed passwords against user input on login. At the end, you added more validations on input data through an additional middleware function to sanitize data before it’s saved to your database. In lesson 24, you take another look at encryption and authentication through Passport.js tools, which make setting up secure user accounts much easier.

Try this

Hashing user passwords is probably the leading scenario for using hashing functions, but you can use hashing functions on other fields on your models. You might hash a user’s email address to prevent that data from getting into the wrong hands, for example. After all, getting access to a user’s email is getting halfway to hacking that user’s account. Try adding hashing to user emails in addition to passwords.

Note
Note

When you hash a user’s email address, you won’t be able to display it in any views. Although you may choose to keep user emails in plain text, this practice is good to follow when other sensitive data enters your application.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.145.194.57