Draft Node.js App Authentication Guide

Create an Authentication and Authorization system in Node.js app that can be used as a standard guide or starter template scalable to any size.

Category: Node.js Web Applications Last Updated: 09/26/19

Github: https://github.com/TechandStartup/node-app-authentication

Context:

Goal: Provide curated standardized building blocks that software developers can trust to use out of the box, and can easily customize to meet the requirements of their projects.
Skill level: Applies to anyone who is or wants to be a web developer. Applies to all skill levels, from brand new programmers to experienced Node.js web devs.
Simple Sites: Non programmers who want to build a simple website for their small business or organization would be better served using Wordpress. It doesn't require any programming, and you can have a basic site up within hours. It is too slow at high volume however.
This is a Guide: Not meant to be a tutorial per se, but rather a guide that can be used as a starting point or template for building a web app.
Opinionated yet flexible: For each decision point a recommended approach is taken after weighing the pros and cons of the options. But because these are just guides you can modify whatever you like thus providing the best of both worlds.
Evaluation criteria: Each approach option is weighed with the following criteria in mind: performance, popularity, maturity, clarity, simplicity, consistency, intuitiveness, beginner friendliness, conventions, best practices, rolling your own favored over installing packages, favoring the core web languages (JavaScript, JSON, HTML, CSS) over DSLs (domain specific languages) or other programming languages, node package documentation.
Up-to-date: Each guide is reviewed when new major or minor versions of dependency packages are released and updates are made where appropriate.

        

Setup the base app

Express Docs: Getting Started | Express Generator | Express Guide | API Package.json: Docs Express-Flash docs: Readme Dotenv Docs: Readme We will set up an authentication system for a Node.js with Express application based on user accounts with a unique email address. This guide covers Authentication in the broader sense, meaning it will do three things. Add a User collection with all the CRUD (Create-Read-Update-Delete) actions. Add a login action for authentication to ensure the user is who they say they are. And add authorization to restrict access to specific pages. The first part of this guide sets up the base application. Prerequisite: Make sure you have a recent version of Node.js (LTS - Latest Stable Version) and npm (comes with Node.js) installed in your local environment. Generate the app using express with flags to make ejs the templating engine. The --git flag just adds a .gitignore file. npx express-generator node-app-authentication --view=ejs --git cd node-app-authentication Install the libraries added to your package.json file by express-generator. npm install We will be using flash so we'll need additional packages. npm install express-session express-flash dotenv • Dotenv is used for managing Environmental Variables in a .env file. This meshes well with hosting platforms like Heroku.

Create the base file structure

Create the folders and files needed for a standard app. We'll add a users collection to the file structure later. Use the below UNIX commands to create the folders and files we need in addition to the ones generated by the express generator. mkdir creates a directory, touch creates a file, mv moves a file, and rm removes a file. mkdir controllers touch controllers/pagesController.js mkdir models mkdir views/pages mv views/index.ejs views/pages/home.ejs mv views/error.ejs views/pages/error.ejs touch views/pages/protected.ejs mkdir views/layouts touch views/layouts/header.ejs touch views/layouts/footer.ejs touch views/layouts/flash.ejs touch views/layouts/form-errors.ejs rm routes/users.js touch .env

Preliminary App.js file

• Start with the file as generated from express-generator. • Import the express-session and express-flash packages and add them to app.use. • Import the dotenv package and chain the config() method. • Change the app.js file to use only one router removing the usersRouter and renaming indexRouter to just router. • Reflect the change in location of the errors.ejs file. • Change the variable declarations from var to const. // app.js const createError = require('http-errors'); const express = require('express'); const path = require('path'); const cookieParser = require('cookie-parser'); const session = require('express-session'); const flash = require('express-flash'); const logger = require('morgan'); require('dotenv').config(); const router = require('./routes/index'); const app = express(); // view engine setup app.set('views', path.join(__dirname, 'views')); app.set('view engine', 'ejs'); app.use(logger('dev')); app.use(express.json()); app.use(express.urlencoded({ extended: false })); app.use(cookieParser()); app.use(session({secret: process.env.SECRET, saveUninitialized: true, resave: false})); app.use(flash()); app.use(express.static(path.join(__dirname, 'public'))); app.use('/', router); // catch 404 and forward to error handler app.use(function(req, res, next) { next(createError(404)); }); // error handler app.use(function(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('pages/error'); }); module.exports = app; Flash messages are stored in sessions. To use sessions you need to add some secret string to the .env file. Don't put the string in quotes. // .env SECRET=put-your-secret-string-here

Routes and Controller

// routes/index.js const express = require('express'); const router = express.Router(); const pagesController = require('../controllers/pagesController'); // Pages routes router.get('/', pagesController.home); router.get('/protected', pagesController.protected); module.exports = router;

Add controller

// controllers/pagesController.js // GET / exports.home = (req, res) => { res.render('pages/home', { title: 'Node Authentication' }); }; // GET /protected exports.protected = (req, res) => { res.render( 'pages/protected', { title: 'Protected Page', message: 'Only Logged In users should see this.' } ); };

View Layout

For brevity we are not including css classes. But in the guide code kept on Github we add Bootstrap classes and links to the Bootstrap, JQuery and Popper CDNs so you can have a presentable app right out of the box. The header includes a navbar and a section to display flash messages. // views/layouts/header.ejs <!DOCTYPE html> <html> <head> <title><%= typeof title === 'undefined' ? 'Node-App-Structure' : title %></title> <link rel='stylesheet' href='/stylesheets/style.css' /> </head> <body> <nav> <a href="/">Home</a> <a href="/protected">Protected</a> </nav> <!-- Flash messages --> <% if(Object.keys(messages).length !== 0) { %> <div role="alert"> <%= Object.values(messages)[0] %> </div> <% } %> // views/layouts/footer.ejs </body> </html>

Page Views

Add some page views: • The home page. • A page we'll call "protected" which isn't protected as of yet. • Wrap the error page generated by express-generator with the header and footer. // views/pages/home.ejs <% include ../layouts/header %> <h1><%= title %></h1> <hr> <p>Welcome to the <%= title %></p> <% include ../layouts/footer %> // views/pages/protected.ejs <% include ../layouts/header %> <h1><%= title %></h1> <hr> <p>Welcome to the <%= title %></p> <p><%= message %></p> <% include ../layouts/footer %> // views/pages/error.ejs <% include ../layouts/header %> <h1><%= message %></h1> <h2><%= error.status %></h2> <!-- Error stack is for debugging. Don't show in production --> <pre><%= error.stack %></pre> <% include ../layouts/footer %> The above displays errors as an entire page. To display errors in a form populate form-errors partial and include it in the forms. // views/layouts/form-errors.ejs <% if(typeof errors !== 'undefined') { %> <ul> <li><strong>Correct Any Errors Below And Resubmit:</strong></li> <% for (var error of errors) { %> <li><%= error.msg %></li> <% } %> </ul> <% } %>

Run the app

Use hot-reloading with nodemon The Nodemon package gives you hot reloading. It reloads your app to the server whenever you save a file. Install the nodemon package globally so you can use it with all your Node.js apps. npm install -g nodemon Run the app with: nodemon View the app in the browser on the default port 3000: http://localhost:3000

Connect a Database

Mongoose.js Docs: Getting Started | Guides | API We are using a MongoDB database. You can either use a cloud version like Atlas, or install it locally on your machine. We recommend installing and using a local version when working on practice apps. For production apps if you are using a cloud version then you should be testing the cloud version in development as well. Install the mongoose package locally. npm install mongoose • Mongoose.js is the most popular ODM (Object-Document-Mapper) for MongoDB. Create a database called my_local_db (or whatever name you prefer). If using a local version open a new terminal window and run the mongo database (from any directory): mongod Then in a separate window (any directory) run the Mongo DB Shell. mongo And create the database. use my_local_db Assign the database url to a constant in the .env file. // .env MONGODB_URI=mongodb://localhost:27017/my_local_db Connect to the database on application start. // app.js ... const mongoose = require('mongoose'); ... // Connect to the MongoDB database mongoose.connect( process.env.MONGODB_URI, { useNewUrlParser: true, useFindAndModify: false, useCreateIndex: true, useUnifiedTopology: true } ); const db = mongoose.connection; db.on('error', console.error.bind(console, 'connection error:')); db.once('open', () => { console.log('Connected to the Database.') }); ... • The various options added to the mongoose.connect method relate to changes in mongoose. You'll get warnings or errors without them. • We're logging messages to the terminal console on database connection and connection error. Referesh the app in the browser to make sure it is still working before moving to the next section.

User Collection

Creating an authentication system is a bit complicated so we'll do it in steps. That way you can test it and fix any problems you encounter along the way. Start by making a plain User collection with CRUD actions without any of the authentication logic other than hashing the password. We are using the MVC (Model-View-Controller) architecture. We are using User as the name of the user collection, but you can use a different name such as Member, Account, etc.

User File Structure

Create the necessary directories and files with the below UNIX commands. touch models/user.js touch controllers/authController.js touch controllers/usersController.js mkdir views/users touch views/users/list.ejs touch views/users/details.ejs touch views/users/update.ejs touch views/users/delete.ejs touch views/users/details.ejs mkdir views/auth touch views/auth/signup.ejs touch views/auth/login.ejs touch views/auth/forgot-password.ejs touch views/auth/reset-password.ejs

User model

// models/user.js const mongoose = require('mongoose'); const userSchema = new mongoose.Schema({ username: { type: String, required: true }, email: { type: String, lowercase: true, required: true, index: true, unique: true }, password: { type: String, required: true }, role: { type: String }, activationToken: { type: String }, activated: { type: Boolean, default: false }, resetToken: { type: String }, resetSentAt: { type: Date } }, {timestamps: true}); // Virtual field for user URL userSchema.virtual('url').get(function() { return '/users/' + this._id; }); module.exports = mongoose.model('User', userSchema); • The only required property for each field is the type. • Mongoose provides built-in validators that it automatically runs before saving to a document to the database. If a validation fails the document is not created and an error object is returned. • Use the "required" validator for username, email, and password. • Downcase the email value and add an index and a unique requirement. • Make the activated feild default to false. • Role is set to type string, but it could also be an array type if users can have multiple roles. • The timestamps option will automatically add createdAt and updatedAt fields and insert a timestamp value when a document is created or updated. • Add a virtual field called url for convenience.

Preliminary Routes

Put all routes in the routes/index.js file. Import the controllers and call the controller actions in the second argument of the route. // routes/index.js const express = require('express'); const router = express.Router(); const pagesController = require('../controllers/pagesController'); const authController = require('../controllers/authController'); const usersController = require('../controllers/usersController'); // Pages routes router.get('/', pagesController.home); router.get('/protected', pagesController.protected); // Auth routes router.get('/signup', authController.signupPage); router.post('/signup', authController.signup); // Users routes router.get('/users', usersController.list); router.get('/users/:id', usersController.details); router.get('/users/:id/update', usersController.updatePage); router.post('/users/:id/update', usersController.update); router.get('/users/:id/delete', usersController.deletePage); router.post('/users/:id/delete', usersController.delete); module.exports = router;

Preliminary controllers

Add standard REST controller actions. For now we'll structure it like any collection with two differences. First, instead of keeping the create action in the users controller, We'll put in a separate auth controller and call it signup. Second, before saving the password to the database we'll make it indecipherable by hashing it with the bcrypt package. Install bcrypt. npm install bcrypt Bcrypt Docs: Readme // controllers/authController.js const bcrypt = require('bcrypt'); const User = require('../models/user'); // GET /signup exports.signupPage = (req, res, next) => { res.render('auth/signup', { title: 'Signup' }); }; // POST /signup exports.signup = async (req, res, next) => { try { req.body.password = await bcrypt.hash(req.body.password, 10); const newUser = await User.create(req.body); req.flash('success', 'Account Created.'); res.redirect(`/users/${newUser._id}`); } catch (err) { next(err); } }; // controllers/usersController.js const bcrypt = require('bcrypt'); const createError = require('http-errors'); const User = require('../models/user'); // GET /users exports.list = (req, res, next) => { User.find() // User.find({activated: true}) adds a condition .sort({'username': 'asc'}) .limit(50) .select('_id username email') .exec((err, users) => { if (err) { next(err); } else { res.render('users/list', { title: 'Users', users: users }); } }); }; // GET /users/:id exports.details = (req, res, next) => { User.findById(req.params.id, (err, user) => { // if id not found mongoose throws CastError. if (err || !user) { next(createError(404)); } else { res.render('users/details', { title: 'User', user: user }); } }); }; // GET /users/:id/update exports.updatePage = (req, res, next) => { User.findById(req.params.id, (err, user) => { // if id not found throws CastError. if (err || !user) { next(createError(404)); } else { res.render('users/update', { title: 'Update User', user: user }); } }); }; // POST /users/:id/update exports.update = async (req, res, next) => { try { if (req.body.password) { req.body.password = await bcrypt.hash(req.body.password, 10); } const user = await User.findByIdAndUpdate( req.params.id, req.body, {new: true, runValidators: true} ); req.flash('success', 'Account Updated.'); res.redirect(`/users/${user._id}`); } catch (err) { next(err); } }; // GET /users/:id/delete exports.deletePage = (req, res, next) => { User.findById(req.params.id, (err, user) => { // if id not found throws CastError. if (err || !user) { next(createError(404)); } else { res.render('users/delete', { title: 'Delete Account', user: user }); } }); }; // POST users/:id/delete exports.delete = (req, res, next) => { User.findByIdAndRemove(req.body.id, (err) => { if (err) { next(err); } else { req.flash('info', 'Account Deleted.'); res.redirect('/users'); } }) };

Views

Add navbar links for users and signup. // views/layouts/header.ejs <nav> <a href="/">Home</a> <a href="/protected">protected</a> <a href="/users">users</a> <a href="/signup">signup</a> </nav> Add user views. // views/auth/signup.ejs <% include ../layouts/header %> <h1>Sign Up</h1> <% include ../layouts/form-errors %> <form method="POST" action="/signup"> <div> <label for="username">Username</label> <input type="text" name="username" value="<%= typeof user === 'undefined' ? '' : user.username %>" maxlength="50" required autofocus> </div> <div> <label for="email">Email</label> <input type="email" name="email" value="<%= typeof user === 'undefined' ? '' : user.email %>" maxlength="50" required> </div> <div> <label for="password">Password (must be at least 6 characters)</label> <input type="password" name="password" minlength="6" maxlength="32" required> </div> <div> <label for="passwordConfirmation">Confirm Password</label> <input type="password" name="passwordConfirmation" required> </div> <button type="submit">Submit</button> <span>Already a member? <a href="/login">Log in</a></span> </div> </form> <% include ../layouts/footer %> // views/users/list.ejs <% include ../layouts/header %> <h1>Users</h1> <ul> <% users.forEach(function(user) { %> <li><a href="/users/<%= user.id %>"><%= user.username %> - <%= user.email %></a></li> <% }); %> </ul> <% include ../layouts/footer %> // views/users/details.ejs <% include ../layouts/header %> <h1>User Info Page</h1> <hr> <p><b>ID:</b> <%= user.id %></p> <p><b>Username:</b> <%= user.username %></p> <p><b>Email:</b> <%= user.email %></p> <% if (user.role) { %> <p><b>Role:</b> <%= user.role %></p> <% } %> <a href="/users/<%= user.id %>/update" class='btn btn-info'>Update</a> <a href="/users/<%= user.id %>/delete">Delete</a> <% include ../layouts/footer %> // views/users/update.ejs <% include ../layouts/header %> <h1>User Settings</h1> <% include ../layouts/form-errors %> <form method="POST" action="/users/<%= user._id %>/update"> <div class='form-group'> <label for="username">Userame</label> <input type="text" name="username" value="<%= typeof user === 'undefined' ? '' : user.username %>" maxlength="50" required> </div> <div> <label for="email">Email</label> <input type="email" name="email" value="<%= typeof user === 'undefined' ? '' : user.email %>" required> </div> <div> <label for="password">Change Password (must be at least 6 characters)</label> <input type="password" name="password" minlength="6" maxlength="32"> </div> <div> <label for="passwordConfirmation">Confirm Password</label> <input type="password" name="passwordConfirmation"> </div> <div> <button type="submit">Submit</button> <a href="/users/<%= user._id %>">Cancel</a> </div> </form> <hr> <h3>Delete Account</h3> <a href="/users/<%= user._id %>/delete">Delete</a> <% include ../layouts/footer %> // views/users/delete.ejs <% include ../layouts/header %> <h1>Delete Account: <%= user.username %></h1> <hr> <p> Are you sure you want to delete this account? <form method='POST' action='/users/<%= user._id %>/delete'> <input type="hidden" name="id" value="<%= user._id %>"> <button type='submit'>Yes - Delete Account</button> <a href="/users/<%= user.id %>">No - Cancel</a> </form> </p> <% include ../layouts/footer %> Restart the app and test it to make sure all 4 CRUD actions work. And you should see the flash messages after signup, update and delete.

Validation

Express-validator Docs: Getting Started | API Install the express-validator package. npm install express-validator Layer on validation in the signup and update actions. // controllers/authController.js ... const { body, validationResult } = require('express-validator'); ... exports.signup = [ // validate username not empty. body('username').trim().not().isEmpty().withMessage('Username cannot be blank.'), // change email to lowercase, validate not empty, valid format, not in use. body('email') .not().isEmpty().withMessage('Email cannot be blank.') .isEmail().withMessage('Email format is invalid.') .normalizeEmail() .custom((value) => { return User.findOne({email: value}).then(user => { if (user) { return Promise.reject('Email is already in use'); } }); }), // Validate password at least 6 chars, passwordConfirmation matches password. body('password').isLength({ min: 6 }).withMessage('Password must be at least 6 characters.') .custom((value, { req }) => { if (value !== req.body.passwordConfirmation) { throw new Error('Password confirmation does not match password'); } // Indicates the success of this synchronous custom validator return true; } ), async (req, res, next) => { // Create object of any validation errors from the request. const errors = validationResult(req); // if errors send the errors and original request body back. if (!errors.isEmpty()) { res.render('auth/signup', { user: req.body, errors: errors.array() }); } try { req.body.password = await bcrypt.hash(req.body.password, 10); const user = await User.create(req.body); req.flash('success', 'Account Created.'); res.redirect(`/users/${user._id}`); } catch (err) { next(err); } } ]; // controllers/usersController.js ... const { body, validationResult } = require('express-validator'); ... exports.update = [ // Validate username not empty. body('username').trim().not().isEmpty().withMessage('Username cannot be blank.'), // Change email to lowercase, validate not empty, valid format, is not in use if changed. body('email') .not().isEmpty().withMessage('Email cannot be blank.') .isEmail().withMessage('Email format is invalid.') .normalizeEmail() // Validate that a changed email is not already in use. (Requires db query so maybe change this.) .custom((value, { req }) => { return User.findOne({email: value}).then(user => { if (user && user._id.toString() !== req.params.id) { return Promise.reject('Email is already in use'); } }); }), // Validate password is at least 6 chars long, matches password confirmation if changed. body('password') .isLength({ min: 6 }).optional({ checkFalsy: true }) .withMessage('Password must be at least 6 characters.') .optional({ checkFalsy: true }).custom((value, { req }) => { if (value != req.body.passwordConfirmation) { throw new Error('Password confirmation does not match password'); } // Indicates the success of this synchronous custom validator return true; } ), async (req, res, next) => { const user = { username: req.body.username, email: req.body.email, _id: req.params.id }; // Create object of any validation errors from the request. const errors = validationResult(req); // if errors send the errors and original request body back. if (!errors.isEmpty()) { res.render('users/update', { user: user, errors: errors.array() }); } try { if (req.body.password) { user.password = await bcrypt.hash(req.body.password, 10); } await User.findByIdAndUpdate( req.params.id, user, {new: true} ); req.flash('success', 'Account Updated.'); res.redirect(`/users/${user._id}`); } catch (err) { next(err); } } ]; Test it out to make sure you are getting the relevant validation errors.

Authentication

Jsonwebtoken Docs: Readme If we want to restrict pages to registered users, specific users, or specific user roles then we first need to authenticate who the user is. We will add login and logout actions, and will store a JSON web token, that identifies the user, in a cookie. Install the jsonwebtoken package. npm install jsonwebtoken

Log In/Out

Add login and logout routes. // routes/index.js ... // Auth routes ... router.get('/login', authController.loginPage); router.post('/login', authController.login); router.get('/logout', authController.logout); Add Login and logout controller actions. // controllers/authController.js const jwt = require('jsonwebtoken'); ... // GET /login exports.loginPage = (req, res, next) => { res.render('auth/login', { title: "Log In" }); }; // POST /login exports.login = [ // change email to lowercase, validate not empty. body('email') .not().isEmpty().withMessage('Email cannot be blank.') .normalizeEmail() // custom validator gets user object from DB from email, rejects if not present, compares user.password to hashed password from login. .custom((value, {req}) => { return User.findOne({email: value}).then(async (user) => { if (!user) { return Promise.reject('Email or Password are incorrect.'); } const passwordIsValid = await bcrypt.compareSync(req.body.password, user.password); if (!passwordIsValid) { // return Promise.reject('Email or Password are incorrect.'); throw new Error('Email or Password are incorrect.') } if (user.activated === false) { throw new Error('Account not activated. Check your email for activation link.') } }); }), async (req, res, next) => { // Create object of any validation errors from the request. const errors = validationResult(req); // if errors send the errors and original request body back. if (!errors.isEmpty()) { res.render('auth/login', { user: req.body, errors: errors.array() }); } else { User.findOne({email: req.body.email}).then((user) => { // the jwt and cookie each have their own expirations. const token = jwt.sign( { user: { id: user._id, username: user.username, role: user.role }}, process.env.SECRET, { expiresIn: '1y' } ); // Assign the jwt to the cookie. // Adding option secure: true only allows https. // maxAge 3600000 is 1 hr (in milliseconds). Below is 1 year. res.cookie('jwt', token, { httpOnly: true, maxAge: 31536000000 }); req.flash('success', 'You are logged in.'); res.redirect('/'); }); } } ]; // GET /logout exports.logout = (req, res, next) => { res.clearCookie('jwt'); req.flash('info', 'Logged out.'); res.redirect('/'); }; Add current_user to local storage. This is used for conditional statements in the view files. For instance show logout button if there is a current_user and login button if not. Make sure to place this before app.use('/', router); or the page will display before getting the currentUser object. // app.js const jwt = require('jsonwebtoken'); ... // Add current user to local storage const getCurrentUser = (token) => { if (token) { let decoded = jwt.verify(token, process.env.SECRET); const user = decoded.user || ''; return user; } } app.use((req, res, next) => { res.locals.currentUser = getCurrentUser(req.cookies.jwt); next(); }); ... app.use('/', router); Populate the login form page. // views/auth/login.ejs <% include ../layouts/header %> <h1>Log In</h1> <% include ../layouts/form-errors %> <form method="POST" action="/login"> <div> <label for="email">Email</label> <input type="email" name="email" value="<%= typeof user === 'undefined' ? '' : user.email %>" required autofocus> </div> <div> <label for="password">Password</label> (<a href="/forgot-password" tabindex="-1">Forgot Password?</a>) <input type="password" name="password" required> </div> <div> <button type="submit">Submit</button> <span>New user? <a href="/signup">Sign up now!</a></span> </div> </form> <% include ../layouts/footer %> Add login and logout routes to the navbar. // views/layouts/header.ejs ... <nav> <a href="/">Home</a> <% if(!currentUser){ %> <a class="nav-link" href="/signup">Sign Up</a> <a class="nav-link" href="/login">Log In</a> <% } else { %> <a href="/protected">protected</a> <a href="/users">users</a> <a href="/users/<%= currentUser.id %>"><%= currentUser.username %> Profile</a> <a href="/users/<%= currentUser.id %>/update">Settings</a> <a href="/logout">Log Out</a> <% } %> </nav> We have not dealt with activation yet so you can try to log in a user and will get an response that the user is not yet activated. In the authController login action you can temporarily comment out the validation, then you can log users in and out. The navbar menu items should change depending on if the user is logged in or not. // if (user.activated === false) { // throw new Error('Account not activated. Check your email for activation link.') // } Login user when they sign up by adding the below to the end of the authController signup action: // controllers/authController.js ... try { req.body.password = await bcrypt.hash(req.body.password, 10); const user = await User.create(req.body); // On success - login user and redirect. const token = jwt.sign( { user: { id: user._id, username: user.username, role: user.role }}, process.env.SECRET, { expiresIn: '1y' } ); res.cookie('jwt', token, { httpOnly: true, maxAge: 3600000 }); req.flash('success', 'Account Created.'); res.redirect(`/users/${user._id}`); } ... Test that a user is automatically logged in when they successfully sign up.

Activate Account

Sendgrid: Website @sendgrid/mail Docs: npm package Crypto-random-string Docs: Readme Send email to the user when they register. The user must click link in email to activate their account. This is to ensure the email address the provided is valid and belongs to them. There are multiple email providers you can choose from. This type of email is called transactional, as opposed to email marketing. Sendgrid is a leading provider of transactional email and offers a free account of up to 100 emails/day. You don't have to have an account follow along. You can use the sendgrid package but just print the email to your terminal without even connecting to sendgrid. Install the required packages. npm install crypto-random-string @sendgrid/mail • crypto-random-string package will generate a token to use when sending the activation email. Add route. // routes/index.js ... // Auth routes router.get('/activate-account', authController.activateAccount); ... Add a helper function to the auth controller that sends an activation email. Modify the signup action to call it. Add the activate-account action. // controllers/authController.js // Helper function for signup action const sendActivationEmail = async (username, email, token) => { sgMail.setApiKey(process.env.SENDGRID_API_KEY); const html = await ejs.renderFile( __dirname + "/../views/email/activate-account.ejs", {username: username, email: email, token: token } ); const msg = { to: email, from: 'no-reply@example.com', subject: 'Account activation', html: html }; try { // View email in the console without sending it. console.log('Activation Email: ', msg); // Uncomment below to send the email. // await sgMail.send(msg); console.log('Email has been sent!'); } catch(err) { console.log('There was an error sending the email. Error: ' + err); } }; exports.signup = [ // validate username not empty. body('username').trim().not().isEmpty().withMessage('Username cannot be blank.'), // change email to lowercase, validate not empty, valid format, not in use. body('email') .not().isEmpty().withMessage('Email cannot be blank.') .isEmail().withMessage('Email format is invalid.') .normalizeEmail() .custom((value) => { return User.findOne({email: value}).then(user => { if (user) { return Promise.reject('Email is already in use'); } }); }), // Validate password at least 6 chars, passwordConfirmation matches password. body('password').isLength({ min: 6 }).withMessage('Password must be at least 6 characters.') .custom((value, { req }) => { if (value !== req.body.passwordConfirmation) { throw new Error('Password confirmation does not match password'); } // Indicates the success of this synchronous custom validator return true; } ), async (req, res, next) => { // Create object of any validation errors from the request. const errors = validationResult(req); // if errors send the errors and original request body back. if (!errors.isEmpty()) { res.render('auth/signup', { user: req.body, errors: errors.array() }); } try { const token = await cryptoRandomString({length: 10, type: 'url-safe'}); const username = req.body.username; const email = req.body.email; sendActivationEmail(username, email, token); const hashedPassword = await bcrypt.hash(req.body.password, 10); const user = User.create({ username: username, email: email, password: hashedPassword, activationToken: token }); req.flash('info', 'Please check your email to activate your account.'); res.redirect('/'); } catch (err) { next(err); } } ]; // GET /activate-account exports.activateAccount = async (req, res, next) => { if (!req.query.token || !req.query.email) { req.flash('warning', 'Token or email was not provided.'); return res.redirect('/'); } const user = await User.findOne({ email: req.query.email }); if (!user || user.activationToken !== req.query.token) { req.flash('warning', 'Could not activate account.'); return res.redirect('/'); } User.findByIdAndUpdate(user._id, {activated: true}, (err) => { if (err) { return next(err); } // On success - login user and redirect. const token = jwt.sign( { user: { id: user._id, username: user.username, role: user.role }}, process.env.SECRET, { expiresIn: '1y' } ); res.cookie('jwt', token, { httpOnly: true, maxAge: 3600000 }); req.flash('success', 'Your account is activated.'); res.redirect(user.url); }); }; Create an email view directory and an email file. In production you would change the domain from localhost:3000 to whatever your domain is. mkdir views/email touch views/email/activate-account.ejs Populate the email file. // views/email/activate-account.ejs <!DOCTYPE html> <html> <head> <title>Activate Account</title> </head> <body> <h1>Node-App-Authentication</h1> <p>Hi <%= username %>,</p> <p>Welcome to Node-App-Authentication. Click on the link below to activate your account:</p> <a href="http://localhost:3000/activate-account?token=<%= token %>&email=<%= email %>">Activate</a> </body> </html> If you sign up for a Sendgrid account then put your sendgrid api key in the .env file. You only need to add this if you want to send an actual email. // .env SENDGRID_API_KEY=put-your-sendgrid-api-key-here Now test it out. Create an account. Then go to the terminal, find where the email was printed. Copy and paste the URL to your browser. You should be activated. To actually send an email through sendgrid, modify the auth controller sendActivationEmail function by changing the try block to: try { await sgMail.send(msg); console.log('Email has been sent!'); }

Authorization

To limit access to controller actions based on whether a user is logged in, is the correct user, or has the right role we need to add middleware to protect the routes. Add a file to hold the auth middleware. touch routes/authMiddleware.js // routes/authMiddleware.js const jwt = require('jsonwebtoken'); const User = require('../models/user'); exports.isLoggedIn = (req, res, next) => { try { jwt.verify(req.cookies.jwt, process.env.SECRET); next(); } catch(err) { console.log(err.name + ': ' + err.message); res.redirect('/login'); } } exports.isAdmin = async (req, res, next) => { try { const decoded = jwt.verify(req.cookies.jwt, process.env.SECRET); const currentUser = await User.findById(decoded.user.id); if ((!currentUser.role) || currentUser.role !== 'admin') { throw (new Error('Unauthorized')); } next(); } catch (err) { console.log(err.name + ': ' + err.message); if (err.name === 'JsonWebTokenError') { res.redirect('/login'); } else { res.redirect('/'); } } } exports.isCorrectUser = (req, res, next) => { try { const decoded = jwt.verify(req.cookies.jwt, process.env.SECRET); if (req.params.id !== decoded.user.id) { res.redirect('/'); throw new Error('Unauthorized'); } next(); } catch (err) { console.log(err.name + ': ' + err.message); if (err.name === 'JsonWebTokenError') { res.redirect('/login'); } else { res.redirect('/'); } } } Import the middleware to the routes file and add the relevant middleware to the routes that need to be restricted. // routes/index.js const express = require('express'); const router = express.Router(); const pagesController = require('../controllers/pagesController'); const authController = require('../controllers/authController'); const usersController = require('../controllers/usersController'); const auth = require('./authMiddleware'); // Pages routes router.get('/', pagesController.home); router.get('/protected', auth.isLoggedIn, pagesController.protected); // Auth routes router.get('/signup', authController.signupPage); router.post('/signup', authController.signup); router.get('/activate-account', authController.activateAccount); router.get('/login', authController.loginPage); router.post('/login', authController.login); router.get('/logout', authController.logout); // Users routes router.get('/users', auth.isAdmin, usersController.list); router.get('/users/:id', auth.isCorrectUser, usersController.details); router.get('/users/:id/update', auth.isCorrectUser, usersController.updatePage); router.post('/users/:id/update', auth.isCorrectUser, usersController.update); router.get('/users/:id/delete', auth.isCorrectUser, usersController.deletePage); router.post('/users/:id/delete', auth.isCorrectUser, usersController.delete); module.exports = router; Test it out by trying to access the protected page when logged out. You should be redirected to the login page. http://localhost:3000/protected If you log in you should be able to access it. If you try to access the user detail page of a different user you'll be redirected to the home page. If you try to access the users page you should also be redirected to the home page if you do not have an admin role. We did not add the ability to add an admin role to a user because that's a highly restricted role. To add it directly in the database open the mongo shell. mongo Go to the database. use my_local_db View all your users. db.users.find() Get the id of the user you want to make an admin and then update them. db.users.update({"_id" : ObjectId("id-string-here")},{ $set: {role: "admin"}}) Now if that user tries to access the users route they should get access.

Forgot Password

There is a forgot password link in the login form that doesn't go anywhere. We'll make a page where they enter their email address. If the email address is in the system we'll generate a reset token, save it to the database along with the current timestamp, and send an email with a link to change the password. The user has two hours to access it. When the user clicks on the link in the email they are taken to a password reset page where they can set their password and are logged in. Create the view and email files. touch views/auth/forgot-password.ejs touch views/auth/reset-password.ejs touch views/email/reset-password.ejs Add routes. // routes/index.js // Auth routes ... router.get('/forgot-password', authController.forgotPasswordPage); router.post('/forgot-password', authController.forgotPassword); router.get('/reset-password', authController.resetPasswordPage); router.post('/reset-password', authController.resetPassword); Add controller actions and a sendResetPasswordEmail helper function. // controllers/authController.js // GET /password-reset exports.forgotPasswordPage = (req, res, next) => { res.render('auth/forgot-password', { title: 'Forgot Password' }); }; // Helper function for handleForgotPassword action. const sendResetPasswordEmail = async (email, token) => { sgMail.setApiKey(process.env.SENDGRID_API_KEY); const html = await ejs.renderFile( __dirname + "/../views/email/reset-password.ejs", {email: email, token: token } ); const msg = { to: email, from: 'no-reply@example.com', subject: 'Reset Password', html: html }; try { // View email in the console without sending it. console.log('Password Reset Email: ', msg); // Uncomment below to send the email. // const status = await sgMail.send(msg); console.log('Email has been sent!'); } catch(err) { console.log('There was an error sending the email. Error: ' + err); } }; // POST /password-reset exports.forgotPassword = [ // change email to lowercase, validate not empty. body('email') .not().isEmpty().withMessage('Email cannot be blank.') .normalizeEmail() // custom validator gets user object from DB from email, rejects if not found. .custom((value, {req}) => { return User.findOne({email: value}).then(async (user) => { if (!user) { return Promise.reject('Email address not found.'); } }); }), async (req, res, next) => { // Create object of any validation errors from the request. const errors = validationResult(req); // if errors send the errors and original request body back. if (!errors.isEmpty()) { res.render('auth/forgot-password', { user: req.body, errors: errors.array() }); } else { const token = await cryptoRandomString({length: 10, type: 'url-safe'}); const user = await User.findOneAndUpdate( {email: req.body.email}, {resetToken: token, resetSentAt: Date.now()}, {new: true} ); sendResetPasswordEmail(user.email, token); req.flash('info', 'Email sent with password reset instructions.'); res.redirect('/'); } } ]; // GET /reset-password exports.resetPasswordPage = (req, res, next) => { res.render( 'auth/reset-password', { title: 'Reset Password', user: {email: req.query.email, resetToken: req.query.token}} ); }; // POST /reset-password exports.resetPassword = [ // Validate password at least 6 chars, passwordConfirmation matches password. body('password').isLength({ min: 6 }).withMessage('Password must be at least 6 characters.') .custom(async (value, { req }) => { if (!req.query.token || !req.query.email) { throw new Error('Reset email or token is invalid'); } if (value !== req.body.passwordConfirmation) { throw new Error('Password confirmation does not match password'); } let user = await User.findOne({ email: req.query.email, resetToken: req.query.token }); if (!user) { throw new Error('Reset email or token is invalid'); } // validate not more than 2 hours. if (Date.now() - user.resetSentAt > 72000000) { throw new Error('Password Reset has Expired.'); } // Indicates the success of this synchronous custom validator return true; } ), async (req, res, next) => { // Create object of any validation errors from the request. const errors = validationResult(req); // if errors send the errors and original request body back. if (!errors.isEmpty()) { res.render('auth/reset-password', { user: req.body, errors: errors.array() }); } else { const hashedPassword = await bcrypt.hash(req.body.password, 10); const user = await User.findOneAndUpdate( {email: req.query.email}, {password: hashedPassword}, { new: true} ); // create the signed json web token expiring in 1 year. const jwtToken = await jwt.sign( { user: { id: user._id, username: user.username, role: user.role }}, process.env.SECRET, { expiresIn: '1y' } ); // Assign the jwt to the cookie expiring in 1 year. // Adding option secure: true only allows https. res.cookie('jwt', jwtToken, { httpOnly: true, maxAge: 31536000000 }); req.flash('success', 'Password has been reset.'); res.redirect(user.url); } } ]; Add the forgot password form and reset password form. // views/auth/forgot-password.ejs <% include ../layouts/header %> <h1>Forgot Password</h1> <% include ../layouts/form-errors %> <form method="POST" action="/forgot-password"> <div> <label for="email">Email</label> <input type="email" name="email" value="<%= typeof user === 'undefined' ? '' : user.email %>" required autofocus> </div> <button type="submit">Submit</button> </form> <% include ../layouts/footer %> // views/auth/reset-password.ejs <% include ../layouts/header %> <h1>Reset Password</h1> <% include ../layouts/form-errors %> <form method="POST" action="/reset-password?token=<%= user.resetToken %>&email=<%= user.email %>"> <input type="hidden" name="email" value="<%= typeof user.email === 'undefined' ? '' : user.email %>"> <input type="hidden" name="resetToken" value="<%= typeof user.resetToken === 'undefined' ? '' : user.resetToken %>"> <label for="password">Password (must be at least 6 characters)</label> <input type="password" name="password" minlength="6" maxlength="32" required> <label for="passwordConfirmation">Confirm Password</label> <input type="password" name="passwordConfirmation" required> <button type="submit">Submit</button> </form> <% include ../layouts/footer %> Add the reset password email. In production change the domain from localhost:3000 to your domain. // views/emails/reset-password.ejs <h1>Reset password</h1> <p>To reset your password click the link below:</p> <a href="http://localhost:3000/reset-password?token=<%= token %>&email=<%= email %>">Reset password</a> <p>This link will expire in two hours.</p> <p>If you did not request your password to be reset, please ignore this email and your password will stay as it is.</p> Test it out by clicking the forgot password link from the login page. Enter a registered user's email and submit. Assuming the email is set to just print to the terminal then go to the terminal and copy the url from the reset link and paste it in the browser. You should now be able to reset the user's password.

Rationale

Bcrypt or bcryptjs package? These do the same thing but bcryptjs is written in pure JavaScript while bcrypt has non-JS dependencies. Bcrypt is supposed to be faster than bcryptjs (I didn't confirm this). While I have installed bcrypt successfully here, I have had trouble installing it in the past so bcrypt.js is a viable alternative.

Open Items

What validator should be used? Mongoose's built-in validator, Express validator, or other? • Here we are using Express Validator. Where to put validation? • Currently it is in the controller. This has the advantage of not having to jump from controller to model to see the validation steps. • Consider moving it to the model. Should you sanitize the inputs? • Express-validator includes sanitation to escape HTML which will convert <, >, /, ', ", to HTML enitities (e.g., < into &lt;). How to store user info? • Using JSON web tokens stored in a cookie. Miscellaneous • In activate-account action, optionally set activationToken to null. No need to take up space in the DB if it's not needed anymore. • Maybe use an env variable for the domain so you don't have to remember to change it in the email view files. Or use a conditional depending on the environment. • Should you show an error on reset password if the user email is not in the system? Currently we are, but may be a good idea not to since it let's a potential bad actor know there is an account with that email.