494 lines
17 KiB
JavaScript
494 lines
17 KiB
JavaScript
//
|
|
// This file manages the API security handling for the Device-style Swagger security
|
|
// definitions
|
|
//
|
|
'use strict';
|
|
/* eslint max-nested-callbacks: ["error", 7] import/max-dependencies: ["error", {"max": 11}] */
|
|
|
|
const config = require(global.configFile);
|
|
const debug = require('debug')('webconsole-api:security:device');
|
|
const _ = require('lodash');
|
|
const Ajv = require('ajv');
|
|
const Q = require('q');
|
|
const url = require('url');
|
|
const JsonRefs = require('json-refs');
|
|
const mongodb = require('mongodb');
|
|
|
|
const auth = require('../ComServe/auth-promises.js');
|
|
const references = require('../utils/references.js');
|
|
const mainDBP = require('../ComServe/mainDB-promises.js');
|
|
|
|
const featureFlags = require(global.pathPrefix + '../utils/feature-flags/feature-flags.js');
|
|
const utils = require(global.pathPrefix + 'utils.js');
|
|
const apiUtils = require('./api_utils.js');
|
|
|
|
module.exports = {
|
|
deviceSession,
|
|
deviceHmacNoSession
|
|
};
|
|
|
|
/**
|
|
* Cache of the validation functions once we compile them from the swagger schema
|
|
*/
|
|
const VALIDATORS = {
|
|
initialised: false,
|
|
session: null,
|
|
hmac: null,
|
|
timestamp: null
|
|
};
|
|
|
|
/**
|
|
* Create the ajv object with the settings we want to use
|
|
*/
|
|
const AJV_OPTIONS = {
|
|
/* Return all errors, not just the first one */
|
|
allErrors: false,
|
|
|
|
/* Validate formats fully. Slower but more correct than 'fast' mode */
|
|
format: 'full',
|
|
|
|
/* Throw exceptions during schema compilation for unknown formats */
|
|
unknownFormats: true,
|
|
|
|
/* Don't remove additional properties, so that we can detect they exist and fail validation */
|
|
/* If removeAdditional = true, they are removed before they can be detected as additional */
|
|
removeAdditional: false,
|
|
|
|
/* No defaults - all specified values must exist.*/
|
|
useDefaults: false,
|
|
|
|
/* Ensure all types are exactly as specified. E.g. this will not accept "1" as a number */
|
|
coerceTypes: false
|
|
};
|
|
const ajv = new Ajv(AJV_OPTIONS);
|
|
|
|
/**
|
|
* Validates the security against the "device"\ security model. This requires 3 headers:
|
|
* 1. `x-bridge-device-session`: `<device_token>:<session_token>`
|
|
* 2. `x-bridge-hmac`: The hmac for the request as described in the wiki
|
|
* 3. `x-bridge-timestamp`: The timestamp of the packet
|
|
*
|
|
* @param {Object} req - the express request object
|
|
* @param {Object} def - the definition of the security definition we are validating
|
|
* @param {Object} scopes - the value of the header specified in the definition
|
|
* @param {Function!} callback - the callback function for success or failure or the security validation
|
|
*/
|
|
function deviceSession(req, def, scopes, callback) {
|
|
debug('DEVICE SESSION CALLED');
|
|
|
|
//
|
|
// Check we have valid tokens.
|
|
//
|
|
const detailsP = getSecurityValues(req, scopes).catch((error) => {
|
|
debug('Failed to get security values', error);
|
|
return Q.reject(utils.createError(30013, 'Missing or invalid security params'));
|
|
});
|
|
|
|
//
|
|
// Validate the client and device details
|
|
//
|
|
const validP = detailsP.then((info) => {
|
|
return validateDeviceSession(info);
|
|
});
|
|
|
|
//
|
|
// Initialise the session info
|
|
//
|
|
const sessionP = validP.then((sessionInfo) => {
|
|
return apiUtils.initSession(req, sessionInfo.client, sessionInfo.device);
|
|
});
|
|
|
|
//
|
|
// Check all the requirements passed
|
|
//
|
|
Q.all([detailsP, validP, sessionP])
|
|
.then(() => {
|
|
//
|
|
// Everything passed so continue
|
|
//
|
|
return callback();
|
|
})
|
|
.catch((error) => {
|
|
//
|
|
// Something failed. Log the real error, then return a generic error (for security)
|
|
//
|
|
debug('Failed to authorise deviceSession', error);
|
|
const authFail = new Error('Not authorised');
|
|
authFail.statusCode = 401;
|
|
return callback(authFail);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Validates the HMAC against the "device" security model in Login (and similar) where there is no
|
|
* session yet. This requires 2 headers:
|
|
* 1. `x-bridge-hmac`: The hmac for the request as described in the wiki
|
|
* 2. `x-bridge-timestamp`: The timestamp of the packet
|
|
*
|
|
* It also requires a path with the device specified as the objectId
|
|
*
|
|
* @param {Object} req - the express request object
|
|
* @param {Object} def - the definition of the security definition we are validating
|
|
* @param {Object} scopes - the value of the header specified in the definition
|
|
* @param {Function!} callback - the callback function for success or failure or the security validation
|
|
*/
|
|
function deviceHmacNoSession(req, def, scopes, callback) {
|
|
debug('DEVICE HMAC NO SESSION CALLED');
|
|
|
|
//
|
|
// Check we have valid values.
|
|
//
|
|
const detailsP = getSecurityValues(req, scopes, true).catch((error) => {
|
|
debug('Failed to get security values', error);
|
|
return Q.reject(utils.createError(30013, 'Missing or invalid security params'));
|
|
});
|
|
|
|
//
|
|
// Validate the client and device details
|
|
//
|
|
const validP = detailsP.then((info) => {
|
|
return validateDeviceNoSession(req, info);
|
|
});
|
|
|
|
//
|
|
// Initialise the session info
|
|
//
|
|
const sessionP = validP.then((sessionInfo) => {
|
|
return apiUtils.initSession(req, sessionInfo.client, sessionInfo.device);
|
|
});
|
|
|
|
//
|
|
// Check all the requirements passed
|
|
//
|
|
Q.all([detailsP, validP, sessionP])
|
|
.then(() => {
|
|
//
|
|
// Everything passed so continue
|
|
//
|
|
return callback();
|
|
})
|
|
.catch((error) => {
|
|
//
|
|
// Something failed. Log the real error, then return a generic error (for security)
|
|
//
|
|
debug('Failed to authorise deviceHmacNoSession', error);
|
|
const authFail = new Error('Not authorised');
|
|
authFail.statusCode = 401;
|
|
return callback(authFail);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Gets the values we need to check the security from the appropriate headers.
|
|
* This also validates that they are in the correct format, splits the session
|
|
* tokens, etc.
|
|
*
|
|
* @param {Object} req - the request object
|
|
* @param {string} session - the session header value
|
|
* @param {boolean} ignoreSession - true to not expect any session values
|
|
*
|
|
* @returns {Object} - Object containing the information we need for the security testing
|
|
*/
|
|
async function getSecurityValues(req, session, ignoreSession) {
|
|
if (!VALIDATORS.initialised) {
|
|
debug('Validators not initialised!');
|
|
await initialiseValidators();
|
|
}
|
|
|
|
//
|
|
// Get tokens from the headers we expect
|
|
//
|
|
let deviceToken;
|
|
let sessionToken;
|
|
let sessionOk = false;
|
|
|
|
if (ignoreSession) {
|
|
sessionOk = true;
|
|
} else {
|
|
sessionOk = VALIDATORS.session(session);
|
|
if (sessionOk === false) {
|
|
debug('Session header failed validation:', VALIDATORS.session.errors);
|
|
} else {
|
|
//
|
|
// Need to split the token into the two parts.
|
|
// NOTE: the JSON Schema validation has ensured that it is in the right format
|
|
// so we don't need to check for errors
|
|
//
|
|
[deviceToken, sessionToken] = session.split(':');
|
|
}
|
|
}
|
|
|
|
const hmac = req.headers['x-bridge-hmac'];
|
|
const hmacOk = VALIDATORS.hmac(hmac);
|
|
if (!hmacOk) {
|
|
debug('HMAC header failed validation:', VALIDATORS.hmac.errors);
|
|
}
|
|
|
|
const timestamp = req.headers['x-bridge-timestamp'];
|
|
const timestampOk = VALIDATORS.timestamp(timestamp);
|
|
if (!timestampOk) {
|
|
debug('HMAC timestamp header failed validation:', VALIDATORS.timestamp.errors);
|
|
}
|
|
|
|
//
|
|
// Check we got all 3 headers and they are formatted correctly
|
|
//
|
|
if (!sessionOk || !hmacOk || !timestampOk) {
|
|
throw new Error('Invalid headers');
|
|
}
|
|
|
|
//
|
|
// Get the full request address. This is a little tricky because there is no one place to get it:
|
|
// 1. The hostname comes in a header which is controlled by the caller, and is thus untrusted.
|
|
// e.g. see http://www.skeletonscribe.net/2013/05/practical-http-host-header-attacks.html
|
|
// - We use our configuration value instead, which is set by us and thus trusted
|
|
// 2. The port is only included in the URL if using a non-standard port, and more importantly
|
|
// we care about the external port (at the gateway), not the internal one we are running on.
|
|
// - Again, we rely on the configuration value to include the port if neccessary
|
|
// 3. The other parts of the URL are split across multiple params in the request
|
|
// - We recombine everything in a safe way using the nodejs URL lib
|
|
//
|
|
const baseUrl = url.format({
|
|
host: config.CCWebsiteAddress,
|
|
protocol: req.protocol,
|
|
slashes: true
|
|
});
|
|
const fullUrl = new url.URL(
|
|
req.originalUrl,
|
|
baseUrl
|
|
);
|
|
const address = fullUrl.toString();
|
|
|
|
//
|
|
// Get the raw body so we can use that as part of validating the hmac.
|
|
// This is stored in the req object by the api_body_middleware.js, and only
|
|
// exists if there was a body (and it was readable according to the given encoding).
|
|
//
|
|
const body = _.isUndefined(req.bodyRaw) ? '' : req.bodyRaw;
|
|
|
|
//
|
|
// Get other basic values from the request
|
|
//
|
|
const method = req.method;
|
|
const featureFlag = req.swagger.operation['x-feature-flag'];
|
|
|
|
return {
|
|
deviceToken,
|
|
sessionToken,
|
|
hmac,
|
|
timestamp,
|
|
address,
|
|
method,
|
|
body,
|
|
featureFlag
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Initialises the validator object with compiled versions of the schemas we will
|
|
* use to validate our incoming header parameters. These schemas are held within
|
|
* the definitions section of the swagger definition.
|
|
*
|
|
* @throws {Error} - throws an error if the schema is invalid
|
|
*/
|
|
async function initialiseValidators() {
|
|
const swaggerDefs = await JsonRefs.resolveRefsAt(require.resolve('./api_definitions.json'));
|
|
const definitions = swaggerDefs.resolved.definitions;
|
|
|
|
const sessionSchema = definitions['security.Device.sessionHeader'];
|
|
const hmacSchema = definitions['security.Device.hmacHeader'];
|
|
const timestampSchema = definitions['security.Device.hmacTimestamp'];
|
|
|
|
const emailSchema = definitions.email;
|
|
const objectIdSchema = definitions.uuid;
|
|
|
|
VALIDATORS.session = ajv.compile(sessionSchema);
|
|
VALIDATORS.hmac = ajv.compile(hmacSchema);
|
|
VALIDATORS.timestamp = ajv.compile(timestampSchema);
|
|
VALIDATORS.email = ajv.compile(emailSchema);
|
|
VALIDATORS.objectId = ajv.compile(objectIdSchema);
|
|
VALIDATORS.initialised = true;
|
|
}
|
|
|
|
/**
|
|
* Function to do the validation of the session and HMAC based on the info we extracted from
|
|
* the headers.
|
|
*
|
|
* @param {Object} sessionInfo - The validated info from the request headers etc.
|
|
* @param {string} sessionInfo.deviceToken - The device token from the header
|
|
* @param {string} sessionInfo.sessionToken - The session token from the header
|
|
* @param {string} sessionInfo.hmac - The value of the HMAC header (expected HMAC)
|
|
* @param {string} sessionInfo.timestamp - The value of the HMAC timestamp header
|
|
* @param {string} sessionInfo.address - The full request url: https://example.com/p/a/t/h/?a=123
|
|
* @param {string} sessionInfo.method - The HTTP method used for the request
|
|
* @param {string?} sessionInfo.body - The request body (if any)
|
|
* @param {string?} sessionInfo.featureFlag - The feature flag required for this feature (if any)
|
|
*
|
|
* @returns {Promise} - Promise for the successful validation (or rejected with error)
|
|
*/
|
|
async function validateDeviceSession(sessionInfo) {
|
|
/**
|
|
* Get the client details (client and device objects)
|
|
*/
|
|
const clientDetails = await auth.validateCurrentSession(
|
|
sessionInfo.deviceToken,
|
|
sessionInfo.sessionToken
|
|
);
|
|
|
|
/**
|
|
* Build the data in the format the legacy checkHMAC() function expects it.
|
|
* NOTE: the device and session tokens used to be implicitly included in the hmac calculation
|
|
* as they were passed in the body itself. As they are now passed as a header, we
|
|
* manually append them to the body to have the same effect of verifying that this
|
|
* request is tied to this device and session.
|
|
*/
|
|
const device = clientDetails[0];
|
|
const client = clientDetails[1];
|
|
|
|
const hmacData = {
|
|
address: sessionInfo.address,
|
|
method: sessionInfo.method,
|
|
body: sessionInfo.body + sessionInfo.deviceToken + ':' + sessionInfo.sessionToken,
|
|
ClientName: client.ClientName,
|
|
timestamp: sessionInfo.timestamp,
|
|
hmac: sessionInfo.hmac
|
|
};
|
|
|
|
/**
|
|
* Validate the HMAC based on all the details
|
|
*/
|
|
await auth.checkHMAC(
|
|
device,
|
|
hmacData,
|
|
'validateDeviceSession',
|
|
);
|
|
|
|
/**
|
|
* Validate the featureFlags exist if required
|
|
*/
|
|
const requiredFlag = sessionInfo.featureFlag;
|
|
if (requiredFlag && !featureFlags.isEnabled(requiredFlag, client)) {
|
|
debug('Required feature flag not present', requiredFlag);
|
|
throw utils.createError(30012, 'Feature unavailable');
|
|
}
|
|
|
|
/**
|
|
* Return the client and device for setting up the session
|
|
*/
|
|
return {
|
|
client,
|
|
device
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Function to do the validation of the provided values and HMAC based on the
|
|
* info provided in the path, the headers, and the body. This requires:
|
|
* - hmac and timestamp from the headers
|
|
* - objectId from the path
|
|
* - ClientName from the body
|
|
*
|
|
* @param {Object} req - The express request object
|
|
* @param {Object} sessionInfo - The validated info from the request headers etc.
|
|
* @param {string} sessionInfo.hmac - The value of the HMAC header (expected HMAC)
|
|
* @param {string} sessionInfo.timestamp - The value of the HMAC timestamp header
|
|
* @param {string} sessionInfo.address - The full request url: https://example.com/p/a/t/h/?a=123
|
|
* @param {string} sessionInfo.method - The HTTP method used for the request
|
|
* @param {string?} sessionInfo.body - The request body (if any)
|
|
* @param {string?} sessionInfo.featureFlag - The feature flag required for this feature (if any)
|
|
*
|
|
* @returns {Promise} - Promise for the successful validation (or rejected with error)
|
|
*/
|
|
async function validateDeviceNoSession(req, sessionInfo) {
|
|
/**
|
|
* Get the client details (client and device objects)
|
|
* As this is run before the main validation we have to manually validate it ourselves
|
|
*/
|
|
const deviceID = _.get(req, 'swagger.params.objectId.value');
|
|
const deviceIDOk = VALIDATORS.objectId(deviceID);
|
|
const clientEmail = _.get(req, 'swagger.params.body.value.ClientName');
|
|
const clientEmailOk = VALIDATORS.email(clientEmail);
|
|
|
|
if (!deviceIDOk || !clientEmailOk) {
|
|
throw new Error('Invalid parameters');
|
|
}
|
|
|
|
/**
|
|
* Get the client
|
|
*/
|
|
const client = await references.getClientByEmail(clientEmail);
|
|
|
|
/**
|
|
* Get the device (checking it belongs to our client)
|
|
*/
|
|
const device = await mainDBP.findOneObject(
|
|
mainDBP.mainDB.collectionDevice,
|
|
{
|
|
_id: mongodb.ObjectID(deviceID),
|
|
ClientID: client.ClientID
|
|
},
|
|
undefined, // No options
|
|
true // Suppress errors (i.e. failing to find an object isn't a db connection issue)
|
|
);
|
|
|
|
/**
|
|
* Verify that both client and device are in a good state (validated, & not banned or blocked)
|
|
*/
|
|
const clientStatus = auth.checkClientStatus(client.ClientStatus);
|
|
const deviceStatus = auth.checkDeviceStatus(device.DeviceStatus);
|
|
|
|
if (clientStatus !== null) {
|
|
throw clientStatus;
|
|
}
|
|
if (deviceStatus !== null) {
|
|
throw deviceStatus;
|
|
}
|
|
|
|
/**
|
|
* Need to convert the request name to the one expected by the legacy auth functions
|
|
*/
|
|
const swaggerFunction = _.get(req, 'swagger.operation.operationId');
|
|
let authFunctionName = swaggerFunction;
|
|
if (swaggerFunction === 'deviceLogin') {
|
|
authFunctionName = 'Login1.process';
|
|
}
|
|
|
|
/**
|
|
* Build the data in the format the legacy checkHMAC() function expects it.
|
|
*/
|
|
const hmacData = {
|
|
address: sessionInfo.address,
|
|
method: sessionInfo.method,
|
|
body: sessionInfo.body,
|
|
ClientName: client.ClientName,
|
|
timestamp: sessionInfo.timestamp,
|
|
hmac: sessionInfo.hmac
|
|
};
|
|
|
|
/**
|
|
* Validate the HMAC based on all the details
|
|
*/
|
|
await auth.checkHMAC(
|
|
device,
|
|
hmacData,
|
|
authFunctionName,
|
|
);
|
|
|
|
/**
|
|
* Validate the featureFlags exist if required
|
|
*/
|
|
const requiredFlag = sessionInfo.featureFlag;
|
|
if (requiredFlag && !featureFlags.isEnabled(requiredFlag, client)) {
|
|
debug('Required feature flag not present', requiredFlag);
|
|
throw utils.createError(30012, 'Feature unavailable');
|
|
}
|
|
|
|
/**
|
|
* Return the client and device for setting up the session
|
|
*/
|
|
return {
|
|
client,
|
|
device
|
|
};
|
|
}
|