Authentication and Authorization in an Express App
Alex Mueller
Who am I?
- Full stack web developer
- @ajmueller on GitHub and Twitter
- Founder of ten-four and co-founder of Modern Menu
- Not a security expert
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
- Passport's initialize and session middleware must be added to your app*
- Users must be serialized and deserialized in your session cookie
- Local strategy finds user based on provided credentials
- Tell Passport we're using email instead of username
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
- Social authentication
-
Authentication services
- https://auth0.com/ (supporters of Passport)
- https://firebase.google.com/docs/auth/
- https://authrocket.com/
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
- @ajmueller on GitHub and Twitter
- alex@ten-four.tech for projects
- alex@modernme.nu for opportunities with Modern Menu
Authentication and Authorization in an Express App
By Alex Mueller
Authentication and Authorization in an Express App
- 3,807