Skip to content

Commit

Permalink
Merge pull request #155 from perry-mitchell/digest
Browse files Browse the repository at this point in the history
Digest auth (final)
  • Loading branch information
perry-mitchell authored Jul 7, 2019
2 parents 0a52108 + b3b1654 commit 6e0615f
Show file tree
Hide file tree
Showing 7 changed files with 208 additions and 12 deletions.
51 changes: 50 additions & 1 deletion source/auth.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,64 @@
const { toBase64 } = require("./encode.js");
const { md5, ha1Compute } = require("./crypto.js");

function generateBasicAuthHeader(username, password) {
const encoded = toBase64(`${username}:${password}`);
return `Basic ${encoded}`;
}

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 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(", ")}`;
}

function generateTokenAuthHeader(tokenInfo) {
return `${tokenInfo.token_type} ${tokenInfo.access_token}`;
}

module.exports = {
generateBasicAuthHeader,
generateTokenAuthHeader
generateTokenAuthHeader,
generateDigestAuthHeader
};
19 changes: 19 additions & 0 deletions source/crypto.js
Original file line number Diff line number Diff line change
@@ -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
};
6 changes: 4 additions & 2 deletions source/factory.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ function createClient(remoteURL, opts = {}) {
if (!opts || typeof opts !== "object") {
throw new Error("Options must be an object, if specified");
}
const { username, password, httpAgent, httpsAgent, token = null } = opts;
const { username, password, httpAgent, httpsAgent, token = null, digest = false } = opts;
const runtimeOptions = {
headers: {},
remotePath: urlTools.extractURLPath(remoteURL),
Expand All @@ -112,7 +112,9 @@ function createClient(remoteURL, opts = {}) {
httpsAgent
};
// Configure auth
if (username) {
if (digest) {
runtimeOptions._digest = { username, password, nc: 0, algorithm: "md5", hasDigestAuth: false };
} else if (username) {
runtimeOptions.headers.Authorization = authTools.generateBasicAuthHeader(username, password);
} else if (token && typeof token === "object") {
runtimeOptions.headers.Authorization = authTools.generateTokenAuthHeader(token);
Expand Down
90 changes: 90 additions & 0 deletions source/fetch.js
Original file line number Diff line number Diff line change
@@ -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;

// If client is already using digest authentication, include the digest authorization header
if (_digest.hasDigestAuth) {
requestOptions = merge(requestOptions, {
headers: {
Authorization: generateDigestAuthHeader(requestOptions, _digest)
}
});
}

// Perform the request and handle digest authentication
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;
8 changes: 6 additions & 2 deletions source/request.js
Original file line number Diff line number Diff line change
@@ -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__";
Expand Down Expand Up @@ -53,6 +53,10 @@ function prepareRequestOptions(requestOptions, methodOptions) {
if (methodOptions.onUploadProgress && typeof methodOptions.onUploadProgress === "function") {
requestOptions.onUploadProgress = methodOptions.onUploadProgress;
}
if (methodOptions._digest) {
requestOptions._digest = methodOptions._digest;
requestOptions.validateStatus = status => ((status >= 200 && status < 300) || status == 401);
}
}

/**
Expand All @@ -74,7 +78,7 @@ function prepareRequestOptions(requestOptions, methodOptions) {
* @returns {Promise.<Object>} A promise that resolves with a response object
*/
function request(requestOptions) {
return getPatcher().patchInline("request", options => axios(options), requestOptions);
return fetch(requestOptions);
}

module.exports = {
Expand Down
15 changes: 11 additions & 4 deletions test/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
31 changes: 28 additions & 3 deletions test/specs/auth.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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() {
Expand All @@ -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() {
Expand All @@ -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");
});
});
});
});

0 comments on commit 6e0615f

Please sign in to comment.