// // 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`: `:` * 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 }; }