diff --git a/Dockerfile b/Dockerfile index 90503a1..f2b4ef1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,11 @@ FROM node:6-slim EXPOSE 8000 MAINTAINER Jean-Christophe Hoelt +# 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 diff --git a/package.json b/package.json index 51a15d7..9520d97 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/users/LoginsUsers.js b/src/users/LoginsUsers.js index 69959ef..ab35836 100644 --- a/src/users/LoginsUsers.js +++ b/src/users/LoginsUsers.js @@ -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'); @@ -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) @@ -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) diff --git a/src/users/detect-hash.js b/src/users/detect-hash.js new file mode 100644 index 0000000..5d5bcc7 --- /dev/null +++ b/src/users/detect-hash.js @@ -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}; diff --git a/src/users/verify-password.js b/src/users/verify-password.js new file mode 100644 index 0000000..32bc452 --- /dev/null +++ b/src/users/verify-password.js @@ -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); +}; diff --git a/tests/users/LoginsUsers.test.js b/tests/users/LoginsUsers.test.js index 469dff1..0589b3d 100644 --- a/tests/users/LoginsUsers.test.js +++ b/tests/users/LoginsUsers.test.js @@ -1,6 +1,7 @@ 'use strict'; const LoginsUsers = require('../../src/users/LoginsUsers'); +const {InvalidCredentialsError} = require('../../src/errors'); describe('LoginsUsers', () => { describe('#hashPassword()', () => { @@ -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()', () => { @@ -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]); @@ -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'); @@ -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'); @@ -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(); + }); + }); }); }); diff --git a/tests/users/detect-hash.test.js b/tests/users/detect-hash.test.js new file mode 100644 index 0000000..b50823f --- /dev/null +++ b/tests/users/detect-hash.test.js @@ -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)); + }); +}); diff --git a/tests/users/verify-password.test.js b/tests/users/verify-password.test.js new file mode 100644 index 0000000..c6f7ff3 --- /dev/null +++ b/tests/users/verify-password.test.js @@ -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(); + }); + }); +});