diff --git a/package.json b/package.json index 07e7b8943..c101eedfd 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "babel-preset-react-hmre": "1.1.1", "babel-preset-stage-0": "6.5.0", "babel-regenerator-runtime": "6.5.0", + "bcrypt": "^0.8.7", "bugsnag": "^1.7.0", "classnames": "2.2.3", "codemirror": "5.16.0", diff --git a/server/migrations/20160911232911-user-unique-fields.js b/server/migrations/20160911232911-user-unique-fields.js index d394960f9..5786ff4d0 100644 --- a/server/migrations/20160911232911-user-unique-fields.js +++ b/server/migrations/20160911232911-user-unique-fields.js @@ -1,5 +1,3 @@ -'use strict'; - module.exports = { up: function (queryInterface, Sequelize) { queryInterface.changeColumn( @@ -42,5 +40,5 @@ module.exports = { allowNull: true, } ); - } + }, }; diff --git a/server/migrations/20160911234928-user-password.js b/server/migrations/20160911234928-user-password.js new file mode 100644 index 000000000..f477dcd19 --- /dev/null +++ b/server/migrations/20160911234928-user-password.js @@ -0,0 +1,12 @@ +module.exports = { + up: (queryInterface, Sequelize) => { + queryInterface.addColumn('users', 'passwordDigest', { + type: Sequelize.STRING, + allowNull: true, + }); + }, + + down: (queryInterface, _Sequelize) => { + queryInterface.removeColumn('users', 'passwordDigest'); + }, +}; diff --git a/server/models/User.js b/server/models/User.js index 4968e9a30..0d6c2b3b5 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -1,4 +1,5 @@ import crypto from 'crypto'; +import bcrypt from 'bcrypt'; import { DataTypes, sequelize, @@ -7,11 +8,15 @@ import { import JWT from 'jsonwebtoken'; +const BCRYPT_COST = process.env.NODE_ENV !== 'production' ? 4 : 12; + const User = sequelize.define('user', { id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, email: { type: DataTypes.STRING, unique: true }, username: { type: DataTypes.STRING, unique: true }, name: DataTypes.STRING, + password: DataTypes.VIRTUAL, + passwordDigest: DataTypes.STRING, isAdmin: DataTypes.BOOLEAN, slackAccessToken: encryptedFields.vault('slackAccessToken'), slackId: { type: DataTypes.STRING, allowNull: true }, @@ -25,6 +30,23 @@ const User = sequelize.define('user', { async getTeam() { return this.team; }, + verifyPassword(password) { + return new Promise((resolve, reject) => { + if (!this.passwordDigest) { + resolve(false); + return; + } + + bcrypt.compare(password, this.passwordDigest, (err, ok) => { + if (err) { + reject(err); + return; + } + + resolve(ok); + }); + }); + }, }, indexes: [ { @@ -36,7 +58,25 @@ const User = sequelize.define('user', { const setRandomJwtSecret = (model) => { model.jwtSecret = crypto.randomBytes(64).toString('hex'); }; +const hashPassword = function hashPassword(model) { + if (!model.password) { + return null; + } + return new Promise((resolve, reject) => { + bcrypt.hash(model.password, BCRYPT_COST, (err, digest) => { + if (err) { + reject(err); + return; + } + + model.passwordDigest = digest; + resolve(); + }); + }); +}; +User.beforeCreate(hashPassword); +User.beforeUpdate(hashPassword); User.beforeCreate(setRandomJwtSecret); export default User; diff --git a/server/models/User.test.js b/server/models/User.test.js new file mode 100644 index 000000000..e130e8a22 --- /dev/null +++ b/server/models/User.test.js @@ -0,0 +1,22 @@ +import { User } from '.'; + +import { flushdb, sequelize } from '../test/support'; + +beforeEach(flushdb); +afterAll(() => sequelize.close()); + +it('should set JWT secret and password digest', async () => { + const user = User.build({ + username: 'user', + name: 'User', + email: 'user1@example.com', + password: 'test123!', + }); + await user.save(); + + expect(user.passwordDigest).toBeTruthy(); + expect(user.getJwtToken()).toBeTruthy(); + + expect(await user.verifyPassword('test123!')).toBe(true); + expect(await user.verifyPassword('badPasswd')).toBe(false); +});