diff --git a/integration_test/db/password-reset.js b/integration_test/db/password-reset.js new file mode 100644 index 00000000..2bb00cc6 --- /dev/null +++ b/integration_test/db/password-reset.js @@ -0,0 +1,143 @@ +const assert = require('assert'); +const PasswordResetDB = require('../../lib/db/password-reset').PasswordResetDB; +const testDB = require('../testutil/db').testDB; + +const passwordResetDB = new PasswordResetDB(testDB); + +function cleanup() { + return testDB.knex.table('password_reset').del(); +} + +describe('PasswordResetDB', () => { + describe('#insert', () => { + beforeEach(cleanup); + + const params = { + ip: '1.2.3.4', + name: 'testing', + email: 'test@example.com', + hash: 'abcdef', + expire: 5678 + }; + + it('adds a new password reset', () => { + return passwordResetDB.insert(params).then(() => { + return testDB.knex.table('password_reset') + .where({ name: 'testing' }) + .select(); + }).then(rows => { + assert.strictEqual(rows.length, 1); + assert.deepStrictEqual(rows[0], params); + }); + }); + + it('overwrites an existing reset for the same name', () => { + return passwordResetDB.insert(params).then(() => { + params.ip = '5.6.7.8'; + params.email = 'somethingelse@example.com'; + params.hash = 'qwertyuiop'; + params.expire = 9999; + + return passwordResetDB.insert(params); + }).then(() => { + return testDB.knex.table('password_reset') + .where({ name: 'testing' }) + .select(); + }).then(rows => { + assert.strictEqual(rows.length, 1); + assert.deepStrictEqual(rows[0], params); + }); + }); + }); + + describe('#get', () => { + const reset = { + ip: '1.2.3.4', + name: 'testing', + email: 'test@example.com', + hash: 'abcdef', + expire: 5678 + }; + + beforeEach(() => cleanup().then(() => { + return testDB.knex.table('password_reset').insert(reset); + })); + + it('gets a password reset by hash', () => { + return passwordResetDB.get(reset.hash).then(result => { + assert.deepStrictEqual(result, reset); + }); + }); + + it('throws when no reset exists for the input', () => { + return passwordResetDB.get('lalala').then(() => { + assert.fail('Expected not found error'); + }).catch(error => { + assert.strictEqual( + error.message, + 'No password reset found for hash lalala' + ); + }); + }); + }); + + describe('#delete', () => { + const reset = { + ip: '1.2.3.4', + name: 'testing', + email: 'test@example.com', + hash: 'abcdef', + expire: 5678 + }; + + beforeEach(() => cleanup().then(() => { + return testDB.knex.table('password_reset').insert(reset); + })); + + it('deletes a password reset by hash', () => { + return passwordResetDB.delete(reset.hash).then(() => { + return testDB.knex.table('password_reset') + .where({ name: 'testing' }) + .select(); + }).then(rows => { + assert.strictEqual(rows.length, 0); + }); + }); + }); + + describe('#cleanup', () => { + const now = Date.now(); + + const reset1 = { + ip: '1.2.3.4', + name: 'testing', + email: 'test@example.com', + hash: 'abcdef', + expire: now - 25 * 60 * 60 * 1000 + }; + + const reset2 = { + ip: '5.6.7.8', + name: 'testing2', + email: 'test@example.com', + hash: 'abcdef', + expire: now + }; + + beforeEach(() => cleanup().then(() => { + return testDB.knex.table('password_reset') + .insert([reset1, reset2]); + })); + + it('cleans up old password resets', () => { + return passwordResetDB.cleanup().then(() => { + return testDB.knex.table('password_reset') + .whereIn('name', ['testing1', 'testing2']) + .select(); + }).then(rows => { + assert.strictEqual(rows.length, 1); + assert.deepStrictEqual(rows[0], reset2); + }); + }); + }); +}); diff --git a/src/database/accounts.js b/src/database/accounts.js index 6ea1918b..c0038195 100644 --- a/src/database/accounts.js +++ b/src/database/accounts.js @@ -484,22 +484,6 @@ module.exports = { }); }, - generatePasswordReset: function (ip, name, email, callback) { - if (typeof callback !== "function") { - return; - } - - callback("generatePasswordReset is not implemented", null); - }, - - recoverPassword: function (hash, callback) { - if (typeof callback !== "function") { - return; - } - - callback("recoverPassword is not implemented", null); - }, - /** * Retrieve a list of channels owned by a user */ diff --git a/src/db/password-reset.js b/src/db/password-reset.js new file mode 100644 index 00000000..d8552b15 --- /dev/null +++ b/src/db/password-reset.js @@ -0,0 +1,53 @@ +import { createMySQLDuplicateKeyUpdate } from '../util/on-duplicate-key-update'; + +const ONE_DAY = 24 * 60 * 60 * 1000; + +class PasswordResetDB { + constructor(db) { + this.db = db; + } + + insert(params) { + // TODO: validate params? + return this.db.runTransaction(tx => { + const insert = tx.table('password_reset').insert(params); + // TODO: Support other DBMS besides MySQL + // Annoyingly, upsert/on duplicate key update are non-standard + // Alternatively, maybe this table shouldn't be an upsert table? + const update = tx.raw(createMySQLDuplicateKeyUpdate( + ['ip', 'hash', 'email', 'expire'] + )); + + return tx.raw(insert.toString() + update.toString()); + }); + } + + get(hash) { + return this.db.runTransaction(tx => { + return tx.table('password_reset').where({ hash }).select() + .then(rows => { + if (rows.length === 0) { + throw new Error(`No password reset found for hash ${hash}`); + } + + return rows[0]; + }); + }); + } + + delete(hash) { + return this.db.runTransaction(tx => { + return tx.table('password_reset').where({ hash }).del(); + }); + } + + cleanup(threshold = ONE_DAY) { + return this.db.runTransaction(tx => { + return tx.table('password_reset') + .where('expire', '<', Date.now() - ONE_DAY) + .del(); + }); + } +} + +export { PasswordResetDB }; diff --git a/src/util/on-duplicate-key-update.js b/src/util/on-duplicate-key-update.js new file mode 100644 index 00000000..b10cf26a --- /dev/null +++ b/src/util/on-duplicate-key-update.js @@ -0,0 +1,7 @@ +export function createMySQLDuplicateKeyUpdate(columns) { + const prefix = ' on duplicate key update '; + const updates = columns.map(col => `\`${col}\` = values(\`${col}\`)`) + .join(', '); + + return prefix + updates; +}