-
Notifications
You must be signed in to change notification settings - Fork 28
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Dependency updates, ES6 goodies, more tests
- Loading branch information
1 parent
5b26dd6
commit d02ccd5
Showing
7 changed files
with
3,303 additions
and
2,372 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,87 +1,72 @@ | ||
'use strict' | ||
|
||
const Bcrypt = require('bcrypt') | ||
const bcrypt = require('bcrypt') | ||
|
||
const RECOMMENDED_ROUNDS = 12 | ||
const BCRYPT_HASH_REGEX = /^\$2[ayb]\$[0-9]{2}\$[A-Za-z0-9./]{53}$/ | ||
|
||
const REGEXP = /^\$2[ayb]\$[0-9]{2}\$[A-Za-z0-9./]{53}$/ | ||
|
||
module.exports = (options) => { | ||
// Plugin that provides automatic bcrypt hashing for passwords on Objection.js models. | ||
const objectionPassword = (options) => { | ||
// Provide good defaults for the options if possible. | ||
options = Object.assign({ | ||
options = { | ||
allowEmptyPassword: false, | ||
passwordField: 'password', | ||
rounds: RECOMMENDED_ROUNDS | ||
}, options) | ||
rounds: RECOMMENDED_ROUNDS, | ||
...options | ||
} | ||
|
||
// Return the mixin. If your plugin doesn't take options, you can simply export | ||
// the mixin. The factory function is not needed. | ||
// Return the mixin. | ||
// If the plugin doesn't take options, the mixin can be exported directly. The factory function is not needed. | ||
return (Model) => { | ||
return class extends Model { | ||
$beforeInsert (context) { | ||
const maybePromise = super.$beforeInsert(context) | ||
async $beforeInsert (...args) { | ||
await super.$beforeInsert(...args) | ||
|
||
return Promise.resolve(maybePromise).then(() => { | ||
// hash the password | ||
return this.generateHash() | ||
}) | ||
return await this.generateHash() | ||
} | ||
|
||
$beforeUpdate (queryOptions, context) { | ||
const maybePromise = super.$beforeUpdate(queryOptions, context) | ||
async $beforeUpdate (queryOptions, ...args) { | ||
await super.$beforeUpdate(queryOptions, ...args) | ||
|
||
return Promise.resolve(maybePromise).then(() => { | ||
if (queryOptions.patch && this[options.passwordField] === undefined) { | ||
return | ||
} | ||
if (queryOptions.patch && this[options.passwordField] === undefined) { | ||
return | ||
} | ||
|
||
// hash the password | ||
return this.generateHash() | ||
}) | ||
return await this.generateHash() | ||
} | ||
|
||
/** | ||
* Compares a password to a Bcrypt hash | ||
* @param {String} password the password... | ||
* @return {Promise.<Boolean>} whether or not the password was verified | ||
*/ | ||
verifyPassword (password) { | ||
return Bcrypt.compare(password, this[options.passwordField]) | ||
// Compares a password to a bcrypt hash, returns whether or not the password was verified. | ||
async verifyPassword (password) { | ||
return await bcrypt.compare(password, this[options.passwordField]) | ||
} | ||
|
||
/** | ||
* Generates a Bcrypt hash | ||
* @return {Promise.<(String|void)>} returns the hash or null | ||
*/ | ||
generateHash () { | ||
/* Sets the password field to a bcrypt hash of the password. | ||
* Only does so if the password is not already a bcrypt hash. */ | ||
async generateHash () { | ||
const password = this[options.passwordField] | ||
|
||
if (password) { | ||
if (this.constructor.isBcryptHash(password)) { | ||
throw new Error('bcrypt tried to hash another bcrypt hash') | ||
} | ||
|
||
return Bcrypt.hash(password, options.rounds).then((hash) => { | ||
this[options.passwordField] = hash | ||
}) | ||
const hash = await bcrypt.hash(password, options.rounds) | ||
this[options.passwordField] = hash | ||
|
||
return hash | ||
} | ||
|
||
// throw an error if empty passwords aren't allowed | ||
// Throw an error if empty passwords are not allowed. | ||
if (!options.allowEmptyPassword) { | ||
throw new Error('password must not be empty') | ||
} | ||
|
||
return Promise.resolve() | ||
} | ||
|
||
/** | ||
* Detect rehashing for avoiding undesired effects | ||
* @param {String} str A string to be checked | ||
* @return {Boolean} True if the str seems to be a bcrypt hash | ||
*/ | ||
/* Detect rehashing to avoid undesired effects. | ||
* Returns true if the string seems to be a bcrypt hash. */ | ||
static isBcryptHash (str) { | ||
return REGEXP.test(str) | ||
return BCRYPT_HASH_REGEX.test(str) | ||
} | ||
} | ||
} | ||
} | ||
|
||
module.exports = objectionPassword |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,205 @@ | ||
/* eslint-env jest */ | ||
const bcrypt = require('bcrypt') | ||
const { transactionPerTest } = require('objection-transactional-tests') | ||
const { Model } = require('objection') | ||
const Knex = require('knex') | ||
const objectionPassword = require('./index') | ||
|
||
// Set up knex | ||
const knex = Knex({ | ||
client: 'sqlite3', | ||
connection: { | ||
filename: ':memory:' | ||
}, | ||
useNullAsDefault: true | ||
}) | ||
|
||
// Bind knex instance to objection | ||
Model.knex(knex) | ||
|
||
const ObjectionPassword = objectionPassword() // Mixin with default options. | ||
|
||
// Objection model using default options | ||
class SampleModel extends ObjectionPassword(Model) { | ||
static get tableName () { | ||
return 'sample_model' | ||
} | ||
} | ||
|
||
beforeAll(async () => { | ||
await knex.schema.createTable('sample_model', (table) => { | ||
table.increments() | ||
table.string('name') | ||
table.string('password') | ||
}) | ||
transactionPerTest() | ||
}) | ||
|
||
afterAll(async () => { | ||
await knex.schema.dropTable('sample_model') | ||
knex.destroy() | ||
}) | ||
|
||
describe('$beforeInsert', () => { | ||
it('does not store the password in plaintext', async () => { | ||
const password = 'hunter1' | ||
const instance = await SampleModel.query().insert({ name: 'Dominic', password }) | ||
|
||
expect(instance.password).not.toEqual(password) | ||
}) | ||
|
||
it('stores a verifiable password', async () => { | ||
const password = 'hunter1' | ||
const instance = await SampleModel.query().insert({ name: 'Dominic', password }) | ||
|
||
expect(await instance.verifyPassword(password)).toBe(true) | ||
}) | ||
|
||
it('does not allow an empty password', async () => { | ||
const insertQuery = SampleModel.query().insert({ name: 'Dominic', password: '' }) | ||
expect(insertQuery).rejects.toThrowError() | ||
}) | ||
|
||
it('throws an error when attempting to hash a bcrypt hash', async () => { | ||
const insertQuery = SampleModel.query().insert({ | ||
name: 'Dominic', | ||
password: '$2a$12$sWSdI13BJ5ipPca/f8KTF.k4eFKsUtobfWdTBoQdj9g9I8JfLmZty' | ||
}) | ||
expect(insertQuery).rejects.toThrowError() | ||
}) | ||
}) | ||
|
||
describe('$beforeUpdate', () => { | ||
it('does not store the password in plaintext after update', async () => { | ||
const original = 'hunter1' | ||
const updated = 'qwerty' | ||
|
||
const instance = await SampleModel.query().insert({ name: 'Dominic', password: original }) | ||
await instance.$query().patch({ password: updated }) | ||
|
||
expect(instance.password).not.toEqual(original) | ||
expect(instance.password).not.toEqual(updated) | ||
}) | ||
|
||
it('creates new hash when updating password', async () => { | ||
const original = 'hunter1' | ||
const updated = 'qwerty' | ||
|
||
const instance = await SampleModel.query().insert({ name: 'Dominic', password: original }) | ||
const bcryptSpy = jest.spyOn(bcrypt, 'hash') | ||
await instance.$query().patch({ password: updated }) | ||
|
||
expect(bcryptSpy).toHaveBeenCalledTimes(1) | ||
expect(await instance.verifyPassword(updated)).toBe(true) | ||
expect(await instance.verifyPassword(original)).toBe(false) | ||
}) | ||
|
||
it('ignores hashing password field when patching a record where password is not updated', async () => { | ||
const bcryptSpy = jest.spyOn(bcrypt, 'hash') | ||
const instance = await SampleModel.query().insert({ name: 'Dominic', password: 'hunter1' }) | ||
|
||
await instance.$query().patch({ name: 'Raphael' }) | ||
|
||
expect(bcryptSpy).toHaveBeenCalledTimes(1) // Once on creation (and 0 times on patch) | ||
}) | ||
|
||
it('does not allow an empty password', async () => { | ||
const instance = await SampleModel.query().insert({ name: 'Dominic', password: 'hunter1' }) | ||
const updateQuery = instance.$query().patch({ password: '' }) | ||
|
||
expect(updateQuery).rejects.toThrowError() | ||
}) | ||
|
||
it('throws an error when attempting to hash a bcrypt hash', async () => { | ||
const instance = await SampleModel.query().insert({ name: 'Dominic', password: 'hunter1' }) | ||
const updateQuery = instance.$query().patch({ | ||
password: '$2a$12$sWSdI13BJ5ipPca/f8KTF.k4eFKsUtobfWdTBoQdj9g9I8JfLmZty' | ||
}) | ||
|
||
expect(updateQuery).rejects.toThrowError() | ||
}) | ||
}) | ||
|
||
describe('options overrides', () => { | ||
const generateCustomModel = (CustomizedMixin) => { | ||
return class extends CustomizedMixin(Model) { | ||
static get tableName () { | ||
return 'sample_model' | ||
} | ||
} | ||
} | ||
|
||
it('can allow empty string password inserts', async () => { | ||
const CustomizedMixin = objectionPassword({ allowEmptyPassword: true }) | ||
const CustomModel = generateCustomModel(CustomizedMixin) | ||
|
||
const instance = await CustomModel.query().insert({ name: 'Dominic', password: '' }) | ||
|
||
expect(instance.password).toBe('') | ||
}) | ||
|
||
it('can make passwords optional', async () => { | ||
const CustomizedMixin = objectionPassword({ allowEmptyPassword: true }) | ||
const CustomModel = generateCustomModel(CustomizedMixin) | ||
|
||
const instance = await CustomModel.query().insert({ name: 'Dominic' }) | ||
|
||
expect(instance.password).not.toBeDefined() | ||
}) | ||
|
||
it('can allow updating a password to an empty string', async () => { | ||
const CustomizedMixin = objectionPassword({ allowEmptyPassword: true }) | ||
const CustomModel = generateCustomModel(CustomizedMixin) | ||
|
||
const instance = await CustomModel.query().insert({ name: 'Dominic', password: 'hunter1' }) | ||
await instance.$query().patch({ password: '' }) | ||
|
||
expect(instance.password).toBe('') | ||
}) | ||
|
||
it('can allow unsetting a password (set to null)', async () => { | ||
const CustomizedMixin = objectionPassword({ allowEmptyPassword: true }) | ||
const CustomModel = generateCustomModel(CustomizedMixin) | ||
|
||
const instance = await CustomModel.query().insert({ name: 'Dominic', password: 'hunter1' }) | ||
await instance.$query().patch({ password: null }) | ||
|
||
expect(instance.password).toBe(null) | ||
}) | ||
|
||
it('can override the default password field', async () => { | ||
// Use the name field instead of the password field as the password used by the plugin | ||
const CustomizedMixin = objectionPassword({ passwordField: 'name' }) | ||
const CustomModel = generateCustomModel(CustomizedMixin) | ||
|
||
const name = 'Dominic' | ||
const password = 'hunter1' | ||
const instance = await CustomModel.query().insert({ name, password }) | ||
|
||
expect(await instance.verifyPassword(password)).toBe(false) | ||
expect(await instance.verifyPassword(name)).toBe(true) | ||
}) | ||
|
||
it('can set the number of bcrypt hashing rounds', async () => { | ||
// Expect to be called with 13 instead of 12 | ||
const CustomizedMixin = objectionPassword({ rounds: 13 }) | ||
const CustomModel = generateCustomModel(CustomizedMixin) | ||
|
||
const bcryptSpy = jest.spyOn(bcrypt, 'hash') | ||
|
||
const password = 'hunter1' | ||
await CustomModel.query().insert({ name: 'Dominic', password }) | ||
|
||
expect(bcryptSpy).toHaveBeenCalledWith(password, 13) | ||
}) | ||
}) | ||
|
||
describe('isBcryptHash', () => { | ||
it('returns true when given a bcrypt hash', async () => { | ||
expect(SampleModel.isBcryptHash(await bcrypt.hash('hello world', 12))).toBe(true) | ||
}) | ||
|
||
it('returns false when given a regular string', () => { | ||
expect(SampleModel.isBcryptHash('hello world')).toBe(false) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
// For a detailed explanation regarding each configuration property, visit: | ||
// https://jestjs.io/docs/en/configuration.html | ||
|
||
module.exports = { | ||
// Automatically clear mock calls and instances between every test | ||
clearMocks: true, | ||
|
||
// The directory where Jest should output its coverage files | ||
coverageDirectory: 'coverage', | ||
|
||
// The test environment that will be used for testing | ||
testEnvironment: 'node' | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.