var _ = require('lodash'), crypto = require('crypto'), urlEncoder = require('postman-url-encoder'), RequestBody = require('postman-collection').RequestBody, bodyBuilder = require('../requester/core-body-builder'), EMPTY = '', ONE = '00000001', DISABLE_RETRY_REQUEST = 'disableRetryRequest', WWW_AUTHENTICATE = 'www-authenticate', DIGEST_PREFIX = 'Digest ', QOP = 'qop', AUTH = 'auth', COLON = ':', QUOTE = '"', SESS = '-sess', AUTH_INT = 'auth-int', AUTHORIZATION = 'Authorization', MD5_SESS = 'MD5-sess', ASCII_SOURCE = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', ASCII_SOURCE_LENGTH = ASCII_SOURCE.length, USERNAME_EQUALS_QUOTE = 'username="', REALM_EQUALS_QUOTE = 'realm="', NONCE_EQUALS_QUOTE = 'nonce="', URI_EQUALS_QUOTE = 'uri="', ALGORITHM_EQUALS_QUOTE = 'algorithm="', CNONCE_EQUALS_QUOTE = 'cnonce="', RESPONSE_EQUALS_QUOTE = 'response="', OPAQUE_EQUALS_QUOTE = 'opaque="', QOP_EQUALS = 'qop=', NC_EQUALS = 'nc=', ALGO = { MD5: 'MD5', MD5_SESS: 'MD5-sess', SHA_256: 'SHA-256', SHA_256_SESS: 'SHA-256-sess', SHA_512_256: 'SHA-512-256', SHA_512_256_SESS: 'SHA-512-256-sess' }, AUTH_PARAMETERS = [ 'algorithm', 'username', 'realm', 'password', 'method', 'nonce', 'nonceCount', 'clientNonce', 'opaque', 'qop', 'uri' ], nonceRegex = /nonce="([^"]*)"/, realmRegex = /realm="([^"]*)"/, qopRegex = /qop="([^"]*)"/, opaqueRegex = /opaque="([^"]*)"/, _extractField, SHA512_256, nodeCrypto; // Current Electron version(7.2.3) in Postman app uses OpenSSL 1.1.0 // which don't support `SHA-512-256`. Use external `js-sha512` module // to handle this case. if (!_.includes(crypto.getHashes(), 'sha512-256')) { SHA512_256 = require('js-sha512').sha512_256; nodeCrypto = crypto; // create a wrapper class with similar interface to Node's crypto and use jsSHA // to support SHA512-256 algorithm crypto = function () { this._hash = SHA512_256.create(); }; _.assign(crypto.prototype, { update: function (data) { this._hash.update(data); return this; }, digest: function () { // we only need 'hex' digest for this auth return this._hash.hex(); } }); _.assign(crypto, { createHash: function (hashAlgo) { // return hash from js-sha for SHA512-256 if (hashAlgo === 'sha512-256') { return new crypto(); } // return Node's hash otherwise return nodeCrypto.createHash(hashAlgo); } }); } /** * Generates a random string of given length * * @todo Move this to util.js. After moving use that for hawk auth too * @param {Number} length */ function randomString (length) { length = length || 6; var result = [], i; for (i = 0; i < length; i++) { result[i] = ASCII_SOURCE[(Math.random() * ASCII_SOURCE_LENGTH) | 0]; } return result.join(EMPTY); } /** * Extracts a Digest Auth field from a WWW-Authenticate header value using a given regexp. * * @param {String} string * @param {RegExp} regexp * @private */ _extractField = function (string, regexp) { var match = string.match(regexp); return match ? match[1] : EMPTY; }; /** * Returns the 'www-authenticate' header for Digest auth. Since a server can suport more than more auth-scheme, * there can be more than one header with the same key. So need to loop over and check each one. * * @param {VariableList} headers * @private */ function _getDigestAuthHeader (headers) { return headers.find(function (property) { return (property.key.toLowerCase() === WWW_AUTHENTICATE) && (_.startsWith(property.value, DIGEST_PREFIX)); }); } /** * Returns hex encoded hash of given data using given algorithm. * * @param {String} data string to calculate hash * @param {String} algorithm hash algorithm * @returns {String} hex encoded hash of given data */ function getHash (data, algorithm) { return crypto.createHash(algorithm).update(data || EMPTY).digest('hex'); } /** * Calculates body hash with given algorithm and digestEncoding. * * @param {RequestBody} body Request body * @param {String} algorithm Hash algorithm to use * @param {String} digestEncoding Encoding of the hash * @param {Function} callback Callback function that will be called with body hash */ function computeBodyHash (body, algorithm, digestEncoding, callback) { if (!(algorithm && digestEncoding)) { return callback(); } var hash = crypto.createHash(algorithm), originalReadStream, rawBody, graphqlBody, urlencodedBody; // if body is not available, return hash of empty string if (!body || body.isEmpty()) { return callback(hash.digest(digestEncoding)); } if (body.mode === RequestBody.MODES.raw) { rawBody = bodyBuilder.raw(body.raw).body; hash.update(rawBody); return callback(hash.digest(digestEncoding)); } if (body.mode === RequestBody.MODES.urlencoded) { urlencodedBody = bodyBuilder.urlencoded(body.urlencoded).form; urlencodedBody = urlEncoder.encodeQueryString(urlencodedBody); hash.update(urlencodedBody); return callback(hash.digest(digestEncoding)); } if (body.mode === RequestBody.MODES.file) { originalReadStream = _.get(body, 'file.content'); if (!originalReadStream) { return callback(); } return originalReadStream.cloneReadStream(function (err, clonedStream) { if (err) { return callback(); } clonedStream.on('data', function (chunk) { hash.update(chunk); }); clonedStream.on('end', function () { callback(hash.digest(digestEncoding)); }); }); } if (body.mode === RequestBody.MODES.graphql) { graphqlBody = bodyBuilder.graphql(body.graphql).body; hash.update(graphqlBody); return callback(hash.digest(digestEncoding)); } // @todo: Figure out a way to calculate hash for formdata body type. // ensure that callback is called if body.mode doesn't match with any of the above modes return callback(); } /** * All the auth definition parameters excluding username and password should be stored and resued. * @todo The current implementation would fail for the case when two requests to two different hosts inherits the same * auth. In that case a retry would not be attempted for the second request (since all the parameters would be present * in the auth definition though invalid). * * @implements {AuthHandlerInterface} */ module.exports = { /** * @property {AuthHandlerInterface~AuthManifest} */ manifest: { info: { name: 'digest', version: '1.0.0' }, updates: [ { property: 'Authorization', type: 'header' }, { property: 'nonce', type: 'auth' }, { property: 'realm', type: 'auth' } ] }, /** * Initializes an item (extracts parameters from intermediate requests if any, etc) * before the actual authorization step. * * @param {AuthInterface} auth * @param {Response} response * @param {AuthHandlerInterface~authInitHookCallback} done */ init: function (auth, response, done) { done(null); }, /** * Checks whether the given item has all the required parameters in its request. * Sanitizes the auth parameters if needed. * * @param {AuthInterface} auth * @param {AuthHandlerInterface~authPreHookCallback} done */ pre: function (auth, done) { // ensure that all dynamic parameter values are present in the parameters // if even one is absent, we return false. done(null, Boolean(auth.get('nonce') && auth.get('realm'))); }, /** * Verifies whether the request was successfully authorized after being sent. * * @param {AuthInterface} auth * @param {Response} response * @param {AuthHandlerInterface~authPostHookCallback} done */ post: function (auth, response, done) { if (auth.get(DISABLE_RETRY_REQUEST) || !response) { return done(null, true); } var code, realm, nonce, qop, opaque, authHeader, authParams = {}; code = response.code; authHeader = _getDigestAuthHeader(response.headers); // If code is forbidden or unauthorized, and an auth header exists, // we can extract the realm & the nonce, and replay the request. // todo: add response.is4XX, response.is5XX, etc in the SDK. if ((code === 401 || code === 403) && authHeader) { nonce = _extractField(authHeader.value, nonceRegex); realm = _extractField(authHeader.value, realmRegex); qop = _extractField(authHeader.value, qopRegex); opaque = _extractField(authHeader.value, opaqueRegex); authParams.nonce = nonce; authParams.realm = realm; opaque && (authParams.opaque = opaque); qop && (authParams.qop = qop); if (authParams.qop || auth.get(QOP)) { authParams.clientNonce = randomString(8); authParams.nonceCount = ONE; } // if all the auth parameters sent by server were already present in auth definition then we do not retry if (_.every(authParams, function (value, key) { return auth.get(key); })) { return done(null, true); } auth.set(authParams); return done(null, false); } done(null, true); }, /** * Computes the Digest Authentication header from the given parameters. * * @param {Object} params * @param {String} params.algorithm * @param {String} params.username * @param {String} params.realm * @param {String} params.password * @param {String} params.method * @param {String} params.nonce * @param {String} params.nonceCount * @param {String} params.clientNonce * @param {String} params.opaque * @param {String} params.qop * @param {String} params.uri * @returns {String} */ computeHeader: function (params) { var algorithm = params.algorithm, hashAlgo = params.hashAlgo, username = params.username, realm = params.realm, password = params.password, method = params.method, nonce = params.nonce, nonceCount = params.nonceCount, clientNonce = params.clientNonce, opaque = params.opaque, qop = params.qop, uri = params.uri, // RFC defined terms, http://tools.ietf.org/html/rfc2617#section-3 A0, A1, A2, hashA1, hashA2, reqDigest, headerParams; if (_.endsWith(algorithm, SESS)) { A0 = getHash(username + COLON + realm + COLON + password, hashAlgo); A1 = A0 + COLON + nonce + COLON + clientNonce; } else { A1 = username + COLON + realm + COLON + password; } if (qop === AUTH_INT) { A2 = method + COLON + uri + COLON + params.bodyhash; } else { A2 = method + COLON + uri; } hashA1 = getHash(A1, hashAlgo); hashA2 = getHash(A2, hashAlgo); if (qop === AUTH || qop === AUTH_INT) { reqDigest = getHash([hashA1, nonce, nonceCount, clientNonce, qop, hashA2].join(COLON), hashAlgo); } else { reqDigest = getHash([hashA1, nonce, hashA2].join(COLON), hashAlgo); } headerParams = [USERNAME_EQUALS_QUOTE + username + QUOTE, REALM_EQUALS_QUOTE + realm + QUOTE, NONCE_EQUALS_QUOTE + nonce + QUOTE, URI_EQUALS_QUOTE + uri + QUOTE ]; algorithm && headerParams.push(ALGORITHM_EQUALS_QUOTE + algorithm + QUOTE); if (qop === AUTH || qop === AUTH_INT) { headerParams.push(QOP_EQUALS + qop); } if (qop === AUTH || qop === AUTH_INT || algorithm === MD5_SESS) { nonceCount && headerParams.push(NC_EQUALS + nonceCount); headerParams.push(CNONCE_EQUALS_QUOTE + clientNonce + QUOTE); } headerParams.push(RESPONSE_EQUALS_QUOTE + reqDigest + QUOTE); opaque && headerParams.push(OPAQUE_EQUALS_QUOTE + opaque + QUOTE); return DIGEST_PREFIX + headerParams.join(', '); }, /** * Signs a request. * * @param {AuthInterface} auth * @param {Request} request * @param {AuthHandlerInterface~authSignHookCallback} done */ sign: function (auth, request, done) { var self = this, params = auth.get(AUTH_PARAMETERS), url = urlEncoder.toNodeUrl(request.url), header; if (!params.username || !params.realm) { return done(); // Nothing to do if required parameters are not present. } request.removeHeader(AUTHORIZATION, {ignoreCase: true}); params.method = request.method; params.uri = url.path; switch (params.algorithm) { case ALGO.SHA_256: case ALGO.SHA_256_SESS: params.hashAlgo = 'sha256'; break; case ALGO.MD5: case ALGO.MD5_SESS: case EMPTY: case undefined: case null: params.algorithm = params.algorithm || ALGO.MD5; params.hashAlgo = 'md5'; break; case ALGO.SHA_512_256: case ALGO.SHA_512_256_SESS: params.hashAlgo = 'sha512-256'; break; default: return done(new Error(`Unsupported digest algorithm: ${params.algorithm}`)); } // calculate body hash for qop='auth-int' if (params.qop === AUTH_INT) { return computeBodyHash(request.body, params.hashAlgo, 'hex', function (bodyhash) { params.bodyhash = bodyhash; header = self.computeHeader(params); request.addHeader({ key: AUTHORIZATION, value: header, system: true }); return done(); }); } header = self.computeHeader(params); request.addHeader({ key: AUTHORIZATION, value: header, system: true }); return done(); } };