/** * @fileOverview Node.js Authorisation Handler for Bridge Pay * @preserve Copyright 2016 Comcarde Ltd. * @author Keith Symington * @see #bridge_server-core */ /** * Includes */ var mainDB = require(global.pathPrefix + 'mainDB.js'); var utils = require(global.pathPrefix + 'utils.js'); var valid = require(global.pathPrefix + 'valid.js'); var mailer = require(global.pathPrefix + 'mailer.js'); var log = require(global.pathPrefix + 'log.js'); var config = require(global.configFile); var templates = require(global.pathPrefix + '../utils/templates.js'); var formattingUtils = require(global.pathPrefix + '../utils/formatting.js'); var crypto = require('crypto'); var async = require('async'); var moment = require('moment'); /** * This function checks the client status for any blocking flags. It will return an error if the following are true: * 1) The client is not verified. * 2) The client is barred. * * @type {function} checkClientStatus * @param {!string} ClientStatus - The ClientStatus flag from the client document. */ exports.checkClientStatus = function(ClientStatus) { /** * Valid session. Check client status. */ if (!utils.bitsAllSet(ClientStatus, utils.ClientEmailVerifiedMask)) { return utils.createError(114, 'Client e-mail has not been verified - please click the link.'); } if (utils.bitsAllSet(ClientStatus, utils.ClientBarredMask)) { return utils.createError(117, 'Client barred by Comcarde.'); } return null; }; /** * This function checks the device status for any blocking flags. It will return an error if the following are true: * 1) The device is not verified. * 2) The device is not authorised. * 3) The device is suspended. * 4) The device is barred. * * @type {function} checkDeviceStatus * @param {!string} DeviceStatus - The DeviceStatus flag from the device document. */ exports.checkDeviceStatus = function(DeviceStatus) { /** * Valid session. Check device status. */ if (!utils.bitsAllSet(DeviceStatus, utils.DeviceRegister2Mask)) { return utils.createError(109, 'Device not verified - SMS not confirmed.'); } if (!utils.bitsAllSet(DeviceStatus, utils.DeviceRegister3Mask)) { return utils.createError(110, 'Device not authorised - PIN not set.'); } if (utils.bitsAllSet(DeviceStatus, utils.DeviceSuspendedMask)) { return utils.createError(111, 'Device suspended by the user.'); } if (utils.bitsAllSet(DeviceStatus, utils.DeviceBarredMask)) { return utils.createError(112, 'Device barred by Comcarde.'); } return null; }; /** * This function needs to be called with all server requests. It checks the user is currently logged in * and if they are, it returns their client and device details. It requires two parameters: * * @type {function} validateCurrentSession * @param {!string} DeviceToken - The token assigned to the device at registration. * @param {!string} SessionToken - The token returned at login. Note that this is valid for ~5 minutes only. * Calling this function extends the SessionToken's life. * @param {!function} next - Not optional and should contain the code to be subsequently executed. */ exports.validateCurrentSession = function(DeviceToken, SessionToken, next) { /** * Valid input. Check to see if the database is online. * Cyclomatic complexity is known to be high for this function. */ //jshint -W074 mainDB.findOneObject(mainDB.collectionDevice, {DeviceToken: DeviceToken}, undefined, false, function(err, existingDevice) { if (err) { next(utils.createError(104, 'Database offline.'), null, null); return; } /** * No information returned from database. */ if (existingDevice === null) { next(utils.createError(103, 'Cannot find device token.'), null, null); return; } /** * Device found. Now check the token. */ if (SessionToken !== existingDevice.SessionToken) { // Session token invalid. next(utils.createError(107, 'Invalid session token.'), null, null); return; } /** * Check the session token expiry. */ var timestamp = new Date(); var expiry = existingDevice.SessionTokenExpiry; if (timestamp >= expiry) { // Session token invalid. next(utils.createError(108, 'Session token expired.'), null, null); return; } /** * Check device status. */ var currentDeviceStatus = exports.checkDeviceStatus(existingDevice.DeviceStatus); if (currentDeviceStatus) { next(currentDeviceStatus, null, null); return; } /** * Through device checks. Pull the client. */ mainDB.findOneObject(mainDB.collectionClient, {ClientID: existingDevice.ClientID}, undefined, false, function(err, existingClient) { /** * Check for an error. */ if (err) { // Database is not working. next(utils.createError(105, 'Database offline.'), null, null); return; } /** * If null then there is no Client account. */ if (existingClient === null) { // Callback. next(utils.createError(106, 'Cannot find account.'), null, null); return; } /** * Check client status. */ var currentClientStatus = exports.checkClientStatus(existingClient.ClientStatus); if (currentClientStatus) { next(currentClientStatus, null, null); return; } /** * Great. All active. Extend token validity. */ var newExpiry = new Date(timestamp); newExpiry.setMinutes(newExpiry.getMinutes() + utils.sessionTimeout); mainDB.updateObject(mainDB.collectionDevice, {DeviceToken: DeviceToken}, { $set: { LastUpdate: timestamp, SessionTokenExpiry: newExpiry } }, {upsert: false}, false, function(err) { if (err) { next(utils.createError(119, 'Database offline.'), null, null); return; } /** * Success! */ next(null, existingDevice, existingClient); }); }); }); //jshint +W074 }; /** * This function needs to be called with all server requests. It checks the user is currently logged in, esures the hmac is OK, * and if so, it returns their client and device details. It requires multiple parameters: * * @type {function} validSession * @param {!object} res - Response object for returning information. This function will respond directly on error. * @param {!string} DeviceToken - The token assigned to the device at registration. * @param {!string} SessionToken - The token returned at login. Note that this is valid for ~5 minutes only. * Calling this function extends the SessionToken's life. * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} * @param {?object} hmacData - HMAC information from incoming packet. * @param {!function} next - Not optional and should contain the code to be subsequently executed. */ exports.validSession = function(res, DeviceToken, SessionToken, functionInfo, hmacData, next) { /** * First check the session. */ exports.validateCurrentSession(DeviceToken, SessionToken, function(err, existingDevice, existingClient) { if (err) { res.status(200).json({ code: ('' + err.code), info: err.message }); log.system( 'WARNING', err.message, functionInfo.name, err.code, ('AF [SessionToken ' + SessionToken + ' (DeviceToken ' + DeviceToken + ')]'), (functionInfo.remote + ' (' + functionInfo.port + ')')); /** * Call back passing the error. */ next(err, null, null); return; } /** * Update the hmacData to store the ClientName from the existingClient as that * is required for the HMAC generation and validation */ hmacData.ClientName = existingClient.ClientName; /** * Check the HMAC is fine. */ exports.checkHMAC(existingDevice, hmacData, functionInfo.name, function(err) { if (err) { res.status(200).json({ code: ('' + err.code), info: err.message }); log.system( 'WARNING', err.message, functionInfo.name, err.code, (existingDevice.ClientID + ' (' + existingDevice.DeviceNumber + ')'), (functionInfo.remote + ' (' + functionInfo.port + ')')); /** * Call back passing the error. */ next(err, null, null); return; } /** * All valid. Proceed. */ next(null, existingDevice, existingClient); }); }); }; /** * Checks the PIN received from the device. This function will return an error object as the * first parameter if something went wrong. * * @type {function} checkDevicePIN * @param {!string} deviceAuthorisation - SHA256 of PIN as received from device. * @param {!object} existingDevice - Existing object in database. * @param {!object} timestamp - Reference time when clock was pulled. * @param {!function} next - Function to call when verification complete. */ exports.checkDevicePIN = function(deviceAuthorisation, existingDevice, timestamp, next) { /** * Check for a locked device. */ if (existingDevice.LoginAttempts >= utils.PINLockout) { next(utils.createError(399, 'Device locked. Please use PIN Reset.')); return; } /** * Split up the existing PIN and update if necessary. */ var receivedDeviceAuth; var databaseDeviceAuth; var authArray = existingDevice.DeviceAuthorisation.split('::'); async.series([ function(callback) { /** * Find the salt or create a new one if one doesn't exist. */ if (authArray[0] === '2') { /** * PIN encrypted using PBKDF2. */ crypto.pbkdf2(deviceAuthorisation, existingDevice.DeviceSalt, config.encryptPBKDF2Rounds, config.encryptPBKDF2Bytes, config.encryptPBKDF2Protocol, function(err, newHash) { if (err) { callback(err); } else { /** * Update the database. */ receivedDeviceAuth = newHash.toString('hex'); databaseDeviceAuth = authArray[1]; callback(null); } }); } else { /** * Problem with the encryption string. */ callback('Unknown encryption type.'); } } ], /** * Final clause which is executed after everything else or when an error is detected. */ function(err) { if (err) { next(utils.createError(400, ('Error when checking PIN: ' + err))); return; } /** * Check that the PIN matches. */ if (receivedDeviceAuth !== databaseDeviceAuth) { /** * Wrong PIN. Increase the fail count. */ mainDB.updateObject(mainDB.collectionDevice, {DeviceToken: existingDevice.DeviceToken}, { $set: {LastUpdate: timestamp}, $inc: {LoginAttempts: 1} }, {upsert: false}, false, function(err) { if (err) { next(utils.createError(401, 'Database offline.')); return; } /** * Check for maximum number of retries; if so, lock the account. */ if (existingDevice.LoginAttempts === (utils.PINLockout - 1)) { /** * Send warning e-mail. */ const suspendUrl = formattingUtils.formatPortalUrl('personal/devices'); var htmlEmail = templates.render('device-locked', { DeviceNumber: existingDevice.DeviceNumber, suspendDeviceUrl: suspendUrl }); mailer.sendEmailByID(null, existingDevice.ClientID, 'Bridge Device Locked', htmlEmail, 'auth.checkDevicePIN', function(err) { if (err) { next(utils.createError(402, 'Unable to send e-mail.')); return; } /** * Tell the user that the device has been locked. */ next(utils.createError(403, 'Wrong PIN. ' + utils.PINLockout + ' failed attempts have locked this device.')); }); return; } /** * Wrong PIN - more attempts left. */ next(utils.createError(404, 'Wrong PIN.')); }); return; } /** * PIN matched successfully. */ mainDB.updateObject(mainDB.collectionDevice, {DeviceToken: existingDevice.DeviceToken}, { $set: { LastUpdate: timestamp, LoginAttempts: 0 } }, {upsert: false}, false, function(err) { if (err) { next(utils.createError(405, 'Database offline.')); return; } /** * Success! */ next(null); }); }); }; /** * Checks the client password. This function will return an error object as the first parameter if something went wrong. * * @type {function} checkClientPassword * @param {!string} password - SHA256 of password as received from device. * @param {!object} existingClient - Existing object in database. * @param {!object} timestamp - Reference time when clock was pulled. * @param {!function} next - Function to call when verification complete. */ exports.checkClientPassword = function(password, existingClient, timestamp, next) { /** * Check for a locked account. */ if (existingClient.LoginAttempts >= utils.passwordLockout) { next(utils.createError(406, 'Attempted login to locked account. Please contact Comcarde.')); return; } /** * Split up the existing password and update if necessary. */ var receivedPassword; var databasePassword; var passArray = existingClient.Password.split('::'); async.series([ function(callback) { /** * Find the salt or create a new one if one doesn't exist. */ if (passArray[0] === '2') { /** * Password encrypted using PBKDF2. */ crypto.pbkdf2(password, existingClient.ClientSalt, config.encryptPBKDF2Rounds, config.encryptPBKDF2Bytes, config.encryptPBKDF2Protocol, function(err, newHash) { if (err) { callback(err); } else { /** * Update the database. */ receivedPassword = newHash.toString('hex'); databasePassword = passArray[1]; callback(null); } }); } else { /** * Problem with the encryption string. */ callback('Unknown encryption type.'); } } ], /** * Final clause which is executed after everything else or when an error is detected. */ function(err) { if (err) { next(utils.createError(407, ('Error when checking password: ' + err))); return; } /** * Check that the password matches. */ if (receivedPassword !== databasePassword) { /** * Wrong password. Increase the fail count. */ mainDB.updateObject(mainDB.collectionClient, {ClientID: existingClient.ClientID}, { $set: {LastUpdate: timestamp}, $inc: {LoginAttempts: 1} }, {upsert: false}, false, function(err) { if (err) { next(utils.createError(408, 'Database offline.')); return; } /** * Check for maximum number of retries; if so, lock the account. */ if (existingClient.LoginAttempts === (utils.passwordLockout - 1)) { /** * Send warning e-mail. */ var htmlEmail = templates.render('account-locked', { ClientName: existingClient.ClientName }); mailer.sendEmail(null, existingClient.ClientName, 'Bridge Account Locked', htmlEmail, 'auth.checkClientPassword', function(err) { if (err) { next(utils.createError(409, 'Unable to send e-mail.')); return; } /** * Tell the user that the client account has been locked. */ next(utils.createError(410, 'Wrong password. ' + utils.passwordLockout + ' failed attempts have locked the client account.')); }); return; } /** * Wrong password - more attempts left. */ next(utils.createError(411, 'Wrong password.')); }); return; } /** * Password matched successfully. Reset login attempts. */ mainDB.updateObject(mainDB.collectionClient, {ClientID: existingClient.ClientID}, { $set: { LastUpdate: timestamp, LoginAttempts: 0 } }, {upsert: false}, false, function(err) { if (err) { next(utils.createError(412, 'Database offline.')); return; } /** * Success! */ next(null); }); }); }; /** * Creates a new salt and encrypts the password hash using PBKDF2. * This function will return an error object as the first parameter if something went wrong. * * @type {function} encryptPBKDF2 * @param {!string} input - SHA256 of input to be encrypted using PBKDF2. * @param {!function} next - Function to call when verification complete. * @param {!object} next.err - Error object. null on success. * @param {!string} next.newSalt - Random salt for encoding. * @param {!string} next.newHash - Hashed input using new salt. */ exports.encryptPBKDF2 = function(input, next) { /** * Create a new salt. */ crypto.randomBytes(config.encryptPBKDF2Bytes, function(err, salt) { if (err) { next(err, null, null); return; } /** * Success. Encrypt the password. */ var newSalt = salt.toString('hex'); crypto.pbkdf2(input, newSalt, config.encryptPBKDF2Rounds, config.encryptPBKDF2Bytes, config.encryptPBKDF2Protocol, function(err, hash) { if (err) { next(err, null, null); return; } /** * All done. Convert the hash and call back with the new values. */ var newHash = hash.toString('hex'); next(null, newSalt, newHash); }); }); }; /** * Reponds with an HTML page. * * @type {function} respond * @param {!object} res - response object. End will be called by this function. * @param {!int} responseCode - HTML response code. * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} * @param {!string} code - The code associated with the response, e.g. '235', '10014'. * @param {!string} fileName - Name of the Pug file that is the HTML source: e.g. 'templates/39_expired_token.pug'. * @param {!object} data - Parameters used to render the HTML file. * @param {!string} logType - The log entry type to be recorded - e.g. 'INFO', 'WARNING' etc. * Omit if no system log entry is needed such as in 'Database offline'. * @param {!string} infoString - If logType above is present, infoString is will be used as the information to be logged. * @param {!string} altUser - If logType above is present, altUser can be used to log different user name. If not, 'UU' wil be used. */ exports.respondHTML = function(res, responseCode, functionInfo, code, fileName, data, logType, infoString, altUser) { /** * Respond to the request. */ var toReturn = templates.render(fileName, data); res.writeHead(200, {'Content-Type': 'text/html'}); res.end(toReturn); /** * Log what has happened if required. */ var logUser = ''; if (altUser) { logUser = altUser; } else { logUser = 'UU'; } if (logType) { log.system( logType, infoString, functionInfo.name, code, logUser, (functionInfo.remote + ' (' + functionInfo.port + ')')); } /** * Add HTML page generation details regardless. */ log.system( 'PAGE', ('Generated file returned [' + fileName + '].'), functionInfo.name, code, logUser, (functionInfo.remote + ' (' + functionInfo.port + ')')); }; /** * Uses a passed HMAC key to generate the data packet. * * @type {function} respond * @param {!object} res - response object. End will be called by this function. * @param {!int} responseCode - HTML response code. * @param {!object} existingDevice - Existing object in database. If set to null the function works as a normal res.status() call. * @param {!object} hmacData - hmac information {!address, !method, !body, !ClientName, ?timestamp, ?hmac} * This function can be used to respond to non hmac calls by adding null in here. * Typically set existingDevice and hmacData to null together. * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} * @param {!object} data - Body of the packet as a JSON object. Note that 'info' and 'code' must be present. * @param {!string} logType - The log entry type to be recorded - e.g. 'INFO', 'WARNING' etc. * Omit if no system log entry is needed such as in 'Database offline'. * @param {!string} altString - If logType above is present, altString can be used to log different data than the user receives. * @param {!string} altUser - If logType above is present, altUser can be used to log different user name. If not, 'UU' wil be used. * This parameter is ignored if hmacData and existingDevice are present. * * Cyclomatic complexity error disabled as it seems unnecessary. */ // jshint -W074 exports.respond = function(res, responseCode, existingDevice, hmacData, functionInfo, data, logType, altString, altUser) { /** * Ensure that the function is getting the correct data to process. * Checking disabled for speed outwith the development environment. */ if (config.isDevEnv) { if (typeof data !== 'object') { throw new Error('auth.respond received bad data from ' + functionInfo.name + ': data is not an object.'); } if (!('code' in data)) { throw new Error('auth.respond received bad data from ' + functionInfo.name + ': data is missing code field.'); } if (!('info' in data)) { throw new Error('auth.respond received bad data from ' + functionInfo.name + ': data is missing info field.'); } if (typeof data.code !== 'string') { throw new Error('auth.respond received bad data from ' + functionInfo.name + ': data.code is not a string.'); } if (typeof data.info !== 'string') { throw new Error('auth.respond received bad data from ' + functionInfo.name + ': data.info is not a string.'); } } /** * Set up variables for an HMAC based return. */ var key = ''; var DeviceUuid = ''; if (existingDevice) { if (functionInfo.name === 'RotateHMAC.process') { key = existingDevice.PendingHMAC; DeviceUuid = existingDevice.DeviceUuid; } else { key = existingDevice.CurrentHMAC; } } /** * If no key is available then there is no HMAC. We do not need to sign. */ if ((hmacData === null) || (key === '')) { /** * Respond to the request. */ res.status(responseCode).json(data); /** * Log what has happened if required. */ if (logType) { var logString = ''; var logUser = ''; if (altString) { logString = altString; } else { logString = data.info; } if (altUser) { logUser = altUser; } else { logUser = 'UU'; } log.system( logType, logString, functionInfo.name, data.code, logUser, (functionInfo.remote + ' (' + functionInfo.port + ')')); } return; } /** * Session token exception for Login1 due to the signing token not yet being saved. */ var sessionToken = ''; if (functionInfo.name === 'Login1.process') { sessionToken = data.SessionToken; } else { sessionToken = existingDevice.SessionToken; } /** * Process the data and create the hmac. */ var timestamp = moment().utc().format('YYYY-MM-DDTHH:mm:ss.SSS') + 'Z'; var text = JSON.stringify(data); var fullText = hmacData.address + hmacData.method + timestamp + hmacData.ClientName + sessionToken + DeviceUuid + text; var newkey = new Buffer(key, 'hex'); // Re-encode the key for use with the hmac. var hmac = crypto.createHmac('sha256', newkey); // Create the HMAC object. hmac.setEncoding('hex'); // Set encoding. /** * Note that the callback is attached as listener to stream's finish event. */ hmac.end(fullText, function() { /** * Read the HMAC and respond. */ var hash = hmac.read(); res.writeHead(responseCode, { 'bridge-hmac': hash, 'bridge-timestamp': timestamp, 'Content-Type': 'application/json; charset=utf-8' // Return that this is JSON }); res.end(text); /** * Log what has happened if required. */ if (logType) { var logString = ''; if (altString) { logString = altString; } else { logString = data.info; } log.system( logType, logString, functionInfo.name, data.code, (existingDevice.ClientID + ' (' + existingDevice.DeviceNumber + ')'), (functionInfo.remote + ' (' + functionInfo.port + ')')); } }); }; // jshint +W074 /** * Checks an incoming HMAC. * * @type {function} checkHMAC * @param {!object} existingDevice - Existing object in database. * @param {!object} hmacData - hmac information {!address, !method, !body, !ClientName, ?timestamp, ?hmac} * @param {!string} functionName - The function that called the validation process: e.g. 'PayCodeRequest.process'. * @param {!function} next - Function that should be called once processing is complete. * * Cyclomatic complexity error disabled as it seems unnecessary. */ // jshint -W074 exports.checkHMAC = function(existingDevice, hmacData, functionName, next) { /** * Check for HMAC problems first. */ if (existingDevice.HMACAttempts >= config.maxHMACAttempts) { next(utils.createError(458, 'HMAC error: too many failed HMAC attempts.')); return; } /** * Only Login1 and RotateHMAC are valid calls if there is a PendingHMAC. */ if (existingDevice.PendingHMAC !== '') { if ((functionName !== 'RotateHMAC.process') && (functionName !== 'Login1.process')) { next(utils.createError(459, 'HMAC error: HMAC must be rotated using RotateHMAC.')); return; } } var key = ''; var DeviceUuid = ''; if (functionName === 'RotateHMAC.process') { key = existingDevice.PendingHMAC; DeviceUuid = existingDevice.DeviceUuid; } else { key = existingDevice.CurrentHMAC; } /** * If the HMAC key is blank then no HMAC has been issued - re-register the device. * Note there is one exception and that is on Login1 where there is a PendingHMAC. */ if (key === '') { if ((functionName === 'Login1.process') && (existingDevice.PendingHMAC !== '')) { next(null); } else { next(utils.createError(462, 'HMAC error: No valid HMAC key - please re-register the device.')); } return; } /** * Look for timestamp errors. */ var output = ''; if (!('timestamp' in hmacData)) { next(utils.createError(446, 'HMAC error: \"bridge-timestamp\" not present.')); return; } else { output = valid.validateFieldTimeStamp(hmacData.timestamp); if (output) { next(utils.createError(449, output)); return; } /** * Check for desync. */ var upperTimestamp = new Date(); upperTimestamp.setSeconds(upperTimestamp.getSeconds() + config.HMACDesyncThreshold); if (hmacData.timestamp > upperTimestamp) { next(utils.createError(451, 'HMAC error: timestamp is in the future.')); return; } var lowerTimestamp = new Date(); lowerTimestamp.setSeconds(lowerTimestamp.getSeconds() - config.HMACDesyncThreshold); if (lowerTimestamp > hmacData.timestamp) { next(utils.createError(452, 'HMAC error: timestamp has expired.')); return; } } /** * Look for hmac errors. */ if (!('hmac' in hmacData)) { next(utils.createError(447, 'HMAC error: \"bridge-hmac\" not present.')); return; } else { output = valid.validateFieldHMAC(hmacData.hmac); if (output) { next(utils.createError(450, output)); return; } } /** * Assemble the HMAC. */ var fullText = hmacData.address + hmacData.method + hmacData.timestamp + hmacData.ClientName + DeviceUuid + hmacData.body; var newkey = new Buffer(key, 'hex'); // Re-encode the key for use with the hmac. var hmac = crypto.createHmac('sha256', newkey); // Create the HMAC object. hmac.setEncoding('hex'); // Set encoding. /** * Note that the callback is attached as listener to stream's finish event. */ hmac.end(fullText, function() { /** * Read the HMAC and respond. */ var hash = hmac.read(); if (hash !== hmacData.hmac) { /** * HMAC error. Tick up HMAC attempts or bar the device if there have been too many problems. */ var timestamp = new Date(); var toUpdate = { $set: {LastUpdate: timestamp}, $inc: {HMACAttempts: 1} }; if (existingDevice.HMACAttempts >= (config.maxHMACAttempts - 1)) { toUpdate.$bit = {DeviceStatus: {or: utils.DeviceBarredMask}}; } /** * Write this information to the correct device. */ mainDB.updateObject(mainDB.collectionDevice, {DeviceToken: existingDevice.DeviceToken}, toUpdate, {upsert: false}, false, function(err) { if (err) { next(utils.createError(460, ('HMAC error: database offline.'))); return; } /** * Inform the device of the error. */ if (existingDevice.HMACAttempts >= (config.maxHMACAttempts - 1)) { next(utils.createError(461, ('HMAC error: security check failed and device barred.'))); } else { next(utils.createError(448, ('HMAC error: security check failed.'))); } }); } else { next(null); } }); }; // jshint +W074