375 lines
12 KiB
JavaScript
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);
|
|
}
|