Skip to content

Commit

Permalink
Add support for Promises (#458)
Browse files Browse the repository at this point in the history
Return a Promise when a callback is not provided.
  • Loading branch information
recrsn authored and defunctzombie committed Dec 5, 2016
1 parent 27b3b74 commit 2488473
Show file tree
Hide file tree
Showing 4 changed files with 280 additions and 12 deletions.
25 changes: 22 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)`
Expand All @@ -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)`
Expand All @@ -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
Expand Down
33 changes: 24 additions & 9 deletions bcrypt.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -46,10 +52,6 @@ module.exports.genSalt = function(rounds, ignore, cb) {
});
}

if (!cb) {
return;
}

crypto.randomBytes(16, function(error, randomBytes) {
if (error) {
cb(error);
Expand Down Expand Up @@ -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'));
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down
46 changes: 46 additions & 0 deletions lib/promises.js
Original file line number Diff line number Diff line change
@@ -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);
};
188 changes: 188 additions & 0 deletions test/promise.js
Original file line number Diff line number Diff line change
@@ -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();
});
}
};
}

0 comments on commit 2488473

Please sign in to comment.