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

375 lines
12 KiB
JavaScript

//
// This file manages the API security handling for the custom Swagger security
// definitions we have
//
'use strict';
const debug = require('debug')('webconsole-api:security');
const crypto = require('crypto');
const featureFlags = require(global.pathPrefix + '../utils/feature-flags/feature-flags.js');
const XSRF_HMAC_KEY = 'XwjyusBZRYVQaAhVTE77pChg';
//
// Session Types: Less than 0 are minimal permissions, >0 are general sessions
// that can be elevated.
//
const SESSION_TYPES = {
RECOVERY: -10,
AWAITING_ACCEPT_EULA: -2,
AWAITING_2FA: -1,
BASIC: 0,
ELEVATED: 1
};
//
// The type of session level match required.
//
const MATCH_TYPE = {
EXACT: 1,
EQUAL_OR_GREATER: 2
};
//
// Level comparison results
//
const LEVEL_COMPARE_RESULT = {
SUCCESS: 0,
FAIL: -1,
NEEDS_ESCALATION: -2
};
module.exports = {
bridgeSession,
elevatedBridgeSession,
awaiting2FASession: awaitingTwoFASession,
awaitingAcceptEulaSession,
recoverySession,
generateXsrfToken,
SESSION_TYPES
};
//
// Callback definition for the results of the swagger security handling
// function.
// @example
// // Successful security check
// callback(); // No parameters
// @example
// // Failed security check
// var error = new Error("A text description of the error");
// error.statusCode = 401; // If you don't want just 403
// callback(error);
//
// @callback securityCallback
// @param {Object} err - Error object. Use a `new Error` for best results,
// and add a `statusCode` parameter if you want
// to change the default `403` HTTP response code
// @param {Object} v - Unknown and undocumented
//
// Security function to test for the existence of a normal bridge session.
// The security function is looking for:
// - [cookie] X-BRIDGE-SESSION
// -- this contains the session token in a `Secure, HttpOnly`, session cookie
// -- used to find/confirm the user session
// -- should only be accessible to the browser so can't be read/stolen by JS
// running in the browser.
// - [header] X-XSRF-TOKEN
// -- clone of the X-XSRF-TOKEN response in the body of the login response.
// -- This ensure we are running JS (as e.g. HTML forms can't set headers. We
// will then prevent browser JS access to this server with CORS, CSP, etc.
// -- It is calculated from and can be verified using the X-BRIDGE-SESSION.
//
// Note that the above security items are largely about securing the browser
// instance against XSRF, XSS, and similar threats. They limitations on access
// to cookies and headers purely relates to what browsers //should// manage
// for code running within HTML. They are not guaranteed, and in particular
// have no bearing on what independant applications can do with HTTP responses.
// So the session management itself must also be robust and secure outside of
// these items (e.g. with session timeouts to limit risk, re-entering
// credentials before making significant changes, etc.)
//
// @param {Object} req - request object, with lots of important information
// @param {Object} def - definition of this security setting in Swagger
// @param {String} scopes - the values for the fields identified in the swagger
// definition(e.g.the value of the specified header)
// @param {integer} matchType - the type of match required for the session level
// @param {securityCallback} callback - The callback handler
//
function bridgeSessionBase(req, def, scopes, requiredLevel, matchType, callback) {
debug('bridgeSession credentials verification');
//
// Check that there exists at least some value for X-XSRF-TOKEN
//
if (!scopes) {
debug('- no credentials supplied');
reportError(callback);
return;
}
const session = req.session;
if (!session || !session.hasOwnProperty('data')) {
reportError(callback);
return;
}
const sessionTokenValue = session.id;
if (!sessionTokenValue) {
debug('- No session id found');
reportError(callback);
return;
}
const email = session.data.email;
if (!email) {
debug('- No session email');
reportError(callback);
return;
}
//
// Check if a required feature flag is set
//
const requiredFlag = req.swagger.operation['x-feature-flag'];
if (requiredFlag && !featureFlags.isEnabled(requiredFlag, session.data)) {
reportFlagRequired(callback);
return;
}
//
// Check if the available level is sufficient to access this request
//
const availableLevel = session.data.level;
if (availableLevel === undefined) {
debug('- No session level');
reportError(callback);
return;
}
const compareResult = checkAvailableLevel(availableLevel, requiredLevel, matchType);
if (compareResult === LEVEL_COMPARE_RESULT.FAIL) {
reportError(callback);
return;
} else if (compareResult === LEVEL_COMPARE_RESULT.NEEDS_ESCALATION) {
reportElevationRequired(callback);
return;
}
//
// Re-generate the XSRF token - this uses an asynchronous stream
//
const xsrfTokenGen = generateXsrfToken(sessionTokenValue, email);
xsrfTokenGen.on('readable', () => {
//
// Get the generated token and check it matches the one we were given
//
const xsrfToken = xsrfTokenGen.read();
if (xsrfToken === scopes) {
// Success!
debug('- Succesfully validated');
return callback();
} else {
debug('- Failed token comparison');
return reportError(callback);
}
});
}
/**
* Compares the level of the session with the level we need, and checks whether
* we can request elevation if it is insufficient.
*
* @param {number} availableLevel - the level we have from the session
* @param {number} requiredLevel - the level this call requires
* @param {MATCH_TYPE} matchType - the type of match required
*
* @returns {LEVEL_COMPARE_RESULT} - the result of the session level comparison
*/
function checkAvailableLevel(availableLevel, requiredLevel, matchType) {
let matched = false;
switch (matchType) {
case MATCH_TYPE.EXACT:
matched = availableLevel === requiredLevel;
break;
case MATCH_TYPE.EQUAL_OR_GREATER:
matched = availableLevel >= requiredLevel;
break;
}
if (!matched) {
debug('- Insufficient session level: ', availableLevel, requiredLevel);
if (availableLevel < 0 || matchType === MATCH_TYPE.EXACT) {
// Just not authorised
return LEVEL_COMPARE_RESULT.FAIL;
} else {
// Can be elevated
return LEVEL_COMPARE_RESULT.NEEDS_ESCALATION;
}
}
return LEVEL_COMPARE_RESULT.SUCCESS;
}
//
// Security function to test for the existence of as session that is only for
// awaiting the acceptance of an updated EULA.
//
// @param {Object} req - request object, with lots of important information
// @param {Object} def - definition that we are currently being called for
// @param {String} scopes - the values for the fields identified in the swagger
// definition(e.g.the value of the specified header)
// @param {securityCallback} callback - The callback handler
//
function awaitingAcceptEulaSession(req, def, scopes, callback) {
bridgeSessionBase(
req, def, scopes,
SESSION_TYPES.AWAITING_ACCEPT_EULA,
MATCH_TYPE.EQUAL_OR_GREATER,
callback
);
}
//
// Security function to test for the existence of as session that is only for
// awaiting the results of a 2FA callback.
//
// @param {Object} req - request object, with lots of important information
// @param {Object} def - definition that we are currently being called for
// @param {String} scopes - the values for the fields identified in the swagger
// definition(e.g.the value of the specified header)
// @param {securityCallback} callback - The callback handler
//
function awaitingTwoFASession(req, def, scopes, callback) {
bridgeSessionBase(
req, def, scopes,
SESSION_TYPES.AWAITING_2FA,
MATCH_TYPE.EQUAL_OR_GREATER,
callback
);
}
//
// Security function to test for the existence of an standard (or higher) bridge session.
//
// @param {Object} req - request object, with lots of important information
// @param {Object} def - definition that we are currently being called for
// @param {String} scopes - the values for the fields identified in the swagger
// definition(e.g.the value of the specified header)
// @param {securityCallback} callback - The callback handler
//
function bridgeSession(req, def, scopes, callback) {
bridgeSessionBase(
req, def, scopes,
SESSION_TYPES.BASIC,
MATCH_TYPE.EQUAL_OR_GREATER,
callback
);
}
//
// Security function to test for the existence of an elevated bridge session.
// An elevated session is required to make substantive changes to an account
//
// @param {Object} req - request object, with lots of important information
// @param {Object} def - definition that we are currently being called for
// @param {String} scopes - the values for the fields identified in the swagger
// definition(e.g.the value of the specified header)
// @param {securityCallback} callback - The callback handler
//
function elevatedBridgeSession(req, def, scopes, callback) {
bridgeSessionBase(
req, def, scopes,
SESSION_TYPES.ELEVATED,
MATCH_TYPE.EQUAL_OR_GREATER,
callback
);
}
//
// Security function to test for the existence of an account recovery session.
// The account recovery session is required to process the recovery of an
// account based on various information.
//
// @param {Object} req - request object, with lots of important information
// @param {Object} def - definition that we are currently being called for
// @param {String} scopes - the values for the fields identified in the swagger
// definition(e.g.the value of the specified header)
// @param {securityCallback} callback - The callback handler
//
function recoverySession(req, def, scopes, callback) {
bridgeSessionBase(
req, def, scopes,
SESSION_TYPES.RECOVERY,
MATCH_TYPE.EXACT,
callback
);
}
//
// Generates the XSRF token as a digest of the sessionId and the email.
// @see {@link https://docs.angularjs.org/api/ng/service/$http}
//
// @example
// // Read the token asynchronously
// var xsrfTokenGen = generateXsrfToken('abcd', 'admin@example.com');
// xsrfTokenGen.on('readable', function () {
// var token = xsrfTokenGen.read();
// console.log('The token is: ', token);
// });
//
// @param {String} sessionId - the current sessionId
// @param {String} email - the users email address
//
// @return {Object} - a crypto stream for the result
//
function generateXsrfToken(sessionId, email) {
const hmac = crypto.createHmac('sha256', XSRF_HMAC_KEY);
hmac.setEncoding('hex'); // avoid values invalid in cookies and/or urls
hmac.write(sessionId, 'utf8');
hmac.end(email, 'utf8');
return hmac;
}
//
// Function to return a consistent error response for failures to authenticate.
// This function is deliberately light on details so as not to leak extra
// information.
//
// @param {securityCallback} callback - The callback to use for responses
//
function reportError(callback) {
const error = new Error('Not authorised');
error.statusCode = 401;
return callback(error);
}
//
// Function to return a specific error when a feature fails because it needs
// an elevated session that it dosen't have
//
// @param {securityCallback} callback - The callback to use for responses
//
function reportElevationRequired(callback) {
const error = new Error('Elevated session required');
error.statusCode = 426;
return callback(error);
}
//
// Function to return a specific error when a path requires a feature flag but
// the user doesn't have that flag enabled.
//
// @param {securityCallback} callback - The callback to use for responses
//
function reportFlagRequired(callback) {
const error = new Error('Feature unavailable');
error.statusCode = 403;
return callback(error);
}