Authentication and Authorization in an Express App

 

Alex Mueller

Who am I?

Who has experience with the MEAN stack?

(MongoDB Express Angular Node)

Before we get started...

No A for today

(actual Google Image result for "men stack")

MEN stack it is

What we'll cover

  • User registration with MongoDB and mongoose
  • Email verification and password reset with SendGrid
  • User authentication with Passport
  • User authorization with node_acl
  • Deploying to Heroku

User Registration

  • Define a user model
  • Securely hash passwords
  • Validate user input

User Registration - 

User Model

  • Models are constructors for MongoDB documents
  • Mongoose acts as an ODM to aid in data integrity and consistency
  • We can define virtual fields, instance methods, and static model methods

User Registration - 

User Model

var mongoose = require('mongoose');
var bcrypt = require('bcrypt');
var async = require('async');
var config = require('../config');

var userSchema = new mongoose.Schema({
    email: { type: String, unique: true, required: true },
    password: { type: String, required: true },
    verificationToken: { type: String, unique: true, required: true },
    isVerified: { type: Boolean, required: true, default: false },
    passwordResetToken: { type: String, unique: true },
    passwordResetExpires: Date,
    loginAttempts: { type: Number, required: true, default: 0 },
    lockUntil: Date,
    role: String
});

userSchema.virtual('isLocked').get(function() {
    return !!(this.lockUntil && this.lockUntil > Date.now());
});

userSchema.methods.comparePassword = function(passwordToCompare, callback) {
    var user = this;

    async.waterfall([
        function(waterfallCb) {
            bcrypt.compare(passwordToCompare, user.password, function(err, isMatch) {
                if (err) {
                    return waterfallCb(err);
                }

                waterfallCb(null, isMatch);
            });
        },
        function(isMatch, waterfallCb) {
            if (bcrypt.getRounds(user.password) !== config.login.passwordHashRounds) {
                user.password = passwordToCompare;

                user.save(function(err, user) {
                    if (err) {
                        return waterfallCb(err, isMatch);
                    }

                    waterfallCb(null, isMatch);
                });
            }
            else {
                waterfallCb(null, isMatch);
            }
        }
    ], function(err, isMatch) {
        if (err) {
            return callback(err);
        }

        callback(null, isMatch);
    });
};

userSchema.methods.incrementLoginAttempts = function(callback) {
    var lockExpired = !!(this.lockUntil && this.lockUntil < Date.now());

    if (lockExpired) {
        return this.update({
            $set: { loginAttempts: 1 },
            $unset: { lockUntil: 1 }
        }, callback);
    }

    var updates = { $inc: { loginAttempts: 1 } };
    var needToLock = !!(this.loginAttempts + 1 >= config.login.maxAttempts && !this.isLocked);

    if (needToLock) {
        updates.$set = { lockUntil: Date.now() + config.login.lockoutHours };
    }

    return this.update(updates, callback);
};

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

User Registration - 

Password Hashing

  • Use mongoose "pre" hook to hash passwords before save
  • Use bcrypt and a randomly generated salt for tighter security
  • Our salt work factor is set in an environment variable and will need to change over time

User Registration - 

Password Hashing

// models/user.js
userSchema.pre('save', function(next) {
    var user = this;

    if (!user.isModified('password')) {
        return next();
    }

    bcrypt.genSalt(config.login.passwordHashRounds, function(err, salt) {
        if (err) {
            console.log(err);
            req.flash('errors', { msg: 'There was an error generating your password salt.' });
            return res.redirect('/');
        }

        bcrypt.hash(user.password, salt, function(err, hash) {
            if (err) {
                console.log(err);
                req.flash('errors', { msg: 'There was an error hashing your password.' });
                return res.redirect('/');
            }

            user.password = hash;
            next();
        });
    });
});

User Registration - 

Input Validation

  • The Express application generator includes validation support out of the box
  • Ensure the user enters a valid:
    • email address
    • password
    • confirmed password
  • Never solely rely on front end validation

User Registration - 

Input Validation

// controllers/user.js
req.assert('email', 'Please provide a valid email address.').isEmail();
req.assert('password', 'Please enter a password of at least ' + config.login.minimumPasswordLength + ' characters.').len(config.login.minimumPasswordLength);
req.assert('confirmPassword', 'Your passwords must match.').equals(req.body.password);

var errors = req.validationErrors();

if (errors) {
    req.flash('errors', errors);
    return res.redirect('/user/register');
}

Emailing with SendGrid

  • Create random tokens for email verification and password reset
  • Construct URLs to place in email
  • Send email to user

Emailing with SendGrid - 

Creating Random Tokens

  • Needed for both email verification and password reset
  • Keep it DRY - write a utility function to create tokens
  • Use crypto library to generate tokens

Emailing with SendGrid - 

Creating Random Tokens

// lib/utility.js
exports.createRandomToken = function() {
    return crypto.randomBytes(20).toString('hex');
};


// other files
var utility = require('../lib/utility');

var token = utility.createRandomToken();

Emailing with SendGrid - 

Constructing URLs

  • URL needs to be applicable to the current environment
  • Request object contains pieces necessary to construct proper URL
    • protocol
    • host
    • port (necessary for local dev)
  • Keep it DRY - write a utility function to construct URLs

Emailing with SendGrid - 

Constructing URLs

// lib/utility.js
exports.constructUrl = function(req, path) {
    return req.protocol + '://' + req.get('host') + path;
};

// other files
var utility = require('../lib/utility.js');

var token = utility.createRandomToken();

utility.constructUrl(req, '/user/reset-password/' + token);

Emailing with SendGrid - 

Send Email to User

*they made sending a simple email way more cumbersome :(

  • SendGrid has a Node API; an API key is required
  • Keep it DRY - you guessed it, write a utility function!
  • The API was recently updated; I haven't updated it in the project yet*

Emailing with SendGrid - 

Send Email to User

// lib/utility.js
exports.sendEmail = function(to, from, subject, contents, contentType, callback) {
    var email = {
            to: to,
            from: from,
            subject: subject,
        };

    email[contentType] = contents;

    sendgrid.send(email, function(err, json) {
        callback(err, json);
    });
};

// other files
var utility = require('../lib/utility.js');

var token = utility.createRandomToken();

utility.sendEmail(req.body.email, config.email.sendFrom, 'Click this link: <a href="' + utility.constructUrl(req, '/user/reset-password/' + token) + '">reset password</a>', 'html', function(err, json) {
    // handle response
});

Emailing with SendGrid - 

Send Email to User

  • SendGrid also supports templating for more complex HTML
  • For an extensive library with a unified API for many email services, consider NodeMailer

Registration and Email -

Putting It All Together

// controllers/user.js
exports.register = {
    post: function(req, res, next) {
        req.assert('email', 'Please provide a valid email address.').isEmail();
        req.assert('password', 'Please enter a password of at least ' + config.login.minimumPasswordLength + ' characters.').len(config.login.minimumPasswordLength);
        req.assert('confirmPassword', 'Your passwords must match.').equals(req.body.password);

        var errors = req.validationErrors();

        if (errors) {
            req.flash('errors', errors);
            return res.redirect('/user/register');
        }

        var verificationToken = utility.createRandomToken();
        var user = new User({
            email: req.body.email,
            password: req.body.password,
            verificationToken: verificationToken,
            role: req.body.role,
            isVerified: false
        });

        var acl = require('../authorization').getAcl();

        User.findOne({ email: req.body.email }, function(err, existingUser) {
            if (existingUser) {
                req.flash('errors', { msg: 'A user with that email address already exists.  Please try another email address.' });
                return res.redirect('/user/register');
            }

            user.save(function(err, newUser) {
                if (err) {
                    console.log(err);
                    req.flash('errors', { msg: 'There was an error creating the user in the database.  Please try again.' });
                    return res.redirect('/user/register');
                }

                acl.addUserRoles(newUser._id.toString(), req.body.role, function(err) {
                    if (err) {
                        console.log(err);
                        req.flash('errors', { msg: 'There was an error setting your roles in the database.  Please contact an administrator.' });
                        return res.redirect('/');
                    }

                    utility.sendEmail(req.body.email, config.email.sendFrom, 'Email Verification Required', '<p>Before you can log in, you must verify your email address:</p><a href="' + utility.constructUrl(req, '/user/verify/' + verificationToken) + '">Verify your email address</a>', 'html', function(err, json) {
                        if (err) {
                            console.log(err);
                            req.flash('errors', { msg: 'There was an error sending your verification email.  Please contact an administrator.' });
                            return res.redirect('/');
                        }

                        req.flash('info', { msg: 'Your account has been created, but you must verify your email before logging in.'});
                        res.redirect('/');
                    });
                });
            });
        });
    }
};

Authentication vs. Authorization

  • Authentication is verification that a user is who they say they are

 

 

 

 

  • Authorization is allowing (or denying) a user access to actions or content based on their role(s)

Authentication with Passport

  • Passport is authentication middleware for Node
  • Authentication methods ("strategies") are packaged as modules
  • Only include the strategy (or strategies) you need
  • We will use a local strategy

Authentication with Passport

Authentication with Passport

// authentication.js
var passport = require('passport');
var LocalStrategy = require('passport-local').Strategy;
var User = require('./models/user');
var moment = require('moment-timezone');
var config = require('./config');

passport.serializeUser(function(user, done) {
    done(null, user.id);
});

passport.deserializeUser(function(id, done) {
    User.findById(id, function(err, user) {
        done(err, user);
    });
});

passport.use(new LocalStrategy({ usernameField: 'email' }, function(email, password, done) {
    User.findOne({ email: email }, function(err, user) {
        if (!user) {
            return done(null, false, { msg: 'No user with the email ' + email + ' was found.' });
        }

        if (!user.isVerified) {
            return done(null, false, { msg: 'Your email has not been verified.  Check your inbox for a verification email.<p><a href="/user/verify-resend/' + email + '" class="btn waves-effect white black-text"><i class="material-icons left">email</i>Re-send verification email</a></p>' });
        }

        if (user.isLocked) {
            return user.incrementLoginAttempts(function(err) {
                if (err) {
                    return done(err);
                }

                return done(null, false, { msg: 'You have exceeded the maximum number of login attempts.  Your account is locked until ' + moment(user.lockUntil).tz(config.server.timezone).format('LT z') + '.  You may attempt to log in again after that time.' });
            });
        }

        user.comparePassword(password, function(err, isMatch) {
            if (isMatch) {
                return done(null, user);
            }
            else {
                user.incrementLoginAttempts(function(err) {
                    if (err) {
                        return done(err);
                    }

                    return done(null, false, { msg: 'Invalid password.  Please try again.' });
                });
            }
        });
    });
}));

exports.isAuthenticated = function(req, res, next) {
    if (req.isAuthenticated()) {
        return next();
    }

    req.flash('info', { msg: "You must be logged in to visit that page." });
    res.redirect('/user/login');
};

Authorization with node_acl

  • Most popular ACL library for Node (and actually all of GitHub)
  • Inspired by Zend ACL
  • Provides:
    • roles
    • hierarchies
    • permissions
    • middleware to protect resources

Authorization with node_acl -

Roles

  • Roles are string-based (e.g. "admin" or "author")
  • Users can have multiple roles
  • Includes functions:
    • add and retrieve roles for a user
    • retrieve users for a role
    • and more

Authorization with node_acl -

Hierarchies

  • Roles can have hierarchical parent/child relationships
  • Child roles inherit permissions from parent roles
  • If you have a simple two level hierarchy of "user" and "admin" roles, user will be a parent of admin

Authorization with node_acl -

Permissions

  • Permissions are defined on a role and resource for a given action/HTTP verb
  • This granularity allows a role to have access to GET a resource, but not POST to it (e.g. read, but not write)

Authorization with node_acl -

Initialization Declaration

// authorization.js
var acl = require('acl');
var mongoose = require('mongoose');
var config = require('./config');

acl = new acl(new acl.mongodbBackend(mongoose.connection.db, config.db.aclCollectionPrefix), { debug: function(string) { console.log(string); } });

module.exports = {
    init: function() {
        acl.addRoleParents('superAdmin', 'admin');
        acl.addRoleParents('admin', 'user');

        acl.allow([
            {
                roles: ['admin'],
                allows: [
                    {
                        resources: '/user/list',
                        permissions: 'get'
                    }
                ]
            },
            {
                roles: ['superAdmin'],
                allows: [
                    {
                        resources: '/admin/list',
                        permissions: 'get'
                    }
                ]
            }
        ]);
    },

    getAcl: function() {
        return acl;
    }
};

Authorization with node_acl -

Initialization Execution

  • Authorization settings should be initialized when the app starts
  • Requires a backend to store data; do not perform initialization until you've successfully connected to your backend
// app.js
mongoose.connect(config.db.uri);
mongoose.connection.on('connected', function(test) {
    require('./authorization').init();
});

Authorization with node_acl -

Middleware

  • Checks the authenticated user for permission to access the requested resource
  • If they are not allowed, they will denied access

 

 

 

 

  • Requires custom getter to retrieve user's ID from Passport's stored location in the session

Authorization with node_acl -

Middleware Implementation

// lib/utility.js
exports.getUserId = function(req, res) {
    if (typeof req.user !== 'undefined') {
        return req.user.id;
    }

    return false;
};

// routes/user.js
var express = require('express');
var router = express.Router();
var userController = require('../controllers/user');
var utility = require('../lib/utility');
var authentication = require('../authentication');
var acl = require('../authorization').getAcl();

router.get('/list', acl.middleware(2, utility.getUserId), userController.list.get);

Deploying to Heroku - 

The Heroku Platform

  • Heroku is a platform as a service
  • Focus on "apps, not ops"
  • Supports many languages including Node, Ruby, and Go
  • Has a generous free tier

Deploying to Heroku - 

Config

  • Heroku apps need a "Procfile" declaring commands to be executed
  • Locally we use `npm start` as our command to use nodemon
  • Heroku restarts servers for us on deploy, so we'll use a different command
// Procfile
web: node ./bin/www

Deploying to Heroku - 

Config

  • Environment variables need to be set*
  • Heroku calls these "Config Vars"
  • Click Settings tab > Reveal Config Vars
  • The keys here will match those in your local .env file

Deploying to Heroku - 

Set Up Remote

Heroku can be deployed in a few ways:

Further Reading

Recommendations

  • ALWAYS use TLS to encrypt connections when dealing with user data
  • For better scalability beyond just a web app, token-based authentication is a better option

Questions?

Thank You

Authentication and Authorization in an Express App

By Alex Mueller

Authentication and Authorization in an Express App

  • 3,807