diff --git a/README.md b/README.md index 817047a1..c9f397cb 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,26 @@ bcrypt.compare(someOtherPlaintextPassword, hash, function(err, res) { // res == false }); ``` +### with promises +bcrypt uses whatever Promise implementation is available in `global.Promise`. NodeJS >= 0.12 has a native Promise implementation built in. However, this should work in any Promises/A+ compilant implementation. + +Async methods that accept a callback, return a `Promise` when callback is not specified if Promise support is available. + +```javascript +bcrypt.hash(myPlaintextPassword, saltRounds).then(function(hash) { + // Store hash in your password DB. +}); +``` +```javascript +// Load hash from your password DB. +bcrypt.compare(myPlaintextPassword, hash).then(function(res) { + // res == true +}); +bcrypt.compare(someOtherPlaintextPassword, hash).then(function(res) { + // res == false +}); +``` ### sync @@ -151,7 +170,7 @@ If you are using bcrypt on a simple script, using the sync mode is perfectly fin * `rounds` - [OPTIONAL] - the cost of processing the data. (default - 10) * `genSalt(rounds, cb)` * `rounds` - [OPTIONAL] - the cost of processing the data. (default - 10) - * `cb` - [REQUIRED] - a callback to be fired once the salt has been generated. uses eio making it asynchronous. + * `cb` - [OPTIONAL] - a callback to be fired once the salt has been generated. uses eio making it asynchronous. If `cb` is not specified, a `Promise` is returned if Promise support is available. * `err` - First parameter to the callback detailing any errors. * `salt` - Second parameter to the callback providing the generated salt. * `hashSync(data, salt)` @@ -160,7 +179,7 @@ If you are using bcrypt on a simple script, using the sync mode is perfectly fin * `hash(data, salt, cb)` * `data` - [REQUIRED] - the data to be encrypted. * `salt` - [REQUIRED] - the salt to be used to hash the password. if specified as a number then a salt will be generated with the specified number of rounds and used (see example under **Usage**). - * `cb` - [REQUIRED] - a callback to be fired once the data has been encrypted. uses eio making it asynchronous. + * `cb` - [OPTIONAL] - a callback to be fired once the data has been encrypted. uses eio making it asynchronous. If `cb` is not specified, a `Promise` is returned if Promise support is available. * `err` - First parameter to the callback detailing any errors. * `encrypted` - Second parameter to the callback providing the encrypted form. * `compareSync(data, encrypted)` @@ -169,7 +188,7 @@ If you are using bcrypt on a simple script, using the sync mode is perfectly fin * `compare(data, encrypted, cb)` * `data` - [REQUIRED] - data to compare. * `encrypted` - [REQUIRED] - data to be compared to. - * `cb` - [REQUIRED] - a callback to be fired once the data has been compared. uses eio making it asynchronous. + * `cb` - [OPTIONAL] - a callback to be fired once the data has been compared. uses eio making it asynchronous. If `cb` is not specified, a `Promise` is returned if Promise support is available. * `err` - First parameter to the callback detailing any errors. * `same` - Second parameter to the callback providing whether the data and encrypted forms match [true | false]. * `getRounds(encrypted)` - return the number of rounds used to encrypt a given hash diff --git a/bcrypt.js b/bcrypt.js index 6d599485..20fba30f 100644 --- a/bcrypt.js +++ b/bcrypt.js @@ -7,6 +7,8 @@ var bindings = require(binding_path); var crypto = require('crypto'); +var promises = require('./lib/promises'); + /// generate a salt (sync) /// @param {Number} [rounds] number of rounds (default 10) /// @return {String} salt @@ -36,6 +38,10 @@ module.exports.genSalt = function(rounds, ignore, cb) { cb = arguments[1]; } + if (!cb) { + return promises.promise(this.genSalt, this, arguments); + } + // default 10 rounds if (!rounds) { rounds = 10; @@ -46,10 +52,6 @@ module.exports.genSalt = function(rounds, ignore, cb) { }); } - if (!cb) { - return; - } - crypto.randomBytes(16, function(error, randomBytes) { if (error) { cb(error); @@ -97,6 +99,16 @@ module.exports.hash = function(data, salt, cb) { }); } + // cb exists but is not a function + // return a rejecting promise + if (cb && typeof cb !== 'function') { + return promises.reject(new Error('cb must be a function or null to return a Promise')); + } + + if (!cb) { + return promises.promise(this.hash, this, arguments); + } + if (data == null || salt == null) { return process.nextTick(function() { cb(new Error('data and salt arguments required')); @@ -109,9 +121,6 @@ module.exports.hash = function(data, salt, cb) { }); } - if (!cb || typeof cb !== 'function') { - return; - } if (typeof salt === 'number') { return module.exports.genSalt(salt, function(err, salt) { @@ -155,8 +164,14 @@ module.exports.compare = function(data, hash, cb) { }); } - if (!cb || typeof cb !== 'function') { - return; + // cb exists but is not a function + // return a rejecting promise + if (cb && typeof cb !== 'function') { + return promises.reject(new Error('cb must be a function or null to return a Promise')); + } + + if (!cb) { + return promises.promise(this.compare, this, arguments); } return bindings.compare(data, hash, cb); diff --git a/lib/promises.js b/lib/promises.js new file mode 100644 index 00000000..50e38311 --- /dev/null +++ b/lib/promises.js @@ -0,0 +1,46 @@ +'use strict'; + +/// encapsulate a method with a node-style callback in a Promise +/// @param {object} 'this' of the encapsulated function +/// @param {function} function to be encapsulated +/// @param {Array-like} args to be passed to the called function +/// @return {Promise} a Promise encapuslaing the function +module.exports.promise = function (fn, context, args) { + + //can't do anything without Promise so fail silently + if (typeof Promise === 'undefined') { + return; + } + + if (!Array.isArray(args)) { + args = Array.prototype.slice.call(args); + } + + if (typeof fn !== 'function') { + return Promise.reject(new Error('fn must be a function')); + } + + return new Promise(function(resolve, reject) { + args.push(function(err, data) { + if (err) { + reject(err); + } else { + resolve(data); + } + }); + + fn.apply(context, args); + }); +}; + +/// @param {err} the error to be thrown +module.exports.reject = function (err) { + + // silently swallow errors if Promise is not defined + // emulating old behavior + if (typeof Promise === 'undefined') { + return; + } + + return Promise.reject(err); +}; diff --git a/test/promise.js b/test/promise.js new file mode 100644 index 00000000..f64ca413 --- /dev/null +++ b/test/promise.js @@ -0,0 +1,188 @@ +var bcrypt = require('../bcrypt'); + +var fail = function(assert, error) { + assert.ok(false, error); + assert.done(); +}; + +// only run these tests if Promise is available +if (typeof Promise !== 'undefined') { + module.exports = { + test_salt_returns_promise_on_no_args: function(assert) { + // make sure test passes with non-native implementations such as bluebird + // http://stackoverflow.com/questions/27746304/how-do-i-tell-if-an-object-is-a-promise + assert.ok(typeof bcrypt.genSalt().then === 'function', "Should return a promise"); + assert.done(); + }, + test_salt_length: function(assert) { + assert.expect(2); + bcrypt.genSalt(10).then(function(salt) { + assert.ok(typeof salt !== 'undefined', 'salt must not be undefined'); + assert.equals(29, salt.length, "Salt isn't the correct length."); + assert.done(); + }); + }, + test_salt_rounds_is_string_number: function(assert) { + assert.expect(1); + bcrypt.genSalt('10').then(function() { + fail(assert, "should not be resolved"); + }).catch(function(err) { + assert.ok((err instanceof Error), "Should be an Error. genSalt requires round to be of type number."); + }).then(function() { + assert.done(); + }); + }, + test_salt_rounds_is_string_non_number: function(assert) { + assert.expect(1); + bcrypt.genSalt('b').then(function() { + fail(assert, "should not be resolved"); + }).catch(function(err) { + assert.ok((err instanceof Error), "Should be an Error. genSalt requires round to be of type number."); + }).then(function() { + assert.done(); + }); + }, + test_hash: function(assert) { + assert.expect(1); + bcrypt.genSalt(10).then(function(salt) { + return bcrypt.hash('password', salt); + }).then(function(res) { + assert.ok(res, "Res should be defined."); + assert.done(); + }); + }, + test_hash_rounds: function(assert) { + assert.expect(1); + bcrypt.hash('bacon', 8).then(function(hash) { + assert.equals(bcrypt.getRounds(hash), 8, "Number of rounds should be that specified in the function call."); + assert.done(); + }); + }, + test_hash_empty_strings: function(assert) { + assert.expect(2); + Promise.all([ + bcrypt.genSalt(10).then(function(salt) { + return bcrypt.hash('', salt); + }).then(function(res) { + assert.ok(res, "Res should be defined even with an empty pw."); + }), + bcrypt.hash('', '').then(function() { + fail(assert, "should not be resolved") + }).catch(function(err) { + assert.ok(err); + }), + ]).then(function() { + assert.done(); + }); + }, + test_hash_no_params: function(assert) { + assert.expect(1); + bcrypt.hash().then(function() { + fail(assert, "should not be resolved"); + }).catch(function(err) { + assert.ok(err, "Should be an error. No params."); + }).then(function() { + assert.done(); + }); + }, + test_hash_one_param: function(assert) { + assert.expect(1); + bcrypt.hash('password').then(function() { + fail(assert, "should not be resolved"); + }).catch(function(err) { + assert.ok(err, "Should be an error. No salt."); + }).then(function() { + assert.done(); + }); + }, + test_hash_salt_validity: function(assert) { + assert.expect(3); + Promise.all( + [ + bcrypt.hash('password', '$2a$10$somesaltyvaluertsetrse').then(function(enc) { + assert.ok(enc, "should be resolved with a value"); + }), + bcrypt.hash('password', 'some$value').then(function() { + fail(assert, "should not resolve"); + }).catch(function(err) { + assert.notEqual(err, undefined); + assert.equal(err.message, "Invalid salt. Salt must be in the form of: $Vers$log2(NumRounds)$saltvalue"); + }) + ]).then(function() { + assert.done(); + }); + }, + test_verify_salt: function(assert) { + assert.expect(2); + bcrypt.genSalt(10).then(function(salt) { + var split_salt = salt.split('$'); + assert.ok(split_salt[1], '2a'); + assert.ok(split_salt[2], '10'); + assert.done(); + }); + }, + test_verify_salt_min_rounds: function(assert) { + assert.expect(2); + bcrypt.genSalt(1).then(function(salt) { + var split_salt = salt.split('$'); + assert.ok(split_salt[1], '2a'); + assert.ok(split_salt[2], '4'); + assert.done(); + }); + }, + test_verify_salt_max_rounds: function(assert) { + assert.expect(2); + bcrypt.genSalt(100).then(function(salt) { + var split_salt = salt.split('$'); + assert.ok(split_salt[1], '2a'); + assert.ok(split_salt[2], '31'); + assert.done(); + }); + }, + test_hash_compare: function(assert) { + assert.expect(3); + bcrypt.genSalt(10).then(function(salt) { + assert.equals(29, salt.length, "Salt isn't the correct length."); + return bcrypt.hash("test", salt); + }).then(function(hash) { + return Promise.all( + [ + bcrypt.compare("test", hash).then(function(res) { + assert.equal(res, true, "These hashes should be equal."); + }), + bcrypt.compare("blah", hash).then(function(res) { + assert.equal(res, false, "These hashes should not be equal."); + }) + ]).then(function() { + assert.done(); + }); + }); + }, + test_hash_compare_empty_strings: function(assert) { + assert.expect(2); + var hash = bcrypt.hashSync("test", bcrypt.genSaltSync(10)); + bcrypt.compare("", hash).then(function(res) { + assert.equal(res, false, "These hashes should be equal."); + return bcrypt.compare("", ""); + }).then(function(res) { + assert.equal(res, false, "These hashes should be equal."); + assert.done(); + }); + }, + test_hash_compare_invalid_strings: function(assert) { + var fullString = 'envy1362987212538'; + var hash = '$2a$10$XOPbrlUPQdwdJUpSrIF6X.LbE14qsMmKGhM1A8W9iqaG3vv1BD7WC'; + var wut = ':'; + Promise.all([ + bcrypt.compare(fullString, hash).then(function(res) { + assert.ok(res); + }), + bcrypt.compare(fullString, wut).then(function(res) { + assert.ok(!res); + }) + ]).then(function() { + assert.done(); + }); + } + }; +} \ No newline at end of file