UNPKG

passwordless

Version:

A node.js/express module for passwordless authentication

655 lines (616 loc) 26 kB
'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).toString(); }; module.exports = Passwordless; /** * Express middleware * @name ExpressMiddleware * @function * @param {Object} req * @param {Object} res * @param {Object} next */