Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Digest auth (final) #155

Merged
merged 7 commits into from
Jul 7, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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");
});
});
});
});