1088 lines
34 KiB
JavaScript
1088 lines
34 KiB
JavaScript
/**
|
|
* Controller to manage the account recovery functions
|
|
*/
|
|
'use strict';
|
|
|
|
const httpStatus = require('http-status-codes');
|
|
const debug = require('debug')('webconsole-api:controllers:recovery');
|
|
const _ = require('lodash');
|
|
const Q = require('q');
|
|
const moment = require('moment');
|
|
|
|
const utils = require(global.pathPrefix + 'utils.js');
|
|
const mailer = require(global.pathPrefix + 'mailer.js');
|
|
const mainDB = require(global.pathPrefix + 'mainDB.js');
|
|
const sms = require(global.pathPrefix + 'sms.js');
|
|
const references = require(global.pathPrefix + '../utils/references.js');
|
|
const responseUtils = require(global.pathPrefix + '../utils/responses.js');
|
|
const templates = require(global.pathPrefix + '../utils/templates.js');
|
|
const clientUtils = require(global.pathPrefix + '../utils/client/client.js');
|
|
const apiUtils = require(global.pathPrefix + '../swagger_api/api_utils.js');
|
|
|
|
/**
|
|
* Predefined errors from shared functions
|
|
*/
|
|
const NO_MATCH = 'BRIDGE: Invalid token';
|
|
const NO_RETRIES = 'BRIDGE: Too many retries';
|
|
const WRONG_STATE = 'BRIDGE: Wrong state';
|
|
const HASH_FAILED = 'BRIDGE: Failed to hash password';
|
|
const FAILED_UPDATE_DB = 'BRIDGE: Failed to store new password';
|
|
|
|
/**
|
|
* States in the recovery process state machine.
|
|
* We use these to ensure the correct request is being received at the correct
|
|
* time, so e.g. a client can't try to complete password reset with just an email
|
|
* token when they should also be confirming a device.
|
|
*/
|
|
const STATES = {
|
|
START: 0,
|
|
WAITING_FOR_EMAIL_TOKEN_PASSWORD: 1,
|
|
WAITING_FOR_EMAIL_TOKEN: 2,
|
|
WAITING_FOR_ANSWERS: 3,
|
|
WAITING_FOR_SMS_TOKEN_PASSWORD: 4
|
|
};
|
|
|
|
/**
|
|
* Types of questions
|
|
*/
|
|
const QTYPE = {
|
|
POSTCODE: 'postcode',
|
|
CARD: 'card',
|
|
TRANSACTION: 'transactions',
|
|
DEVICE: 'device',
|
|
DOB: 'dob'
|
|
};
|
|
|
|
/**
|
|
* Exports from this module
|
|
*/
|
|
module.exports = {
|
|
startRecovery,
|
|
completeRecoveryEmailPw,
|
|
confirmRecoveryEmail,
|
|
confirmAnswers,
|
|
completeRecoveryDevicePw
|
|
};
|
|
|
|
/**
|
|
* Sets the next expected state and any associated data for that state.
|
|
* This is stored in the session and used to verify that we are in the appropriate state.
|
|
* We also initialise the number of retries we are allowed for this next state.
|
|
*
|
|
* @param {Object} req - The request object (which holds the session info).
|
|
* @param {number} state - The next state. MUST be a member of STATES.
|
|
* @param {any} data - The data for the next state.
|
|
*/
|
|
function setNextState(req, state, data) {
|
|
debug(
|
|
'STATE TRANSITION: ',
|
|
_.get(req, 'session.data.nextState', 'n/a'), '=>', state
|
|
);
|
|
_.set(req, 'session.data.nextState', state);
|
|
_.set(req, 'session.data.stateData', data);
|
|
_.set(req, 'session.data.stateRetries', utils.recoveryRetries);
|
|
}
|
|
|
|
/**
|
|
* Gets the data for the expected state after verifying that this is the state
|
|
* we should be in, and that we still have retries left.
|
|
*
|
|
* @param {Object} req - The request object (which holds the session data).
|
|
* @param {number} expectedState - The state we expect to be in. MUST be from STATES.
|
|
* @returns {Promise<any>} - The data for this state.
|
|
*/
|
|
function getStateData(req, expectedState) {
|
|
debug('STATE START: ', expectedState, '[expecting: ', req.session.data.nextState, ']');
|
|
|
|
/**
|
|
* Check we are in the right state
|
|
*/
|
|
if (expectedState !== req.session.data.nextState) {
|
|
return Q.reject(WRONG_STATE);
|
|
}
|
|
|
|
/**
|
|
* Check we still have retries left (and reduce the count if we do)
|
|
*/
|
|
if (req.session.data.stateRetries <= 0) {
|
|
// Destroy session so it can no longer be used
|
|
req.session.destroy();
|
|
|
|
return Q.reject(NO_RETRIES);
|
|
} else {
|
|
req.session.data.stateRetries -= 1;
|
|
}
|
|
|
|
/**
|
|
* All good so return the data
|
|
*/
|
|
return Q.resolve(req.session.data.stateData);
|
|
}
|
|
|
|
/**
|
|
* Shared function to update the password in the database.
|
|
*
|
|
* @param {string} clientID - The ID of the client to update.
|
|
* @param {string} newPassword - The new password for that client.
|
|
*
|
|
* @returns {Promise} - Result of updating the password.
|
|
*/
|
|
function updatePassword(clientID, newPassword) {
|
|
/**
|
|
* Step 1. Hash the password
|
|
*/
|
|
const hashP = apiUtils.encodePassword(newPassword)
|
|
.catch((error) => {
|
|
debug('Failed to hash password', error);
|
|
|
|
return Q.reject(HASH_FAILED);
|
|
});
|
|
|
|
/**
|
|
* Step 2. Update the account
|
|
*/
|
|
const updateP = hashP.then((hashed) => {
|
|
const hashedPassword = hashed.hash;
|
|
const salt = hashed.salt;
|
|
|
|
const query = {
|
|
ClientID: clientID
|
|
};
|
|
|
|
const update = {
|
|
$set: {
|
|
Password: hashedPassword,
|
|
ClientSalt: salt,
|
|
'PasswordManagement.0.RecoveryLimits.Attempts': 0 // Complete recovery, so reset
|
|
},
|
|
$inc: {
|
|
LastVersion: 1
|
|
},
|
|
$currentDate: {
|
|
LastUpdate: true,
|
|
'PasswordManagement.0.RecoveryLimits.AllowAfter': true // Complete recovery, so reset
|
|
}
|
|
};
|
|
|
|
const options = {
|
|
upsert: false
|
|
};
|
|
|
|
return Q.nfcall(
|
|
mainDB.updateObject,
|
|
mainDB.collectionClient,
|
|
query,
|
|
update,
|
|
options,
|
|
false // Don't suppress errors - we expect this to succed
|
|
).then((res) => {
|
|
if (res.result.nModified === 1) {
|
|
return Q.resolve();
|
|
} else {
|
|
debug('DB ran, but didnt update 1 entry:', res.result.nModified);
|
|
|
|
return Q.reject(FAILED_UPDATE_DB);
|
|
}
|
|
}).catch(() => Q.reject(FAILED_UPDATE_DB));
|
|
});
|
|
|
|
return Q.all([hashP, updateP]);
|
|
}
|
|
|
|
/**
|
|
* Gets a list of Knowledge-Based Authentication questions and answers for a client.
|
|
*
|
|
* @param {string} clientID - ID of the client in question.
|
|
* @returns {Promise<Object>} - Promise for the questions and answers.
|
|
*/
|
|
function getKba(clientID) {
|
|
//
|
|
// Knoweldge Based Authentication questions are generated by a number of
|
|
// different functions. They all have the same format, so we can iterate
|
|
// over them to get to the full list of questions to select from.
|
|
//
|
|
const kbaFuncs = [
|
|
getKbaAddresses,
|
|
getKbaCards,
|
|
getKbaClientDetails,
|
|
getKbaDevices,
|
|
getKbaTransactions
|
|
];
|
|
const kbaPs = [];
|
|
|
|
for (let i = 0; i < kbaFuncs.length; ++i) {
|
|
kbaPs.push(kbaFuncs[i](clientID));
|
|
}
|
|
|
|
//
|
|
// Wait for all the questions to be generated, then prepare the results
|
|
//
|
|
return Q.all(kbaPs).then((results) => {
|
|
// Merge all the sub results
|
|
const all = [].concat(...results);
|
|
|
|
//
|
|
// Pick a random sample of them, then split out the questions and
|
|
// answers into seperate arrays (questions to send, answers to keep).
|
|
//
|
|
const kbas = _.sampleSize(all, utils.recoveryQuestionsCount);
|
|
const result = {
|
|
questions: _.map(
|
|
kbas,
|
|
(kba) => _.pick(kba, ['questionID', 'questionType', 'questionText'])
|
|
),
|
|
answers: _.map(
|
|
kbas,
|
|
(kba) => _.pick(kba, ['questionID', 'questionType', 'answer'])
|
|
)
|
|
};
|
|
|
|
return result;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Gets a set of KBA questions related to addresses. Specifically, the postcode
|
|
* of an address with the given description.
|
|
*
|
|
* @param {string} clientID - The client id.
|
|
* @returns {Promise<Object[]>} - Promise for the questions and answers.
|
|
*/
|
|
function getKbaAddresses(clientID) {
|
|
return mainDB.collectionAddresses
|
|
.find({
|
|
ClientID: clientID
|
|
})
|
|
.project({
|
|
AddressDescription: 1,
|
|
PostCode: 1
|
|
})
|
|
.toArray()
|
|
.then((addresses) => {
|
|
const kbas = [];
|
|
|
|
for (let i = 0; i < addresses.length; ++i) {
|
|
const id = utils.timeBasedRandomCode();
|
|
|
|
kbas.push({
|
|
questionID: id,
|
|
questionType: QTYPE.POSTCODE,
|
|
questionText: addresses[i].AddressDescription,
|
|
answer: addresses[i].PostCode
|
|
});
|
|
}
|
|
|
|
return kbas;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Gets KBA questions based on credit/debit card details. We give them the
|
|
* name; they give us back the last 3 digits.
|
|
*
|
|
* @param {string} clientID - The client ID.
|
|
* @returns {Promise<Object[]>} - Array of question and answer objects.
|
|
*/
|
|
function getKbaCards(clientID) {
|
|
return mainDB.collectionAccount
|
|
.find({
|
|
ClientID: clientID,
|
|
AccountType: 'Credit/Debit Payment Card',
|
|
$or: [
|
|
{AccountStatus: 0},
|
|
{AccountStatus: 1}
|
|
]
|
|
})
|
|
.project({
|
|
ClientAccountName: 1,
|
|
CardPAN: 1
|
|
})
|
|
.toArray()
|
|
.then((accounts) => {
|
|
const kbas = [];
|
|
|
|
for (let i = 0; i < accounts.length; ++i) {
|
|
const id = utils.timeBasedRandomCode();
|
|
|
|
//
|
|
// The card PAN can be anonymised in a number of ways depending
|
|
// on how many characters it has. In particular, the last 3
|
|
// digits might have a space in the middle depending on how the
|
|
// groups of 4 end up. e.g.
|
|
// - ...***1 23
|
|
// - ... **** 123
|
|
// - ... *123
|
|
//
|
|
// So we grab the last 4 digits, and remove the space/* wherever
|
|
// we find it.
|
|
//
|
|
const anonPan = accounts[i].CardPAN.slice(-4); // Last 4 characters
|
|
const last3 = anonPan.replace(/[ *]/, ''); // Remove any spaces or *s
|
|
|
|
kbas.push({
|
|
questionID: id,
|
|
questionType: QTYPE.CARD,
|
|
questionText: accounts[i].ClientAccountName,
|
|
answer: last3
|
|
});
|
|
}
|
|
|
|
return kbas;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get KBA questions based on recent transactions.
|
|
* At present we only ask how many transactions you have PAID in the past week
|
|
* with Bridge.
|
|
*
|
|
* @param {string} clientID - The client ID.
|
|
* @returns {Promise<Object[]>} - Array of question and answer objects.
|
|
*/
|
|
function getKbaTransactions(clientID) {
|
|
const since = moment().subtract(7, 'days').startOf('day').utc();
|
|
|
|
debug('Finding transactions since', since);
|
|
|
|
return mainDB.collectionTransaction
|
|
.find({
|
|
CustomerClientID: clientID,
|
|
TransactionStatus: utils.TransactionStatus.COMPLETE,
|
|
SaleTime: {
|
|
$gte: since.toDate()
|
|
}
|
|
})
|
|
.project({
|
|
_id: 1
|
|
})
|
|
.limit(5) // We only care for up to 5 transactions
|
|
.toArray()
|
|
.then((transactions) => {
|
|
const id = utils.timeBasedRandomCode();
|
|
const kbas = [{
|
|
questionID: id,
|
|
questionType: QTYPE.TRANSACTION,
|
|
questionText: since.toISOString(),
|
|
answer: transactions.length
|
|
}];
|
|
|
|
return kbas;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Gets KBA questions based on device details. We give them the
|
|
* name; they give us back the phone number.
|
|
*
|
|
* @param {string} clientID - The client ID.
|
|
* @returns {Promise<Object[]>} - Array of question and answer objects.
|
|
*/
|
|
function getKbaDevices(clientID) {
|
|
return mainDB.collectionDevice
|
|
.find({
|
|
ClientID: clientID
|
|
})
|
|
.project({
|
|
DeviceName: 1,
|
|
DeviceNumber: 1
|
|
})
|
|
.toArray()
|
|
.then((devices) => {
|
|
const kbas = [];
|
|
|
|
for (let i = 0; i < devices.length; ++i) {
|
|
const id = utils.timeBasedRandomCode();
|
|
|
|
kbas.push({
|
|
questionID: id,
|
|
questionType: QTYPE.DEVICE,
|
|
questionText: devices[i].DeviceName,
|
|
answer: devices[i].DeviceNumber
|
|
});
|
|
}
|
|
|
|
return kbas;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Gets KBA questions based on personal details. i.e. what is their date of birth.
|
|
*
|
|
* @param {string} clientID - The client ID.
|
|
* @returns {Promise<Object[]>} - Array of question and answer objects.
|
|
*/
|
|
function getKbaClientDetails(clientID) {
|
|
return references.getClient(clientID)
|
|
.then((client) => {
|
|
const kbas = [];
|
|
const dob = _.get(client, 'KYC[0].DateOfBirth', '');
|
|
const id = utils.timeBasedRandomCode();
|
|
|
|
if (dob !== '') {
|
|
kbas.push({
|
|
questionID: id,
|
|
questionType: QTYPE.DOB,
|
|
questionText: '',
|
|
answer: dob
|
|
});
|
|
}
|
|
|
|
return kbas;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Verifies that the answers provided to the KBA questions are correct.
|
|
*
|
|
* @param {Object[]} responses - Array of the answers provided by the caller.
|
|
* @param {Object[]} expected - Array of expected answers.
|
|
* @returns {Promise} - Promise for the verification.
|
|
*/
|
|
function verifyAnswers(responses, expected) {
|
|
//
|
|
// Check we have the correct number of responses
|
|
//
|
|
if (responses.length !== expected.length) {
|
|
return Q.reject(NO_MATCH);
|
|
}
|
|
|
|
//
|
|
// Sort the responses and expected answers by the ID to ensure they are
|
|
// in the same order
|
|
//
|
|
const sortedResponses = _.sortBy(responses, 'questionID');
|
|
const sortedExpected = _.sortBy(expected, 'questionID');
|
|
|
|
//
|
|
// Now compare them in order where the IDs and answers should match.
|
|
// Each question type has a verifier that compares them in different ways.
|
|
//
|
|
const verifierLookup = {};
|
|
|
|
verifierLookup[QTYPE.POSTCODE] = verifyKbaPostcode;
|
|
verifierLookup[QTYPE.CARD] = verifyKbaCard;
|
|
verifierLookup[QTYPE.TRANSACTION] = verifyKbaTransactions;
|
|
verifierLookup[QTYPE.DEVICE] = verifyKbaDevice;
|
|
verifierLookup[QTYPE.DOB] = verifyKbaDob;
|
|
|
|
for (let i = 0; i < sortedResponses.length; ++i) {
|
|
if (sortedResponses[i].questionID !== sortedExpected[i].questionID) {
|
|
return Q.reject(NO_MATCH);
|
|
}
|
|
|
|
const verifier = verifierLookup[sortedExpected[i].questionType];
|
|
|
|
if (!verifier || !verifier(sortedResponses[i].answer, sortedExpected[i].answer)) {
|
|
debug(sortedResponses[i], '!==', sortedExpected[i]);
|
|
|
|
return Q.reject(NO_MATCH);
|
|
}
|
|
}
|
|
|
|
return Q.resolve(); // All matched
|
|
}
|
|
|
|
/**
|
|
* Verifies that a postcode matches the expected answer. We compare after
|
|
* removing any spaces and uppercasing to avoid any formatting errors.
|
|
*
|
|
* @param {string} answer - The answer provided by the user.
|
|
* @param {string} expected - The expected answer stored in the system.
|
|
*
|
|
* @returns {boolean} - True if the answers match.
|
|
*/
|
|
function verifyKbaPostcode(answer, expected) {
|
|
const fixedAnswer = answer.replace(/ /g, '').toUpperCase();
|
|
const fixedExpected = expected.replace(/ /g, '').toUpperCase();
|
|
|
|
return fixedAnswer === fixedExpected;
|
|
}
|
|
|
|
/**
|
|
* Verifies that the correct last 3 digits of the card are given. We ensure
|
|
* both are strings, and are exactly equal. E.g. `'012'` is NOT equal to `12`.
|
|
*
|
|
* @param {string} answer - The answer provided by the user.
|
|
* @param {string} expected - The expected answer stored in the system.
|
|
*
|
|
* @returns {boolean} - True if the answers match.
|
|
*/
|
|
function verifyKbaCard(answer, expected) {
|
|
return _.isString(answer) && _.isString(expected) && answer === expected;
|
|
}
|
|
|
|
/**
|
|
* Verifies that the correct transaction count is given. We have stored the
|
|
* exact number of transactions, but only expect answers in a smaller set of
|
|
* buckets (0, 1-2, 3-4, 5+), so check that fits.
|
|
*
|
|
* @param {string} answer - The answer provided by the user.
|
|
* @param {string} expected - The expected answer stored in the system.
|
|
*
|
|
* @returns {boolean} - True if the answers match.
|
|
*/
|
|
function verifyKbaTransactions(answer, expected) {
|
|
const expectedN = Number(expected);
|
|
switch (Number(answer)) {
|
|
case 0:
|
|
return expectedN === 0;
|
|
case 1:
|
|
return expectedN === 1 || expectedN === 2;
|
|
case 3:
|
|
return expectedN === 3 || expectedN === 4;
|
|
case 5:
|
|
return expectedN >= 5;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verifies that the correct phone number for a device is given. We compare
|
|
* directly without any changes.
|
|
*
|
|
* @param {string} answer - The answer provided by the user.
|
|
* @param {string} expected - The expected answer stored in the system.
|
|
*
|
|
* @returns {boolean} - True if the answers match.
|
|
*/
|
|
function verifyKbaDevice(answer, expected) {
|
|
return answer === expected;
|
|
}
|
|
|
|
/**
|
|
* Verifies that the date of birth is given. We compare using moment.js to
|
|
* convert to a date and checking that they are the same. This allows us to be
|
|
* a little flexible in the exact format (e.g. with or without a time) so long
|
|
* as it matches ISO 8601 (which we enforce).
|
|
*
|
|
* @param {string} answer - The answer provided by the user.
|
|
* @param {string} expected - The expected answer stored in the system.
|
|
*
|
|
* @returns {boolean} - True if the answers match.
|
|
*/
|
|
function verifyKbaDob(answer, expected) {
|
|
const momAnswer = moment(answer, moment.ISO_8601, true); // ISO 8601 strict mode.
|
|
const momExpected = moment(expected, moment.ISO_8601, true); // ISO 8601 strict mode.
|
|
|
|
return momAnswer.isSame(momExpected, 'day'); // 'day' also matches month and year
|
|
}
|
|
|
|
/**
|
|
* Starts a recovery process.
|
|
*
|
|
* @param {Object} req - Express request object.
|
|
* @param {Object} res - Express response object.
|
|
*/
|
|
function startRecovery(req, res) {
|
|
const email = req.swagger.params.body.value.email;
|
|
|
|
debug('Start recovery for: ', email);
|
|
|
|
/**
|
|
* Step 1. Find the client, and check that we haven't hit the recovery rate limit
|
|
*/
|
|
const TOO_SOON = 'BRIDGE: Recovery attempted too soon. Must wait longer.';
|
|
const clientP = references.getClientByEmail(email)
|
|
.then((client) => {
|
|
debug('Found client for ', email);
|
|
|
|
//
|
|
// Check if recovery is alloed already (or no limit has been set)
|
|
//
|
|
const now = new Date();
|
|
const allowAfter = _.get(client, 'PasswordManagement.0.RecoveryLimits.AllowAfter', now);
|
|
|
|
if (now < allowAfter) {
|
|
return Q.reject({
|
|
name: TOO_SOON,
|
|
validAfter: allowAfter
|
|
});
|
|
}
|
|
|
|
//
|
|
// Otherwise we are all good, so just keep going
|
|
//
|
|
return client;
|
|
});
|
|
|
|
/**
|
|
* Step 2. Create an email token. As it needs to be copied from an email
|
|
* we make it shorter than the usual ones
|
|
*/
|
|
const token = utils.randomCode(utils.paycodeString, utils.SMStokenLength);
|
|
|
|
/**
|
|
* Step 3. Check if the client has any devices so we can tell if they
|
|
* need to do SMS confirmation or not.
|
|
*/
|
|
const deviceInfoP = clientP.then((client) => clientUtils.getDevicesInfo(client.ClientID));
|
|
|
|
/**
|
|
* Step 4. Send an email with the recovery token in it
|
|
*/
|
|
const EMAIL_FAIL = 'BRIDGE: Failed to send email';
|
|
const emailP = Q.all([clientP, deviceInfoP]).spread((client) => {
|
|
//
|
|
// Build and send the email
|
|
//
|
|
const htmlEmail = templates.render(
|
|
'account-recovery',
|
|
{
|
|
emailValidationCode: token
|
|
}
|
|
);
|
|
|
|
debug('Sending email...', token);
|
|
|
|
return Q.nfcall(
|
|
mailer.sendEmail,
|
|
'Live',
|
|
client.ClientName,
|
|
'Bridge Account Recovery',
|
|
htmlEmail,
|
|
'startRecovery'
|
|
).catch(() => Q.reject(EMAIL_FAIL));
|
|
});
|
|
|
|
/**
|
|
* Step 5. Increase the delay before the next allowed recovery
|
|
*/
|
|
const timeoutP = Q.all([clientP, emailP, deviceInfoP]).spread((client) => {
|
|
debug('Email sent. Updating client');
|
|
const query = {
|
|
ClientID: client.ClientID
|
|
};
|
|
|
|
//
|
|
// Calculate the exponential backoff between attempts
|
|
//
|
|
const attempts = _.get(client, 'client.PasswordManagement.0.RecoveryLimits.Attempts', 0);
|
|
const delay = Math.pow(2, attempts) * utils.recoveryInitialDelay * 60; // Delay in seconds
|
|
const after = moment().add(delay, 'seconds').toDate();
|
|
|
|
//
|
|
// Update the RecoveryLimits of the client for next time.
|
|
// These will be reset on successful password reset.
|
|
//
|
|
const update = {
|
|
$set: {
|
|
'PasswordManagement.0.RecoveryLimits.AllowAfter': after
|
|
},
|
|
$inc: {
|
|
LastVersion: 1,
|
|
'PasswordManagement.0.RecoveryLimits.Attempts': 1
|
|
},
|
|
$currentDate: {
|
|
LastUpdate: true
|
|
}
|
|
};
|
|
|
|
const options = {
|
|
upsert: false
|
|
};
|
|
|
|
return Q.nfcall(
|
|
mainDB.updateObject,
|
|
mainDB.collectionClient,
|
|
query,
|
|
update,
|
|
options,
|
|
false // Don't suppress errors - we expect this to succed
|
|
).catch(() => Q.reject(FAILED_UPDATE_DB));
|
|
});
|
|
|
|
/**
|
|
* Step 6. Setup the recovery session
|
|
*/
|
|
const sessionP = Q.all([clientP, deviceInfoP, timeoutP])
|
|
.spread((client) => apiUtils.initRecoverySession(req, client));
|
|
|
|
/**
|
|
* Step 7. Return the result
|
|
* If the client has devices we return 202 ACCEPTED to say that we
|
|
* will need SMS authentication as a later step
|
|
* If the parents don't have devices, return 200 OK to say we only
|
|
* need email confirmation
|
|
*/
|
|
return Q.all([clientP, emailP, timeoutP, sessionP, deviceInfoP])
|
|
.then((results) => {
|
|
debug('All done!');
|
|
const sessionResponse = results[3];
|
|
const deviceInfo = results[4];
|
|
|
|
//
|
|
// Setup the next expected state. This depends on whether we have
|
|
// devices to validate with an SMS token or not.
|
|
//
|
|
let nextState = STATES.WAITING_FOR_EMAIL_TOKEN;
|
|
let result = httpStatus.ACCEPTED;
|
|
|
|
if (!deviceInfo.hasDevices) {
|
|
nextState = STATES.WAITING_FOR_EMAIL_TOKEN_PASSWORD;
|
|
result = httpStatus.OK; // No devices, so accept just an email token
|
|
}
|
|
|
|
setNextState(
|
|
req,
|
|
nextState,
|
|
{
|
|
emailToken: token
|
|
}
|
|
);
|
|
|
|
return res.status(result).json(sessionResponse);
|
|
})
|
|
.catch((error) => {
|
|
debug('Error starting recovery', error);
|
|
const responses = [
|
|
[
|
|
'MongoError',
|
|
httpStatus.BAD_GATEWAY, 31131, 'Database Offline', true
|
|
],
|
|
[
|
|
references.ERRORS.INVALID_CLIENT,
|
|
httpStatus.BAD_REQUEST, 31132, 'Client not found', true
|
|
],
|
|
[
|
|
TOO_SOON,
|
|
httpStatus.TOO_MANY_REQUESTS, 31133, 'Too many recovery attempts.', true
|
|
],
|
|
[
|
|
EMAIL_FAIL,
|
|
httpStatus.BAD_GATEWAY, 31134, 'Failed to send validation email.'
|
|
],
|
|
[
|
|
FAILED_UPDATE_DB,
|
|
httpStatus.BAD_GATEWAY, 31135, 'Failed to update database.'
|
|
],
|
|
[
|
|
apiUtils.ERRORS.SESSION_REGEN_FAILED,
|
|
httpStatus.INTERNAL_SERVER_ERROR, 31136, 'Failed to create session.'
|
|
]
|
|
];
|
|
const responseHandler = new responseUtils.ErrorResponses(responses);
|
|
|
|
responseHandler.respond(res, error);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Completes the recovery process in the cases where we only need to confirm
|
|
* the email address. In these case, we send the new password along with the
|
|
* email token so that it is confirmed and the new password set in 1 step.
|
|
*
|
|
* @param {Object} req - Express request object.
|
|
* @param {Object} res - Express response object.
|
|
*/
|
|
function completeRecoveryEmailPw(req, res) {
|
|
const emailToken = req.swagger.params.body.value.validationToken;
|
|
const newPassword = req.swagger.params.body.value.newPassword;
|
|
const clientID = req.session.data.clientID;
|
|
const dataP = getStateData(req, STATES.WAITING_FOR_EMAIL_TOKEN_PASSWORD);
|
|
|
|
debug('Completing recover for: ', clientID);
|
|
|
|
/**
|
|
* Step 1. Check the tokens match
|
|
*/
|
|
const matchP = dataP.then(
|
|
(data) => {
|
|
return emailToken === data.emailToken ? Q.resolve() : Q.reject(NO_MATCH);
|
|
}
|
|
);
|
|
|
|
/**
|
|
* Update the password
|
|
*/
|
|
const updateP = matchP.then(() => updatePassword(clientID, newPassword));
|
|
|
|
/**
|
|
* Check the results
|
|
*/
|
|
return Q.all([dataP, matchP, updateP])
|
|
.then(() => {
|
|
debug('Password updated');
|
|
res.status(httpStatus.OK).json();
|
|
|
|
// All good, so we also destroy the session so they have to log
|
|
// in normally to get a normal session
|
|
return req.session.destroy();
|
|
})
|
|
.catch((error) => {
|
|
debug('Error resetting password', error);
|
|
const responses = [
|
|
[
|
|
'MongoError',
|
|
httpStatus.BAD_GATEWAY, 31141, 'Database Offline', true
|
|
],
|
|
[
|
|
WRONG_STATE,
|
|
httpStatus.FORBIDDEN, 31142, 'Operation not allowed'
|
|
],
|
|
[
|
|
NO_RETRIES,
|
|
httpStatus.FORBIDDEN, 31143, 'Too many failures'
|
|
],
|
|
[
|
|
NO_MATCH,
|
|
httpStatus.BAD_REQUEST, 31144, 'Invalid email token'
|
|
],
|
|
[
|
|
HASH_FAILED,
|
|
httpStatus.INTERNAL_SERVER_ERROR, 31145, 'Failed to initialise password'
|
|
],
|
|
[
|
|
FAILED_UPDATE_DB,
|
|
httpStatus.BAD_GATEWAY, 31146, 'Failed to update database.'
|
|
]
|
|
];
|
|
const responseHandler = new responseUtils.ErrorResponses(responses);
|
|
|
|
responseHandler.respond(res, error);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Confirms the email token, then produce a set of KBA questions for the client
|
|
* to answer.
|
|
*
|
|
* @param {Object} req - Express request object.
|
|
* @param {Object} res - Express response object.
|
|
*/
|
|
function confirmRecoveryEmail(req, res) {
|
|
const clientID = req.session.data.clientID;
|
|
const token = req.swagger.params.body.value.validationToken;
|
|
const dataP = getStateData(req, STATES.WAITING_FOR_EMAIL_TOKEN);
|
|
|
|
debug('Confirming email for: ', clientID);
|
|
|
|
/**
|
|
* Step 1. Check the tokens match
|
|
*/
|
|
const matchP = dataP.then(
|
|
(data) => {
|
|
return token === data.emailToken ? Q.resolve() : Q.reject(NO_MATCH);
|
|
}
|
|
);
|
|
|
|
/**
|
|
* Step 2. Build the KBA questions
|
|
*/
|
|
const kbaP = getKba(clientID);
|
|
|
|
/**
|
|
* Check if everything passed
|
|
*/
|
|
return Q.all([dataP, matchP, kbaP])
|
|
.spread((data, match, kba) => {
|
|
debug('Email token validations');
|
|
|
|
setNextState(
|
|
req,
|
|
STATES.WAITING_FOR_ANSWERS,
|
|
{
|
|
answers: kba.answers
|
|
});
|
|
|
|
res.status(httpStatus.OK).json({
|
|
questions: kba.questions
|
|
});
|
|
})
|
|
.catch((error) => {
|
|
debug('Error resetting password', error);
|
|
const responses = [
|
|
[
|
|
'MongoError',
|
|
httpStatus.BAD_GATEWAY, 31151, 'Database Offline', true
|
|
],
|
|
[
|
|
WRONG_STATE,
|
|
httpStatus.FORBIDDEN, 31152, 'Operation not allowed'
|
|
],
|
|
[
|
|
NO_RETRIES,
|
|
httpStatus.FORBIDDEN, 31153, 'Too many failures'
|
|
],
|
|
[
|
|
NO_MATCH,
|
|
httpStatus.BAD_REQUEST, 31154, 'Invalid email token'
|
|
]
|
|
];
|
|
const responseHandler = new responseUtils.ErrorResponses(responses);
|
|
|
|
responseHandler.respond(res, error);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Confirm the answers to the KBA answers provided. Checks the provided phone
|
|
* number is correct for this client, and sends a reset token to the phone.
|
|
*
|
|
* @param {Object} req - Express request object.
|
|
* @param {Object} res - Express response object.
|
|
*/
|
|
function confirmAnswers(req, res) {
|
|
const clientID = req.session.data.clientID;
|
|
const phoneNumber = req.swagger.params.body.value.DeviceNumber;
|
|
const answers = req.swagger.params.body.value.Answers;
|
|
const dataP = getStateData(req, STATES.WAITING_FOR_ANSWERS);
|
|
|
|
debug('Confirming answers for: ', clientID);
|
|
|
|
/**
|
|
* Validate the answers to the questions we asked
|
|
*/
|
|
const checkKbaP = dataP.then(
|
|
(data) => {
|
|
return verifyAnswers(answers, data.answers);
|
|
}
|
|
);
|
|
|
|
/**
|
|
* Check the device is a valid one for this client
|
|
*/
|
|
const deviceP = checkKbaP.then(() => references.getDevice(phoneNumber, clientID));
|
|
|
|
/**
|
|
* Send an SMS to that device
|
|
*/
|
|
const token = utils.randomCode(utils.paycodeString, utils.SMStokenLength);
|
|
const SMS_SEND_FAIL = 'BRIDGE: Failed to send SMS';
|
|
const smsP = deviceP.then(() => {
|
|
debug('Sending reset SMS:', token);
|
|
|
|
return Q.nfcall(
|
|
sms.sendSMS,
|
|
null, // or 'TEST'
|
|
phoneNumber,
|
|
'Your Bridge verification code is ' + token
|
|
).catch(() => Q.reject(SMS_SEND_FAIL));
|
|
});
|
|
|
|
/**
|
|
* Check everything worked and reply ok
|
|
*/
|
|
return Q.all([dataP, checkKbaP, deviceP, smsP])
|
|
.then(() => {
|
|
debug('KBA complete. Sent SMS token');
|
|
|
|
setNextState(
|
|
req,
|
|
STATES.WAITING_FOR_SMS_TOKEN_PASSWORD,
|
|
{
|
|
smsToken: token
|
|
});
|
|
|
|
return res.status(httpStatus.OK).json();
|
|
})
|
|
.catch((error) => {
|
|
debug('Error verifying KBA', error);
|
|
const responses = [
|
|
[
|
|
'MongoError',
|
|
httpStatus.BAD_GATEWAY, 31161, 'Database Offline', true
|
|
],
|
|
[
|
|
WRONG_STATE,
|
|
httpStatus.FORBIDDEN, 31162, 'Operation not allowed'
|
|
],
|
|
[
|
|
NO_RETRIES,
|
|
httpStatus.FORBIDDEN, 31163, 'Too many failures'
|
|
],
|
|
[
|
|
NO_MATCH,
|
|
httpStatus.BAD_REQUEST, 31164, 'Incorrect answers'
|
|
],
|
|
[
|
|
SMS_SEND_FAIL,
|
|
httpStatus.BAD_GATEWAY, 31165, 'Failed to send recovery token'
|
|
],
|
|
[
|
|
references.ERRORS.INVALID_DEVICE,
|
|
httpStatus.BAD_REQUEST, 31166,
|
|
'Device number is not registered for this client, or has been disabled', true
|
|
]
|
|
];
|
|
const responseHandler = new responseUtils.ErrorResponses(responses);
|
|
|
|
responseHandler.respond(res, error);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Completes the recovery process when we have an SMS validation token.
|
|
*
|
|
* @param {Object} req - Express request object.
|
|
* @param {Object} res - Express response object.
|
|
*/
|
|
function completeRecoveryDevicePw(req, res) {
|
|
const newPassword = req.swagger.params.body.value.newPassword;
|
|
const token = req.swagger.params.body.value.validationToken;
|
|
const clientID = req.session.data.clientID;
|
|
const dataP = getStateData(req, STATES.WAITING_FOR_SMS_TOKEN_PASSWORD);
|
|
|
|
debug('Completing recover via SMS for: ', clientID);
|
|
|
|
/**
|
|
* Step 1. Check the tokens match,
|
|
* If not, check if we have any retries left
|
|
*/
|
|
const matchP = dataP.then(
|
|
(data) => {
|
|
return token === data.smsToken ? Q.resolve() : Q.reject(NO_MATCH);
|
|
}
|
|
);
|
|
|
|
/**
|
|
* Step 2. Update password
|
|
*/
|
|
const updateP = matchP.then(() => updatePassword(clientID, newPassword));
|
|
|
|
/**
|
|
* Check the results
|
|
*/
|
|
return Q.all([dataP, matchP, updateP])
|
|
.then(() => {
|
|
debug('Password updated');
|
|
res.status(httpStatus.OK).json();
|
|
|
|
// All good, so we also destroy the session so they have to log
|
|
// in normally to get a normal session
|
|
return req.session.destroy();
|
|
})
|
|
.catch((error) => {
|
|
debug('Error resetting password', error);
|
|
const responses = [
|
|
[
|
|
'MongoError',
|
|
httpStatus.BAD_GATEWAY, 31171, 'Database Offline', true
|
|
],
|
|
[
|
|
WRONG_STATE,
|
|
httpStatus.FORBIDDEN, 31172, 'Operation not allowed'
|
|
],
|
|
[
|
|
NO_RETRIES,
|
|
httpStatus.FORBIDDEN, 31173, 'Too many failures'
|
|
],
|
|
[
|
|
NO_MATCH,
|
|
httpStatus.BAD_REQUEST, 31174, 'Invalid sms token'
|
|
],
|
|
[
|
|
HASH_FAILED,
|
|
httpStatus.INTERNAL_SERVER_ERROR, 31175, 'Failed to initialise password'
|
|
],
|
|
[
|
|
FAILED_UPDATE_DB,
|
|
httpStatus.BAD_GATEWAY, 31176, 'Failed to update database.'
|
|
]
|
|
];
|
|
const responseHandler = new responseUtils.ErrorResponses(responses);
|
|
|
|
responseHandler.respond(res, error);
|
|
});
|
|
}
|