Skip to content

Commit

Permalink
Enable using asynchronous bcrypt methods (at save-time)
Browse files Browse the repository at this point in the history
  • Loading branch information
venables committed Feb 13, 2017
1 parent 306be18 commit dbfa9b8
Show file tree
Hide file tree
Showing 5 changed files with 965 additions and 82 deletions.
64 changes: 57 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
# bookshelf-secure-password


[![Version](https://img.shields.io/npm/v/bookshelf-secure-password.svg)](https://www.npmjs.com/package/bookshelf-secure-password)
[![Build Status](https://img.shields.io/travis/venables/bookshelf-secure-password/master.svg)](https://travis-ci.org/venables/bookshelf-secure-password)
[![Coverage Status](https://img.shields.io/coveralls/venables/bookshelf-secure-password.svg)](https://coveralls.io/github/venables/bookshelf-secure-password)
[![Dependency Status](https://david-dm.org/venables/bookshelf-secure-password.png)](https://david-dm.org/venables/bookshelf-secure-password)
[![Standard - JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](http://standardjs.com/)
[![Downloads](https://img.shields.io/npm/dm/bookshelf-secure-password.svg)](https://www.npmjs.com/package/bookshelf-secure-password)

A Bookshelf.js plugin for handling secure passwords.
A Bookshelf.js plugin for securely handling passwords.

Adds a method to securely set and authenticate a password using BCrypt.
## Features

Similar to [has_secure_password](http://api.rubyonrails.org/classes/ActiveModel/SecurePassword/ClassMethods.html) in Ruby on Rails.
* Securely store passwords in the database using BCrypt with little-to-no code.
* Minimal setup required: just install the module, and make a password_digest column in the database!
* Follows the latest security guidelines, using a BCrypt cost of 12
* Two usage options: when the password is set on the model (using BCrypt sync methods), or when the model is saved (using BCrypt async methods). Your choice! ([Read more](#async-or-sync))
* Inspired by and similar to [has_secure_password](http://api.rubyonrails.org/classes/ActiveModel/SecurePassword/ClassMethods.html) in Ruby on Rails.

## Installation

Expand All @@ -37,6 +40,14 @@ npm install bookshelf-secure-password --save
bookshelf.plugin(securePassword)
```

Optionally, you can pass a configuration object when enabling the plugin. (See [options](#configuration-options) below)

```javascript
bookshelf.plugin(securePassword, {
performOnSave: true
})
```

2. Add `hasSecurePassword` to the model(s) which require a secure password

```javascript
Expand All @@ -55,7 +66,28 @@ npm install bookshelf-secure-password --save
})
```

3. To authenticate against the password, simply call the instance method `authenticate`, which returns a `Promise` resolving to the authenticated Model.
3. Now, when you set a password (or save the record with a new password), it will be hashed as `password_digest`:

```javascript
// Default: Password is hashed during `set` time:
user = new User({ password: 'testing' })
user.get('password') // => undefined
user.get('password_digest') // => '$2a$12$SzUDit15feMdVCtfSzopc.0LuqeHlJInqq/1Ol8uxCC5QydHpVWFy'
```

```javascript
// Optional: Password is hashed during `save` call:
user = new User({ password: 'testing' })
user.get('password') // => 'testing'
user.get('password_digest') // => undefined

user.save().then(function () {
user.get('password') // => undefined
user.get('password_digest') // => '$2a$12$SzUDit15feMdVCtfSzopc.0LuqeHlJInqq/1Ol8uxCC5QydHpVWFy'
})
```

4. To authenticate against the password, simply call the instance method `authenticate`, which returns a `Promise` resolving to the authenticated Model.

```javascript
user.authenticate('some-password').then(function (user) {
Expand Down Expand Up @@ -96,11 +128,29 @@ function signIn (email, password) {
}
```

## Configuration Options

When enabling the plugin, you can pass in a configuration object. There is currently one option:

* `performOnSave`: A boolean to perform password hashing on save (using asynchronous calls) versus the default behavior or hasing when the password variable is set.

## Async or Sync?

This module provides two options for hashing passwords, each offers their own benefits.

* Hashing at **Set-time**: The default option is to perform password hashing immediately when the `password` field is set on the record. This is the more secure option, because the password is never stored on the record (in memory or in the database). However, it is a blocking operation and the web server will not be able to process other requests during the milliseconds that the hashing is ocurring.
* Hashing at **Save-time**: The other option is to perform password hashing immediately before the record is being saved to the database. This method uses asynchronous bcrypt methods which allows the web server to handle other requests in between each hashing process, if necessary. However, it is less secure because the raw password is stored in memory on the Model. This will increase chances of an inadvertent exposure of the password.

| Method | Security | Scalability |
| ------------------ | -------- | ----------- |
| Set-time (default) | Higher | Lower |
| Save-time | Lower | Higher |


## Notes

* BCrypt requires that passwords are 72 characters maximum (it ignores characters after 72).
* This library uses the bcrypt synchronous methods when setting a password. This is to ensure the raw password is never stored on the model (in memory, or otherwise).
* This library enables the built-in `virtuals` plugin on Bookshelf.
* This library enables the built-in `virtuals` plugin on Bookshelf if using the synchronous method.
* Passing a `null` value to the password will clear the `password_digest`.
* Passing `undefined` or a zero-length string to the password will leave the `password_digest` as-is

Expand Down
116 changes: 93 additions & 23 deletions lib/secure-password.js
Original file line number Diff line number Diff line change
@@ -1,24 +1,32 @@
'use strict'

function enableSecurePasswordPlugin (Bookshelf) {
function enableSecurePasswordPlugin (Bookshelf, opts) {
const bcrypt = require('bcrypt')
const DEFAULT_PASSWORD_FIELD = 'password'
const DEFAULT_PASSWORD_DIGEST_FIELD = 'password_digest'
const DEFAULT_SALT_ROUNDS = 12
const PasswordMismatchError = require('./error')
const proto = Bookshelf.Model.prototype
const useAsync = opts && opts.performOnSave

Bookshelf.PasswordMismatchError = PasswordMismatchError
Bookshelf.Model.PasswordMismatchError = PasswordMismatchError
Bookshelf.plugin('virtuals')

/**
* Enable the `virtuals` plugin if we are using the synchronous method of handling
* password hashing
*/
if (!useAsync) {
Bookshelf.plugin('virtuals')
}

/**
* Get the password field from the plugin configuration. defaults to `password_digest`
*
* @param {Model} model - the Bookshelf model
* @returns {String} - The database column name for the password digest
*/
function passwordField (model) {
function passwordDigestField (model) {
if (typeof model.hasSecurePassword === 'string' || model.hasSecurePassword instanceof String) {
return model.hasSecurePassword
}
Expand All @@ -27,17 +35,31 @@ function enableSecurePasswordPlugin (Bookshelf) {
}

/**
* Generate the BCrypt hash for a given string
* Generate the BCrypt hash for a given string using synchronous methods
*
* @param {String} value - The string to hash
* @returns {String} - A BCrypt hashed version of the string
*/
function hash (value) {
function hashSync (value) {
let salt = bcrypt.genSaltSync(DEFAULT_SALT_ROUNDS)

return bcrypt.hashSync(value, salt)
}

/**
* Generate the BCrypt hash for a given string using asynchronous methods
*
* @param {String} value - The string to hash
* @returns {String} - A BCrypt hashed version of the string
*/
function hashAsync (value) {
return bcrypt
.genSalt(DEFAULT_SALT_ROUNDS)
.then((salt) => {
return bcrypt.hash(value, salt)
})
}

/**
* Checks if a string is empty (null, undefined, or length of zero)
*
Expand All @@ -52,25 +74,72 @@ function enableSecurePasswordPlugin (Bookshelf) {
return ('' + str).length === 0
}

/**
* Enable sychronous (set-time) password hasing on the model when the attribute is set. This
* method is considered more secure than the asynchronous method because the raw password is
* not stored in memory on the model, decreasing the liklihood of inadvertently exposing the
* password.
*
* However, this method is blocking and could prevent the web server from handling other requests
* while hasing the password.
*
* @param {Model} model - The bookshelf model to set up
* @returns {Model} - The model
*/
function enableSyncHashing (model) {
let field = passwordDigestField(model)

model.virtuals = model.virtuals || {}
model.virtuals[DEFAULT_PASSWORD_FIELD] = {
get: function getPassword () {},
set: function setPassword (value) {
if (value === null) {
model.set(field, null)
} else if (!isEmpty(value)) {
model.set(field, hashSync(value))
}
}
}

return model
}

/**
* Enable asychronous (save-time) password hasing on the model when the model is saved. This
* method is beneficial because it makes all expensive calls using asynchronous calls, freeing
* up additional resources to handle income requests.
*
* However, use this with caution. The raw `password` variable will be stored on the model until
* the record is saved, which increases the chance of inadvertantly exposing it.
*
* @param {Model} model - The bookshelf model to set up
* @returns {Model} - The model
*/
function enableAsyncHashing (model) {
let field = passwordDigestField(model)

model.on('saving', (model) => {
if (model.hasChanged(DEFAULT_PASSWORD_FIELD)) {
let value = model.get(DEFAULT_PASSWORD_FIELD)

return hashAsync(value).then((_hashed) => {
model.unset(DEFAULT_PASSWORD_FIELD)
model.set(field, _hashed)
return model
})
}
})
}

const Model = Bookshelf.Model.extend({
hasSecurePassword: false,

constructor: function () {
let passwordDigestField

if (this.hasSecurePassword) {
passwordDigestField = passwordField(this)

this.virtuals = this.virtuals || {}
this.virtuals[DEFAULT_PASSWORD_FIELD] = {
get: function getPassword () {},
set: function setPassword (value) {
if (value === null) {
this.set(passwordDigestField, null)
} else if (!isEmpty(value)) {
this.set(passwordDigestField, hash(value))
}
}
if (useAsync) {
enableAsyncHashing(this)
} else {
enableSyncHashing(this)
}
}

Expand All @@ -90,18 +159,19 @@ function enableSecurePasswordPlugin (Bookshelf) {
return proto.authenticate.apply(this, arguments)
}

if (isEmpty(password)) {
return Promise.reject(new this.constructor.PasswordMismatchError())
}

return bcrypt
.compare(password, this.get(passwordField(this)))
.compare(password, this.get(passwordDigestField(this)))
.then((matches) => {
if (!matches) {
throw new this.constructor.PasswordMismatchError()
}

return this
})
.catch(() => {
throw new this.constructor.PasswordMismatchError()
})
}
})

Expand Down
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"description": "A Bookshelf.js plugin for handling secure passwords",
"main": "lib/secure-password.js",
"scripts": {
"test": "NODE_ENV=test ./node_modules/.bin/nyc ./node_modules/.bin/mocha 'test/**/*.spec.js'",
"test": "./node_modules/.bin/standard && NODE_ENV=test ./node_modules/.bin/nyc ./node_modules/.bin/mocha 'test/**/*.spec.js'",
"coverage": "./node_modules/.bin/nyc report --reporter=text-lcov | ./node_modules/.bin/coveralls"
},
"repository": {
Expand Down Expand Up @@ -38,6 +38,8 @@
"coveralls": "^2.11.16",
"knex": "^0.12.6",
"mocha": "^3.2.0",
"nyc": "^10.1.2"
"mock-knex": "^0.3.7",
"nyc": "^10.1.2",
"standard": "^8.6.0"
}
}
Loading

0 comments on commit dbfa9b8

Please sign in to comment.