From 8c52e4ffd8d7265abcc4f01c6e956ef861cb80c8 Mon Sep 17 00:00:00 2001 From: J Bruni Date: Sun, 31 Mar 2019 02:35:45 -0300 Subject: [PATCH 1/4] Support for digest authentication --- source/auth.js | 63 ++++++++++++++++++++++++++++++++- source/factory.js | 6 ++-- source/fetch.js | 90 +++++++++++++++++++++++++++++++++++++++++++++++ source/request.js | 7 ++-- 4 files changed, 161 insertions(+), 5 deletions(-) create mode 100644 source/fetch.js diff --git a/source/auth.js b/source/auth.js index 23b09abb..5d406b81 100644 --- a/source/auth.js +++ b/source/auth.js @@ -1,3 +1,4 @@ +const crypto = require("crypto"); const { toBase64 } = require("./encode.js"); function generateBasicAuthHeader(username, password) { @@ -9,7 +10,67 @@ function generateTokenAuthHeader(tokenInfo) { return `${tokenInfo.token_type} ${tokenInfo.access_token}`; } +function generateDigestAuthHeader(options, digest) { + function md5(data) { + return crypto + .createHash("md5") + .update(data) + .digest("hex"); + } + + function ha1Compute(algorithm, user, realm, pass, nonce, cnonce) { + const ha1 = md5(`${user}:${realm}:${pass}`); + if (algorithm && algorithm.toLowerCase() === "md5-sess") { + return md5(`${ha1}:${nonce}:${cnonce}`); + } else { + return ha1; + } + } + + const url = options.url.replace("//", ""); + const uri = url.indexOf("/") == -1 ? "/" : url.slice(url.indexOf("/")); + + const method = options.method ? options.method.toUpperCase() : "GET"; + + const qop = /(^|,)\s*auth\s*($|,)/.test(digest.qop) ? "auth" : false; + const ncString = `00000000${digest.nc}`.slice(-8); + const cnonce = digest.cnonce; + const ha1 = ha1Compute(digest.algorithm, digest.username, digest.realm, digest.password, digest.nonce, digest.cnonce); + const ha2 = md5(`${method}:${uri}`); + + const digestResponse = qop + ? md5(`${ha1}:${digest.nonce}:${ncString}:${digest.cnonce}:${qop}:${ha2}`) + : md5(`${ha1}:${digest.nonce}:${ha2}`); + + const authValues = { + username: digest.username, + realm: digest.realm, + nonce: digest.nonce, + uri, + qop, + response: digestResponse, + nc: ncString, + cnonce: digest.cnonce, + algorithm: digest.algorithm, + opaque: digest.opaque + }; + + const authHeader = []; + for (var k in authValues) { + if (authValues[k]) { + if (k === "qop" || k === "nc" || k === "algorithm") { + authHeader.push(`${k}=${authValues[k]}`); + } else { + authHeader.push(`${k}="${authValues[k]}"`); + } + } + } + + return `Digest ${authHeader.join(", ")}`; +} + module.exports = { generateBasicAuthHeader, - generateTokenAuthHeader + generateTokenAuthHeader, + generateDigestAuthHeader }; diff --git a/source/factory.js b/source/factory.js index 55738242..f8e4bf3f 100644 --- a/source/factory.js +++ b/source/factory.js @@ -99,7 +99,7 @@ const stats = require("./interface/stat.js"); * console.log(contents); * }); */ -function createClient(remoteURL, { username, password, httpAgent, httpsAgent, token = null } = {}) { +function createClient(remoteURL, { username, password, httpAgent, httpsAgent, token = null, digest = false } = {}) { const baseOptions = { headers: {}, remotePath: urlTools.extractURLPath(remoteURL), @@ -108,7 +108,9 @@ function createClient(remoteURL, { username, password, httpAgent, httpsAgent, to httpsAgent }; // Configure auth - if (username) { + if (digest) { + baseOptions._digest = { username, password, nc: 0, algorithm: "md5", hasDigestAuth: false }; + } else if (username) { baseOptions.headers.Authorization = authTools.generateBasicAuthHeader(username, password); } else if (token && typeof token === "object") { baseOptions.headers.Authorization = authTools.generateTokenAuthHeader(token); diff --git a/source/fetch.js b/source/fetch.js new file mode 100644 index 00000000..a46f8829 --- /dev/null +++ b/source/fetch.js @@ -0,0 +1,90 @@ +const axios = require("axios"); +const { merge } = require("./merge.js"); +const { getPatcher } = require("./patcher.js"); +const { generateDigestAuthHeader } = require("./auth.js"); + +function makeNonce() { + const cnonceSize = 32; + const nonceRaw = "abcdef0123456789"; + + let uid = ""; + for (let i = 0; i < cnonceSize; ++i) { + uid += nonceRaw[Math.floor(Math.random() * nonceRaw.length)]; + } + return uid; +} + +function parseAuth(response, _digest) { + const authHeader = response.headers["www-authenticate"] || ""; + + if (authHeader.split(/\s/)[0].toLowerCase() !== "digest") { + return false; + } + + const re = /([a-z0-9_-]+)=(?:"([^"]+)"|([a-z0-9_-]+))/gi; + for (;;) { + var match = re.exec(authHeader); + if (!match) { + break; + } + _digest[match[1]] = match[2] || match[3]; + } + + _digest.nc++; + _digest.cnonce = makeNonce(); + + return true; +} + +function request(requestOptions) { + return getPatcher().patchInline("request", options => axios(options), requestOptions); +} + +function fetch(requestOptions) { + // Client not configured for digest authentication + if (!requestOptions._digest) { + return request(requestOptions); + } + + // Remove client's digest authentication object from request options + const _digest = requestOptions._digest; + delete requestOptions._digest; + + requestOptions.validateStatus = status => ((status >= 200 && status < 300) || status == 401); + + if (_digest.hasDigestAuth) { + requestOptions = merge(requestOptions, { + headers: { + Authorization: generateDigestAuthHeader(requestOptions, _digest) + } + }); + } + + return request(requestOptions).then(function(response) { + if (response.status == 401) { + _digest.hasDigestAuth = parseAuth(response, _digest); + + if (_digest.hasDigestAuth) { + requestOptions = merge(requestOptions, { + headers: { + Authorization: generateDigestAuthHeader(requestOptions, _digest) + } + }); + + return request(requestOptions).then(function(response2) { + if (response2.status == 401) { + _digest.hasDigestAuth = false; + } else { + _digest.nc++; + } + return response2; + }); + } + } else { + _digest.nc++; + } + return response; + }); +} + +module.exports = fetch; diff --git a/source/request.js b/source/request.js index e16d381a..e78ee514 100644 --- a/source/request.js +++ b/source/request.js @@ -1,6 +1,6 @@ const axios = require("axios"); +const fetch = require("./fetch.js"); const { merge } = require("./merge.js"); -const { getPatcher } = require("./patcher.js"); const SEP_PATH_POSIX = "__PATH_SEPARATOR_POSIX__"; const SEP_PATH_WINDOWS = "__PATH_SEPARATOR_WINDOWS__"; @@ -50,6 +50,9 @@ function prepareRequestOptions(requestOptions, methodOptions) { if (methodOptions.maxContentLength) { requestOptions.maxContentLength = methodOptions.maxContentLength; } + if (methodOptions._digest) { + requestOptions._digest = methodOptions._digest; + } } /** @@ -71,7 +74,7 @@ function prepareRequestOptions(requestOptions, methodOptions) { * @returns {Promise.} A promise that resolves with a response object */ function request(requestOptions) { - return getPatcher().patchInline("request", options => axios(options), requestOptions); + return fetch(requestOptions); } module.exports = { From 077c484eaf6af6aec68c5832ab0d1d56ad6294b5 Mon Sep 17 00:00:00 2001 From: J Bruni Date: Tue, 2 Apr 2019 10:35:51 -0300 Subject: [PATCH 2/4] Set validateStatus request option at better place --- source/fetch.js | 4 ++-- source/request.js | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/source/fetch.js b/source/fetch.js index a46f8829..e44cf545 100644 --- a/source/fetch.js +++ b/source/fetch.js @@ -50,8 +50,7 @@ function fetch(requestOptions) { const _digest = requestOptions._digest; delete requestOptions._digest; - requestOptions.validateStatus = status => ((status >= 200 && status < 300) || status == 401); - + // If client is already using digest authentication, include the digest authorization header if (_digest.hasDigestAuth) { requestOptions = merge(requestOptions, { headers: { @@ -60,6 +59,7 @@ function fetch(requestOptions) { }); } + // Perform the request and handle digest authentication return request(requestOptions).then(function(response) { if (response.status == 401) { _digest.hasDigestAuth = parseAuth(response, _digest); diff --git a/source/request.js b/source/request.js index e78ee514..a3337a93 100644 --- a/source/request.js +++ b/source/request.js @@ -52,6 +52,7 @@ function prepareRequestOptions(requestOptions, methodOptions) { } if (methodOptions._digest) { requestOptions._digest = methodOptions._digest; + requestOptions.validateStatus = status => ((status >= 200 && status < 300) || status == 401); } } From 86069e7ba374cc2d36b182ca3adeaede3c98c25c Mon Sep 17 00:00:00 2001 From: J Bruni Date: Tue, 2 Apr 2019 10:36:03 -0300 Subject: [PATCH 3/4] Move crypto functions to its own file --- source/auth.js | 18 +----------------- source/crypto.js | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 17 deletions(-) create mode 100644 source/crypto.js diff --git a/source/auth.js b/source/auth.js index 5d406b81..5186942c 100644 --- a/source/auth.js +++ b/source/auth.js @@ -1,5 +1,5 @@ -const crypto = require("crypto"); const { toBase64 } = require("./encode.js"); +const { md5, ha1Compute } = require("./crypto.js"); function generateBasicAuthHeader(username, password) { const encoded = toBase64(`${username}:${password}`); @@ -11,22 +11,6 @@ function generateTokenAuthHeader(tokenInfo) { } function generateDigestAuthHeader(options, digest) { - function md5(data) { - return crypto - .createHash("md5") - .update(data) - .digest("hex"); - } - - function ha1Compute(algorithm, user, realm, pass, nonce, cnonce) { - const ha1 = md5(`${user}:${realm}:${pass}`); - if (algorithm && algorithm.toLowerCase() === "md5-sess") { - return md5(`${ha1}:${nonce}:${cnonce}`); - } else { - return ha1; - } - } - const url = options.url.replace("//", ""); const uri = url.indexOf("/") == -1 ? "/" : url.slice(url.indexOf("/")); diff --git a/source/crypto.js b/source/crypto.js new file mode 100644 index 00000000..04803244 --- /dev/null +++ b/source/crypto.js @@ -0,0 +1,19 @@ +const { createHash } = require("crypto"); + +function md5(data) { + return createHash("md5").update(data).digest("hex"); +} + +function ha1Compute(algorithm, user, realm, pass, nonce, cnonce) { + const ha1 = md5(`${user}:${realm}:${pass}`); + if (algorithm && algorithm.toLowerCase() === "md5-sess") { + return md5(`${ha1}:${nonce}:${cnonce}`); + } else { + return ha1; + } +} + +module.exports = { + md5, + ha1Compute +}; From b3b16542bf61a59ce7f7cffa6e71e5a350acd450 Mon Sep 17 00:00:00 2001 From: Perry Mitchell Date: Sun, 7 Jul 2019 21:45:59 +0300 Subject: [PATCH 4/4] Add test for digest auth --- source/auth.js | 20 ++++++++++++-------- test/server/index.js | 15 +++++++++++---- test/specs/auth.spec.js | 31 ++++++++++++++++++++++++++++--- 3 files changed, 51 insertions(+), 15 deletions(-) diff --git a/source/auth.js b/source/auth.js index 5186942c..1aaf240c 100644 --- a/source/auth.js +++ b/source/auth.js @@ -6,22 +6,22 @@ function generateBasicAuthHeader(username, password) { return `Basic ${encoded}`; } -function generateTokenAuthHeader(tokenInfo) { - return `${tokenInfo.token_type} ${tokenInfo.access_token}`; -} - function generateDigestAuthHeader(options, digest) { const url = options.url.replace("//", ""); const uri = url.indexOf("/") == -1 ? "/" : url.slice(url.indexOf("/")); - const method = options.method ? options.method.toUpperCase() : "GET"; - const qop = /(^|,)\s*auth\s*($|,)/.test(digest.qop) ? "auth" : false; const ncString = `00000000${digest.nc}`.slice(-8); const cnonce = digest.cnonce; - const ha1 = ha1Compute(digest.algorithm, digest.username, digest.realm, digest.password, digest.nonce, digest.cnonce); + const ha1 = ha1Compute( + digest.algorithm, + digest.username, + digest.realm, + digest.password, + digest.nonce, + digest.cnonce + ); const ha2 = md5(`${method}:${uri}`); - const digestResponse = qop ? md5(`${ha1}:${digest.nonce}:${ncString}:${digest.cnonce}:${qop}:${ha2}`) : md5(`${ha1}:${digest.nonce}:${ha2}`); @@ -53,6 +53,10 @@ function generateDigestAuthHeader(options, digest) { return `Digest ${authHeader.join(", ")}`; } +function generateTokenAuthHeader(tokenInfo) { + return `${tokenInfo.token_type} ${tokenInfo.access_token}`; +} + module.exports = { generateBasicAuthHeader, generateTokenAuthHeader, diff --git a/test/server/index.js b/test/server/index.js index 627c3420..1b0956d2 100644 --- a/test/server/index.js +++ b/test/server/index.js @@ -9,10 +9,17 @@ function createServer(dir, authType) { } const userManager = new ws.SimpleUserManager(); const user = userManager.addUser("webdav-user", "pa$$w0rd!"); - const auth = - !authType || authType === "basic" - ? new ws.HTTPBasicAuthentication(userManager) - : new ws.HTTPDigestAuthentication(userManager, "test"); + let auth; + switch (authType) { + case "digest": + auth = new ws.HTTPDigestAuthentication(userManager, "test"); + break; + case "basic": + /* falls-through */ + default: + auth = new ws.HTTPBasicAuthentication(userManager); + break; + } const privilegeManager = new ws.SimplePathPrivilegeManager(); privilegeManager.setRights(user, "/", ["all"]); const server = new ws.WebDAVServer({ diff --git a/test/specs/auth.spec.js b/test/specs/auth.spec.js index 4db6f798..583c6f53 100644 --- a/test/specs/auth.spec.js +++ b/test/specs/auth.spec.js @@ -7,7 +7,8 @@ describe("Authentication", function() { afterEach(function() { nock.cleanAll(); }); - it("should go unauthenticated if no credentials are passed", function() { + + it("should connect unauthenticated if no credentials are passed", function() { nock(DUMMYSERVER) .get("/file") .reply(200, function() { @@ -18,7 +19,7 @@ describe("Authentication", function() { return webdav.getFileContents("/file"); }); - it("should use HTTP Basic if user and password are provided", function() { + it("should connect using HTTP Basic if user and password are provided", function() { nock(DUMMYSERVER) .get("/file") .reply(200, function() { @@ -32,7 +33,7 @@ describe("Authentication", function() { return webdav.getFileContents("/file"); }); - it("should use Bearer if an object is provided", function() { + it("should connect using a Bearer token if an object is provided", function() { nock(DUMMYSERVER) .get("/file") .reply(200, function() { @@ -47,4 +48,28 @@ describe("Authentication", function() { }); return webdav.getFileContents("/file"); }); + + describe("using Digest-enabled server", function() { + beforeEach(function() { + this.client = createWebDAVClient("http://localhost:9988/webdav/server", { + username: createWebDAVServer.test.username, + password: createWebDAVServer.test.password, + digest: true + }); + clean(); + this.server = createWebDAVServer("digest"); + return this.server.start(); + }); + + afterEach(function() { + return this.server.stop(); + }); + + it("should connect using Digest authentication if digest enabled", function() { + return this.client.getDirectoryContents("/").then(function(contents) { + expect(contents).to.be.an("array"); + expect(contents[0]).to.be.an("object"); + }); + }); + }); });