bridge-node-server/node_server/swagger_api/api_security_device.js
Martin Donnelly 57bd6c8e6a init
2018-06-24 21:15:03 +01:00

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
};
}