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
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.
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.
<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>
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.
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.
router.get("/users/login", usersController.login); 1 router.post("/users/login", usersController.authenticate, usersController.redirectView); 2
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).
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); }); }
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.
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.
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.
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.
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.
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.
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.
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 };
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.
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); }); }
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).
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.
In the next section, you add some more security to the create and update actions by adding validation middleware before those actions are called.
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.
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.
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.
router.post("/users/create", usersController.validate,
usersController.create, usersController.redirectView); 1
Finally, create the validate action in usersController.js to handle requests before they reach the create action. In this action, you add the following:
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.
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 } }); }
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.
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.
What’s the difference between a sanitizer and a validator?
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.
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.
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.
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.