Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

bcrypt support #24

Merged
merged 4 commits into from
Apr 24, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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();
});
});
});