Skip to content

Commit

Permalink
Merge pull request #24 from j3k0/feature/bcrypt
Browse files Browse the repository at this point in the history
`bcrypt` support
  • Loading branch information
j3k0 committed Apr 24, 2017
2 parents 81b2e11 + 79a3372 commit 241e0cc
Show file tree
Hide file tree
Showing 8 changed files with 232 additions and 8 deletions.
5 changes: 5 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ FROM node:6-slim
EXPOSE 8000
MAINTAINER Jean-Christophe Hoelt <hoelt@fovea.cc>

# Install python
RUN apt-get update \
&& apt-get install -y make python g++ \
&& rm -fr /var/lib/apt

# Create 'app' user
RUN useradd app -d /home/app

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"dependencies": {
"async": "^2.1.4",
"authdb": "^0.3.0",
"bcrypt": "^1.0.2",
"bunyan": "^1.8.1",
"curtain-down": "^1.0.0",
"ganomede-tagizer": "^2.0.0",
Expand Down
9 changes: 7 additions & 2 deletions src/users/LoginsUsers.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
const async = require('async');
const crypto = require('crypto');
const pbkdf = require('password-hash-and-salt');
const {detectHash, hashes} = require('./detect-hash');
const verifyPassword = require('./verify-password');
const Db = require('../db/db');
const {UserNotFoundError, InvalidCredentialsError} = require('../errors');
const config = require('../../config');
Expand All @@ -20,7 +22,9 @@ class LoginsUsers {

// callback(err, hashString)
hashPassword (password, callback) {
pbkdf(password).hash(callback);
return detectHash(password) === hashes.plainText
? pbkdf(password).hash(callback)
: setImmediate(callback, null, password);
}

// callback(err, authTokenString)
Expand All @@ -38,9 +42,10 @@ class LoginsUsers {
login (userId, password, token, callback) {
if (config.secret === password)
return this.createToken(userId, token, callback);

async.waterfall([
(cb) => this.db.get(`id:${userId}`, cb),
(userDoc, cb) => pbkdf(password).verifyAgainst(userDoc.hash, cb),
(userDoc, cb) => verifyPassword(password, userDoc.hash, cb),
(matches, cb) => {
return matches
? this.createToken(userId, null, cb)
Expand Down
30 changes: 30 additions & 0 deletions src/users/detect-hash.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
'use strict';

const hashes = {
bcrypt: 'detect-hash::hash-bcrypt',
pbkdf2: 'detect-hash::hash-pbkdf2',
plainText: 'detect-hash::plain-text'
};

const regexes = {
// https://en.wikipedia.org/wiki/Bcrypt
// https://www.npmjs.com/package/bcrypt#hash-info
// I added `=` since it is base64 (though it probably won't have padding)
bcrypt: /^\$2[aby]\$\d\d\$[A-Za-z0-9./=]{53}$/,
// https://www.npmjs.com/package/password-hash-and-salt#created-hash
// Looks like hex to me.
pbkdf2: /^pbkdf2\$10000\$[0-9a-f]{128}\$[0-9a-f]{128}$/,
};

const detectHash = (str) => {
if (typeof str !== 'string')
throw new TypeError(`\`str\` must be a string, got \`${typeof str}\` instead`);

const kind = Object.keys(regexes).find(hashKind => regexes[hashKind].test(str));

return kind
? hashes[kind]
: hashes.plainText;
};

module.exports = {detectHash, hashes};
27 changes: 27 additions & 0 deletions src/users/verify-password.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
'use strict';

const bcrypt = require('bcrypt');
const pbkdf = require('password-hash-and-salt');
const {detectHash, hashes} = require('./detect-hash');

const verifiers = {
[hashes.bcrypt]: (plain, hash, cb) => bcrypt.compare(plain, hash, cb),
[hashes.pbkdf2]: (plain, hash, cb) => pbkdf(plain).verifyAgainst(hash, cb)
};

const wrapVerifierCallback = (cb) => (err, matches) => {
return err
? cb(err)
: cb(null, !!matches);
};

// callback(err, matches: Boolean)
module.exports = (plainText, hash, cb) => {
const verifier = verifiers[detectHash(hash)];
const callback = wrapVerifierCallback(cb);

if (!verifier)
return setImmediate(callback, new Error('Uknown Hash Format'));

verifier(plainText, hash, callback);
};
70 changes: 64 additions & 6 deletions tests/users/LoginsUsers.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use strict';

const LoginsUsers = require('../../src/users/LoginsUsers');
const {InvalidCredentialsError} = require('../../src/errors');

describe('LoginsUsers', () => {
describe('#hashPassword()', () => {
Expand All @@ -12,6 +13,26 @@ describe('LoginsUsers', () => {
done();
});
});

it('passes pbkdf2 hashes through as is', (done) => {
const inputHash = 'pbkdf2$10000$5786bea278988484b2ef02fffa2cbc759e4f20bfe3464f43e3b9e56356a09957ce2d77e78c743d7caad381a8818994d55c72e6543ab8a133958494436e997b79$8e8234c0675ec1af532f83acf9db08dec2e07ed425acbba3a53dff1f78921ccc021169312fc144109de1890c8f58b17745a8df250ab7f86cc13515e550dd79aa';

new LoginsUsers().hashPassword(inputHash, (err, hash) => {
expect(err).to.be.null;
expect(hash).to.equal(inputHash);
done();
});
});

it('passes bcrypt hashes through as is', (done) => {
const inputHash = '$2a$04$tjJzUHkOoR.xzyMTCDxfSu6Vq4NL1u3/9m2NT9bu7.ECODNEWi41K';

new LoginsUsers().hashPassword(inputHash, (err, hash) => {
expect(err).to.be.null;
expect(hash).to.equal(inputHash);
done();
});
});
});

describe('#createToken()', () => {
Expand Down Expand Up @@ -48,22 +69,26 @@ describe('LoginsUsers', () => {

describe('#login()', () => {
const loginTest = () => {
const verifyPassword = td.replace('../../src/users/verify-password', td.function('verifyPassword'));
const LoginsUsers = require('../../src/users/LoginsUsers');
const db = td.object(['get']);
const authdb = td.object(['addAccount']);
const hash = 'pbkdf2$10000$5786bea278988484b2ef02fffa2cbc759e4f20bfe3464f43e3b9e56356a09957ce2d77e78c743d7caad381a8818994d55c72e6543ab8a133958494436e997b79$8e8234c0675ec1af532f83acf9db08dec2e07ed425acbba3a53dff1f78921ccc021169312fc144109de1890c8f58b17745a8df250ab7f86cc13515e550dd79aa';

td.when(db.get('id:jdoe', td.callback))
.thenCallback(null, {id: 'jdoe', hash: 'pbkdf2$10000$5786bea278988484b2ef02fffa2cbc759e4f20bfe3464f43e3b9e56356a09957ce2d77e78c743d7caad381a8818994d55c72e6543ab8a133958494436e997b79$8e8234c0675ec1af532f83acf9db08dec2e07ed425acbba3a53dff1f78921ccc021169312fc144109de1890c8f58b17745a8df250ab7f86cc13515e550dd79aa'});
.thenCallback(null, {id: 'jdoe', hash});

td.when(authdb.addAccount(
td.matchers.isA(String),
td.matchers.contains({username: 'jdoe'}),
td.callback))
.thenCallback(null, 'OK');
return {db, authdb};

return {LoginsUsers, verifyPassword, db, authdb, hash};
};

it('allows to use API_SECRET as a password', (done) => {
const {db, authdb} = loginTest();
const {LoginsUsers, db, authdb} = loginTest();
new LoginsUsers(db, authdb).login('jdoe', process.env.API_SECRET, null, (err, token) => {
expect(err).to.be.null;
expect(token).to.equal(td.explain(authdb.addAccount).calls[0].args[0]);
Expand All @@ -72,7 +97,7 @@ describe('LoginsUsers', () => {
});

it('allows to change token when password is API_SECRET', (done) => {
const {db, authdb} = loginTest();
const {LoginsUsers, db, authdb} = loginTest();
new LoginsUsers(db, authdb).login('jdoe', process.env.API_SECRET, 'my-token', (err, token) => {
expect(err).to.be.null;
expect(token).to.equal('my-token');
Expand All @@ -81,7 +106,11 @@ describe('LoginsUsers', () => {
});

it('disregards the requested token when password is not API_SECRET', (done) => {
const {db, authdb} = loginTest();
const {LoginsUsers, verifyPassword, db, authdb, hash} = loginTest();

td.when(verifyPassword('pwd', hash, td.callback))
.thenCallback(null, true);

new LoginsUsers(db, authdb).login('jdoe', 'pwd', 'my-token', (err, token) => {
expect(err).to.be.null;
expect(token).not.to.equal('my-token');
Expand All @@ -90,12 +119,41 @@ describe('LoginsUsers', () => {
});

it('verifies provided password against Couch hash and creates token', (done) => {
const {db, authdb} = loginTest();
const {LoginsUsers, verifyPassword, db, authdb, hash} = loginTest();

td.when(verifyPassword('pwd', hash, td.callback))
.thenCallback(null, true);

new LoginsUsers(db, authdb).login('jdoe', 'pwd', null, (err, token) => {
expect(err).to.be.null;
expect(token).to.equal(td.explain(authdb.addAccount).calls[0].args[0]);
done();
});
});

it('fails if verifyPassword() returns false', (done) => {
const {LoginsUsers, verifyPassword, db, authdb, hash} = loginTest();

td.when(verifyPassword('pwd', hash, td.callback))
.thenCallback(null, false);

new LoginsUsers(db, authdb).login('jdoe', 'pwd', null, (err, token) => {
expect(err).to.be.instanceof(InvalidCredentialsError);
done();
});
});

it('fails if verifyPassword() errors', (done) => {
const {LoginsUsers, verifyPassword, db, authdb, hash} = loginTest();

td.when(verifyPassword('pwd', hash, td.callback))
.thenCallback(new Error('Oops'));

new LoginsUsers(db, authdb).login('jdoe', 'pwd', null, (err, token) => {
expect(err).to.be.instanceof(Error);
expect(err).to.have.property('message', 'Oops');
done();
});
});
});
});
41 changes: 41 additions & 0 deletions tests/users/detect-hash.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
'use strict';

const {detectHash, hashes} = require('../../src/users/detect-hash');

describe('detectHash()', () => {
it('exports `hash` that contains valid stuff', () => {
const keys = Object.keys(hashes);
expect(hashes).to.be.an('object');
expect(hashes).to.be.ok;
expect(keys.length).to.be.greaterThan(0);
keys.forEach(key => {
const val = hashes[key];
expect(val).to.be.a('string');
expect(val.length).to.be.greaterThan(0);
});
});

it('detects pbkdf2', () => {
[
'pbkdf2$10000$5786bea278988484b2ef02fffa2cbc759e4f20bfe3464f43e3b9e56356a09957ce2d77e78c743d7caad381a8818994d55c72e6543ab8a133958494436e997b79$8e8234c0675ec1af532f83acf9db08dec2e07ed425acbba3a53dff1f78921ccc021169312fc144109de1890c8f58b17745a8df250ab7f86cc13515e550dd79aa',
'pbkdf2$10000$1ac47a036e2db28617a9973b87e5cb8b64bca4f9d2d07896773c29ac162a070a3f537f2074c29d39cda3891fe74d3153d834e2e410d9be9c13ef01c650ab38a8$73ba4660abc46cd0550df78e494991c1b3f9ff09b54afb66b9ebfe7528fc30a97437699b1531119e72964cb0a44864f4223be628b16e6e76578d982b30275c74'
].forEach(hash => expect(detectHash(hash)).to.equal(hashes.pbkdf2));
});

it('detects bcrypt', () => {
[
'$2a$12$HHKaaYswPqVJFROlS/4ZbuN5I2XWb3NE9ChTZ5m174ZRVDSqbGd16',
'$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy',
'$2a$04$tjJzUHkOoR.xzyMTCDxfSu6Vq4NL1u3/9m2NT9bu7.ECODNEWi41K'
].forEach(hash => expect(detectHash(hash)).to.equal(hashes.bcrypt));
});

it('detects plainText', () => {
[
'pbkdf2$10000$_NOT_QUITE_5786bea278988484b2ef02fffa2cbc759e4f20bfe3464f43e3b9e56356a09957ce2d77e78c743d7caad381a8818994d55c72e6543ab8a133958494436e997b79$8e8234c0675ec1af532f83acf9db08dec2e07ed425acbba3a53dff1f78921ccc021169312fc144109de1890c8f58b17745a8df250ab7f86cc13515e550dd79aa',
'$2a$10$_SOMETHING_FISHY_N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy',
'',
'plain text'
].forEach(hash => expect(detectHash(hash)).to.equal(hashes.plainText));
});
});
57 changes: 57 additions & 0 deletions tests/users/verify-password.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
'use strict';

const verifyPassword = require('../../src/users/verify-password');

describe('verifyPassword()', () => {
const passwords = {
correct: 'pwd',
wrong: 'banana'
};

const hashes = {
pbkdf2: 'pbkdf2$10000$5786bea278988484b2ef02fffa2cbc759e4f20bfe3464f43e3b9e56356a09957ce2d77e78c743d7caad381a8818994d55c72e6543ab8a133958494436e997b79$8e8234c0675ec1af532f83acf9db08dec2e07ed425acbba3a53dff1f78921ccc021169312fc144109de1890c8f58b17745a8df250ab7f86cc13515e550dd79aa',
bcrypt: '$2a$04$tjJzUHkOoR.xzyMTCDxfSu6Vq4NL1u3/9m2NT9bu7.ECODNEWi41K',
malformed: 'he he i am bad hash; in fact i am no hash at all!'
};

it('pbkdf2 correct password', (done) => {
verifyPassword(passwords.correct, hashes.pbkdf2, (err, matches) => {
expect(err).to.be.null;
expect(matches).to.be.true;
done();
});
});

it('pbkdf2 wrong password', (done) => {
verifyPassword(passwords.wrong, hashes.pbkdf2, (err, matches) => {
expect(err).to.be.null;
expect(matches).to.be.false;
done();
});
});

it('bcrypt correct password', (done) => {
verifyPassword(passwords.correct, hashes.bcrypt, (err, matches) => {
expect(err).to.be.null;
expect(matches).to.be.true;
done();
});
});

it('bcrypt wrong password', (done) => {
verifyPassword(passwords.wrong, hashes.bcrypt, (err, matches) => {
expect(err).to.be.null;
expect(matches).to.be.false;
done();
});
});

it('errors on wierd looking hashes', (done) => {
verifyPassword(passwords.correct, hashes.malformed, (err, matches) => {
expect(err).to.be.instanceof(Error);
expect(err).to.have.property('message', 'Uknown Hash Format');
expect(matches).to.be.undefined;
done();
});
});
});

0 comments on commit 241e0cc

Please sign in to comment.