Source: passwordless.js

'use strict';

var url = require('url');
var crypto = require('crypto');
var base58 = require('bs58');

/**
 * Passwordless is a node.js module for express that allows authentication and 
 * authorization without passwords but simply by sending tokens via email or 
 * other means. It utilizes a very similar mechanism as many sites use for 
 * resetting passwords. The module was inspired by Justin Balthrop's article 
 * "Passwords are Obsolete"
 * @constructor
 */
function Passwordless() {
	this._tokenStore = undefined;
	this._userProperty = undefined;
	this._deliveryMethods = {};
	this._defaultDelivery = undefined;
}

/**
 * Initializes Passwordless and has to be called before any methods are called
 * @param {TokenStore} tokenStore - An instance of a TokenStore used to store
 * and authenticate the generated tokens
 * @param {Object} [options]
 * @param {String} [options.userProperty] - Sets the name under which the uid 
 * is stored in the http request object (default: 'user')
 * @param {Boolean} [options.allowTokenReuse] - Defines wether a token may be
 * reused by users. Enabling this option is usually required for stateless
 * operation, but generally not recommended due to the risk that others might
 * have acquired knowledge about the token while in transit (default: false)
 * @param {Boolean} [options.skipForceSessionSave] - Some session middleware
 * (especially cookie-session) does not require (and support) the forced
 * safe of a session. In this case set this option to 'true' (default: false)
 * @throws {Error} Will throw an error if called without an instantiated 
 * TokenStore
 */
Passwordless.prototype.init = function(tokenStore, options) {
	options = options || {};
	if(!tokenStore) {
		throw new Error('tokenStore has to be provided')
	}

	this._tokenStore = tokenStore;
	this._userProperty = (options.userProperty) ? options.userProperty : 'user';
	this._allowTokenReuse = options.allowTokenReuse;
	this._skipForceSessionSave = (options.skipForceSessionSave) ? true : false;
}

/**
 * Returns express middleware which will look for token / UID query parameters and
 * authenticate the user if they are provided and valid. A typical URL that is
 * accepted by acceptToken() does look like this:
 * http://www.example.com?token=TOKEN&uid=UID
 * Simply calls the next middleware in case no token / uid has been submitted or if
 * the supplied token / uid are not valid
 *
 * @example
 * app.use(passwordless.sessionSupport());
 * // Look for tokens in any URL requested from the server
 * app.use(passwordless.acceptToken());
 * 		
 * @param  {Object} [options]
 * @param  {String} [options.successRedirect] - If set, the user will be redirected
 * to the supplied URL in case of a successful authentication. If not set but the 
 * authentication has been successful, the next middleware will be called. This 
 * option is overwritten by option.enableOriginRedirect if set and an origin has 
 * been supplied. It is strongly recommended to set this option to avoid leaking 
 * valid tokens via the HTTP referrer. In case of session-less operation, though, 
 * you might want to ignore this flag for efficient operation (default: null)
 * @param  {String} [options.tokenField] - The query parameter used to submit the
 * token (default: 'token')
 * @param  {String} [options.uidField] - The query parameter used to submit the 
 * user id (default: 'uid')
 * @param  {Boolean} [options.allowPost] - If set, acceptToken() will also look for
 * POST parameters to contain the token and uid (default: false)
 * @param  {String} [options.failureFlash] - The error message to be flashed in case
 * a token and uid were provided but the authentication failed. Using this option
 * requires flash middleware such as connect-flash. The error message will be stored
 * as 'passwordless' (example: 'This token is not valid anymore!', default: null)
 * @param  {String} [options.successFlash] - The success message to be flashed in case
 * the supplied token and uid were accepted. Using this option requires flash middleware 
 * such as connect-flash. The success message will be stored as 'passwordless-success' 
 * (example: 'You are now logged in!', default: null)
 * @param  {Boolean} [options.enableOriginRedirect] - If set to true, the user will
 * be redirected to the URL he originally requested before he was redirected to the 
 * login page. Requires that the URL was stored in the TokenStore when requesting a
 * token through requestToken() (default: false)
 * @return {ExpressMiddleware} Express middleware
 * @throws {Error} Will throw an error if there is no valid TokenStore, if failureFlash
 * or successFlash is used without flash middleware or allowPost is used without body 
 * parser middleware
 */
Passwordless.prototype.acceptToken = function(options) {
	var self = this;
	options = options || {};
	return function(req, res, next) {
		if(!self._tokenStore) {
			throw new Error('Passwordless is missing a TokenStore. Are you sure you called passwordless.init()?');
		}

		var tokenField = (options.tokenField) ? options.tokenField : 'token';
		var uidField = (options.uidField) ? options.uidField : 'uid';

		var token = req.query[tokenField], uid = req.query[uidField];
		if(!token && !uid && options.allowPost) {
			if(!req.body) {
				throw new Error('req.body does not exist: did you require middleware to accept POST data (such as body-parser) before calling acceptToken?')
			} else if(req.body[tokenField] && req.body[uidField]) {
				token = req.body[tokenField];
				uid = req.body[uidField];
			}
		}

		if((options.failureFlash || options.successFlash) && !req.flash) {
			throw new Error('To use failureFlash, flash middleware is required such as connect-flash');
		}

		if(token && uid) {
			self._tokenStore.authenticate(token, uid.toString(), function(error, valid, referrer) {
				if(valid) {
					var success = function() {

						req[self._userProperty] = uid;
						if(req.session) {
							req.session.passwordless = req[self._userProperty];
						}
						if(options.successFlash) {
							req.flash('passwordless-success', options.successFlash);
						}
						if(options.enableOriginRedirect && referrer) {
							// next() will not be called
							return self._redirectWithSessionSave(req, res, next, referrer);
						}
						if(options.successRedirect) {
							// next() will not be called, and
							// enableOriginRedirect has priority
							return self._redirectWithSessionSave(req, res, next, options.successRedirect);
						}
						next();
					}

					// Invalidate token, except allowTokenReuse has been set
					if(!self._allowTokenReuse) {
						self._tokenStore.invalidateUser(uid, function(err) {
							if(err) {
								next('TokenStore.invalidateUser() error: ' + error);
							} else {
								success();
							}
						})
					} else {
						success();
					}
				} else if(error) {
					next('TokenStore.authenticate() error: ' + error);
				} else if(options.failureFlash) {
					req.flash('passwordless', options.failureFlash);
					next();
				} else {
					next();
				}
			});
		} else {
			next();
		}
	}
}

/**
 * Returns express middleware that ensures that only successfully authenticated users
 * have access to any middleware or responses that follows this middleware. Can either
 * be used for individual URLs or a certain path and any sub-elements. By default, a
 * 401 error message is returned if the user has no access to the underlying resource.
 *
 * @example
 * router.get('/admin', passwordless.restricted({ failureRedirect: '/login' }),
 * 	function(req, res) {
 *  	res.render('admin', { user: req.user });
 * 	});
 * 			
 * @param  {Object} [options]
 * @param  {String} [options.failureRedirect] - If provided, the user will be redirected
 * to the given URL in case she is not authenticated. This would typically by a login 
 * page (example: '/login', default: null)
 * @param  {String} [options.failureFlash] - The error message to be flashed in case
 * the user is not authenticated. Using this option requires flash middleware such as 
 * connect-flash. The message will be stored as 'passwordless'. Can only be used in
 * combination with failureRedirect (example: 'No access!', default: null)
 * @param  {String} [options.originField] - If set, the originally requested URL will
 * be passed as query param (with the supplied name) to the redirect URL provided by
 * failureRedirect. Can only be used in combination with failureRedirect (example: 
 * 'origin', default: null)
 * @return {ExpressMiddleware} Express middleware
 * @throws {Error} Will throw an error if failureFlash is used without flash middleware, 
 * failureFlash is used without failureRedirect, or originField is used without 
 * failureRedirect
 */
Passwordless.prototype.restricted = function(options) {
	var self = this;
	return function(req, res, next) {
		if(req[self._userProperty]) {
			return next();
		}

		// not authorized
		options = options || {};
		if(options.failureRedirect) {
			var queryParam = '';
			if(options.originField){
				var parsedRedirectUrl = url.parse(options.failureRedirect), queryParam = '?';
				if(parsedRedirectUrl.query) {
					queryParam = '&';
				}
				queryParam += options.originField + '=' + encodeURIComponent(req.originalUrl);
			}

			if(options.failureFlash) {
				if(!req.flash) {
					throw new Error('To use failureFlash, flash middleware is requied such as connect-flash');
				} else {
					req.flash('passwordless', options.failureFlash);
				}
			}	

			self._redirectWithSessionSave(req, res, next, options.failureRedirect + queryParam);
		} else if(options.failureFlash) {
			throw new Error('failureFlash cannot be used without failureRedirect');
		} else if(options.originField) {
			throw new Error('originField cannot be used without failureRedirect');
		} else {
			self._send401(res, 'Provide a token');
  		}
	}
}

/**
 * Logs out the current user and invalidates any tokens that are still valid for the user
 *
 * @example
 * router.get('/logout', passwordless.logout( {options.successFlash: 'All done!'} ),
 * 	function(req, res) {
 * 		res.redirect('/');
 * });
 * 		
 * @param  {Object} [options]
 * @param  {String} [options.successFlash] - The success message to be flashed in case
 * has been logged in an the logout proceeded successfully. Using this option requires 
 * flash middleware such as connect-flash. The success message will be stored as 
 * 'passwordless-success'. (example: 'You are now logged in!', default: null)
 * 
 * @return {ExpressMiddleware} Express middleware
 * @throws {Error} Will throw an error if successFlash is used without flash middleware
 */
Passwordless.prototype.logout = function(options) {
	var self = this;
	return function(req, res, next) {
		if(req.session && req.session.passwordless) {
			delete req.session.passwordless;
		}
		if(req[self._userProperty]) {
			self._tokenStore.invalidateUser(req[self._userProperty], function() {
				delete req[self._userProperty];
				if(options && options.successFlash) {
					if(!req.flash) {
						return next('To use successFlash, flash middleware is requied such as connect-flash');
					} else {
						req.flash('passwordless-success', options.successFlash);
					}
				}
				next();
			});
		} else {
			next();
		}
	}
}

/**
 * By adding this middleware function to a route, Passwordless automatically restores
 * the logged in user from the session. In 90% of the cases, this is what is required. 
 * However, Passwordless can also work without session support in a stateless mode.
 *
 * @example
 * var app = express();
 * var passwordless = new Passwordless(new DBTokenStore());
 * 		
 * app.use(cookieParser());
 * app.use(expressSession({ secret: '42' }));
 * 		
 * app.use(passwordless.sessionSupport());
 * app.use(passwordless.acceptToken());
 *
 * @return {ExpressMiddleware} Express middleware
 * @throws {Error} Will throw an error no session middleware has been supplied
 */
Passwordless.prototype.sessionSupport = function() {
	var self = this;
	return function(req, res, next) {
		if(!req.session) {
			throw new Error('sessionSupport requires session middleware such as expressSession');
		} else if (req.session.passwordless) {
			req[self._userProperty] = req.session.passwordless;
		}
		next();
	}
}

/**
 * @callback getUserID
 * @param  {Object} user Contact details provided by the user (e.g. email address)
 * @param  {String} delivery Delivery method used (can be null)
 * @param  {function(error, user)} callback To be called in the format 
 * callback(error, user), where error is either null or an error message and user 
 * is either null if not user has been found or the user ID.
 * @param  {Object} req Express request object
 */

/**
 * Requests a token from Passwordless for a specific user and calls the delivery strategy 
 * to send the token to the user. Sends back a 401 error message if the user is not valid 
 * or a 400 error message if no user information has been transmitted at all. By default,
 * POST params will be expected
 * 
 * @example
 * router.post('/sendtoken', 
 * 	passwordless.requestToken(
 * 		function(user, delivery, callback, req) {
 * 			// usually you would want something like:
 * 			User.find({email: user}, callback(ret) {
 * 				if(ret)
 * 					callback(null, ret.id)
 * 				else
 * 					callback(null, null)
 * 			})
 * 		}),
 * 		function(req, res) {
 * 			res.render('sent');
 * 		});
 *
 * @param  {getUserID} getUserID The function called to resolve the supplied user contact 
 * information (e.g. email) into a proper user ID: function(user, delivery, callback, req)
 * where user contains the contact details provided, delivery the method used, callback 
 * expects a call in the format callback(error, user), where error is either null or an 
 * error message and user is either null if not user has been found or the user ID. req 
 * contains the Express request object
 * @param  {Object} [options]
 * @param  {String} [options.failureRedirect] - If provided, the user will be redirected
 * to the given URL in case the user details were not provided or could not be validated
 * by getUserId. This could typically by a login page (example: '/login', default: null)
 * @param  {String} [options.failureFlash] - The error message to be flashed in case
 * the user details could not be validated. Using this option requires flash middleware 
 * such as connect-flash. The message will be stored as 'passwordless'. Can only be used 
 * in combination with failureRedirect (example: 'Your user details seem strange', 
 * default: null)
 * @param  {String} [options.successFlash] - The message to be flashed in case the tokens
 * were send out successfully. Using this option requires flash middleware such as 
 * connect-flash. The message will be stored as 'passwordless-success '. 
 * (example: 'Your token has been send', default: null)
 * @param  {String} [options.userField] - The field which contains the user's contact 
 * detail such as her email address (default: 'user')
 * @param  {String} [options.deliveryField] - The field which contains the name of the
 * delivery method to be used. Only needed if several strategies have been added with
 * addDelivery() (default: null)
 * @param  {String} [options.originField] - If set, requestToken() will look for any
 * URLs in this field that will be stored in the token database so that the user can
 * be redirected to this URL as soon as she is authenticated. Usually used to redirect 
 * the user to the resource that she originally requested before being redirected to
 * the login page (default: null)
 * @param  {Boolean} [options.allowGet] - If set, requestToken() will look for GET 
 * parameters instead of POST (default: false)
 * @return {ExpressMiddleware} Express middleware
 * @throws {Error} Will throw an error if failureFlash is used without flash middleware, 
 * failureFlash is used without failureRedirect, successFlash is used without flash 
 * middleware, no body parser is used and POST parameters are expected, or if no 
 * delivery method has been added
 */
Passwordless.prototype.requestToken = function(getUserID, options) {
	var self = this;
	options = options || {};
	
	return function(req, res, next) {
		var sendError = function(statusCode, authenticate) {
			if(options.failureRedirect) {
				if(options.failureFlash) {
					req.flash('passwordless', options.failureFlash);
				}
				self._redirectWithSessionSave(req, res, next, options.failureRedirect);
			} else {
				if(statusCode === 401) {
					self._send401(res, authenticate)
				} else {
					res.status(statusCode).send();
				}
			}
		}

		if(!self._tokenStore) {
			throw new Error('Passwordless is missing a TokenStore. Are you sure you called passwordless.init()?');
		}

		if(!req.body && !options.allowGet) {
			throw new Error('req.body does not exist: did you require middleware to accept POST data (such as body-parser) before calling acceptToken?')
		} else if(!self._defaultDelivery && Object.keys(self._deliveryMethods).length === 0) {
			throw new Error('passwordless requires at least one delivery method which can be added using passwordless.addDelivery()');
		} else if((options.successFlash || options.failureFlash) && !req.flash) {
			throw new Error('To use failureFlash or successFlash, flash middleware is required such as connect-flash');
		} else if(options.failureFlash && !options.failureRedirect) {
			throw new Error('failureFlash cannot be used without failureRedirect');
		}

		var userField = (options.userField) ? options.userField : 'user';
		var deliveryField = (options.deliveryField) ? options.deliveryField : 'delivery';
		var originField = (options.originField) ? options.originField : null;

		var user, delivery, origin;
		if(req.body && req.method === "POST") {
			user = req.body[userField];
			delivery = req.body[deliveryField];
			if(originField) {
				origin = req.body[originField];
			}
		} else if(options.allowGet && req.method === "GET") {
			user = req.query[userField];
			delivery = req.query[deliveryField];
			if(originField) {
				origin = req.query[originField];
			}
		}

		var deliveryMethod = self._defaultDelivery;
		if(delivery && self._deliveryMethods[delivery]) {
			deliveryMethod = self._deliveryMethods[delivery];
		}

		if(typeof user === 'string' && user.length === 0) {
			return sendError(401, 'Provide a valid user');
		} else if(!deliveryMethod || !user) {
			return sendError(400);
		}

		getUserID(user, delivery, function(uidError, uid) {
			if(uidError) {
				next(new Error('Error on the user verification layer: ' + uidError));
			} else if(uid) {
				var token;
				try {
					if(deliveryMethod.options.numberToken && deliveryMethod.options.numberToken.max) {
						token = self._generateNumberToken(deliveryMethod.options.numberToken.max);
					} else {
						token = (deliveryMethod.options.tokenAlgorithm || self._generateToken())();
					}
				} catch(err) {
					next(new Error('Error while generating a token: ' + err));
				}
				var ttl = deliveryMethod.options.ttl || 60 * 60 * 1000;

				self._tokenStore.storeOrUpdate(token, uid.toString(), ttl, origin, function(storeError) {
					if(storeError) {
						next(new Error('Error on the storage layer: ' + storeError));
					} else {
						deliveryMethod.sendToken(token, uid, user, function(deliveryError) {
							if(deliveryError) {
								next(new Error('Error on the deliveryMethod delivery layer: ' + deliveryError));
							} else {
								if(!req.passwordless) {
									req.passwordless = {};
								}
								req.passwordless.uidToAuth = uid;
								if(options.successFlash) {
									req.flash('passwordless-success', options.successFlash);
								}
								next();
							}
						}, req)		
					}	
				});
			} else {
				sendError(401, 'Provide a valid user');
			}
		}, req)
	}
}

/**
 * @callback sendToken
 * @param  {String} tokenToSend The token to send
 * @param  {Object} uidToSend The UID that has to be part of the token URL
 * @param  {String} recipient the target such as an email address or a phone number 
 * depending on the user input
 * @param  {function(error)} callback Has to be called either with no parameters or 
 * with callback({String}) in case of any issues during delivery
 * @param  {Object} req The request object
 */

/**
 * Adds a new delivery method to Passwordless used to transmit tokens to the user. This could, 
 * for example, be an email client or a sms client. If only one method is used, no name has to 
 * provided as it will be the default delivery method. If several methods are used and added, 
 * they will have to be named.
 *
 * @example
 * passwordless.init(new MongoStore(pathToMongoDb));
 * passwordless.addDelivery(
 * 	function(tokenToSend, uidToSend, recipient, callback, req) {
 * 		// Send out token
 * 		smtpServer.send({
 * 			text:    'Hello!\nYou can now access your account here: ' 
 * 				+ host + '?token=' + tokenToSend + '&uid=' + encodeURIComponent(uidToSend), 
 * 			from:    yourEmail, 
 * 			to:      recipient,
 * 			subject: 'Token for ' + host
 * 		}, function(err, message) { 
 * 			if(err) {
 * 				console.log(err);
 * 			}
 * 			callback(err);
 * 		});
 * 	});
 * 	
 * @param {String} [name] - Name of the strategy. Not needed if only one method is added
 * @param {sendToken} sendToken - Method that will be called as
 * function(tokenToSend, uidToSend, recipient, callback, req) to transmit the token to the 
 * user. tokenToSend contains the token, uidToSend the UID that has to be part of the 
 * token URL, recipient contains the target such as an email address or a phone number 
 * depending on the user input, and callback has to be called either with no parameters 
 * or with callback({String}) in case of any issues during delivery
 * @param  {Object} [options]
 * @param  {Number} [options.ttl] - Duration in ms that the token shall be valid 
 * (example: 1000*60*30, default: 1 hour)
 * @param  {function()} [options.tokenAlgorithm] - The algorithm used to generate a token. 
 * Function shall return the token in sync mode (default: Base58 token)
 * @param  {Number} [options.numberToken.max] - Overwrites the default token generator
 * by a random number generator which generates numbers between 0 and max. Cannot be used
 * together with options.tokenAlgorithm
 */
Passwordless.prototype.addDelivery = function(name, sendToken, options) {
	// So that add can be called with (sendToken [, options])
	var defaultUsage = false;
	if(typeof name === 'function') {
		options = sendToken;
		sendToken = name;
		name = undefined;
		defaultUsage = true;
	}
	options = options || {};

	if(typeof sendToken !== 'function' || typeof options !== 'object' 
		|| (name && typeof name !== 'string')) {
		throw new Error('Passwordless.addDelivery called with wrong parameters');
	} else if((options.ttl && typeof options.ttl !== 'number') || 
		(options.tokenAlgorithm && typeof options.tokenAlgorithm !== 'function') ||
		(options.numberToken && (!options.numberToken.max || typeof options.numberToken.max !== 'number'))) {
		throw new Error('One of the provided options is of the wrong format');
	} else if(options.tokenAlgorithm && options.numberToken) {
		throw new Error('options.tokenAlgorithm cannot be used together with options.numberToken');
	} else if(this._defaultDelivery) {
		throw new Error('Only one default delivery method shall be defined and not be mixed up with named methods. Use named delivery methods instead')
	} else if(defaultUsage && Object.keys(this._deliveryMethods).length > 0) {
		throw new Error('Default delivery methods and named delivery methods shall not be mixed up');
	}

	var method = {
			sendToken: sendToken,
			options: options
		};
	if(defaultUsage) {
		this._defaultDelivery = method;
	} else {
		if(this._deliveryMethods[name]) {
			throw new Error('Only one named delivery method with the same name shall be added')
		} else {
			this._deliveryMethods[name] = method;
		}
	}
}

/**
 * Sends a 401 error message back to the user
 * @param  {Object} res - Node's http res object
 * @private
 */
Passwordless.prototype._send401 = function(res, authenticate) {
	res.statusCode = 401;
	if(authenticate) {
		res.setHeader('WWW-Authenticate', authenticate);
	}
	res.end('Unauthorized');
}

/**
 * Avoids a bug in express that might lead to a redirect
 * before the session is actually saved
 * @param  {Object} req - Node's http req object
 * @param  {Object} res - Node's http res object
 * @param  {Function} next - Middleware callback
 * @param  {String} target - URL to redirect to
 * @private
 */
Passwordless.prototype._redirectWithSessionSave = function(req, res, next, target) {
	if (!req.session || this._skipForceSessionSave) {
		return res.redirect(target);
	} else {
		req.session.save(function(err) {
			if (err) {
				return next(err);
			} else {
				res.redirect(target);
			}
		});
	}
};

/**
 * Generates a random token using Node's crypto rng
 * @param  {Number} randomBytes - Random bytes to be generated
 * @return {function()} token-generator function
 * @throws {Error} Will throw an error if there is no sufficient
 * entropy accumulated
 * @private
 */
Passwordless.prototype._generateToken = function(randomBytes) {
	randomBytes = randomBytes || 16;
	return function() {
		var buf = crypto.randomBytes(randomBytes);
		return base58.encode(buf);
	}
};

/**
 * Generates a strong random number between 0 and a maximum value. The
 * maximum value cannot exceed 2^32
 * @param  {Number} max - Maximum number to be generated
 * @return {Number} Random number between 0 and max
 * @throws {Error} Will throw an error if there is no sufficient
 * entropy accumulated
 * @private
 */
Passwordless.prototype._generateNumberToken = function(max) {
	var buf = crypto.randomBytes(4);
	return Math.floor(buf.readUInt32BE(0)%max);
};

module.exports = Passwordless;

/** 
 * Express middleware
 * @name ExpressMiddleware
 * @function
 * @param {Object} req
 * @param {Object} res
 * @param {Object} next
 */