diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4d227a8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +.project \ No newline at end of file diff --git a/Gruntfile.js b/Gruntfile.js new file mode 100644 index 0000000..ba137c0 --- /dev/null +++ b/Gruntfile.js @@ -0,0 +1,53 @@ +'use strict'; + +module.exports = function(grunt) { + + // Project configuration. + grunt.initConfig({ + nodeunit: { + files: ['test/**/*_test.js'] + }, + + jshint: { + options: { + jshintrc: '.jshintrc' + }, + gruntfile: { + src: 'Gruntfile.js' + }, + lib: { + src: ['lib/**/*.js'] + }, + test: { + src: ['test/**/*.js'] + }, + }, + watch: { + gruntfile: { + files: '<%= jshint.gruntfile.src %>', + tasks: ['jshint:gruntfile'] + }, + lib: { + files: '<%= jshint.lib.src %>', + tasks: ['jshint:lib', 'nodeunit'] + }, + test: { + files: '<%= jshint.test.src %>', + tasks: ['jshint:test', 'nodeunit'] + }, + work: { + files: ['<%= jshint.lib.src %>', '<%= jshint.test.src %>', 'node_modules/**/*.js'], + tasks: ['nodeunit'] + } + }, + }); + + // These plugins provide necessary tasks. + grunt.loadNpmTasks('grunt-contrib-nodeunit'); + grunt.loadNpmTasks('grunt-contrib-jshint'); + grunt.loadNpmTasks('grunt-contrib-watch'); + + // Default task. + grunt.registerTask('default', ['jshint', 'nodeunit' ]); + +}; diff --git a/http-digest-client.js b/http-digest-client.js deleted file mode 100644 index 12d0d16..0000000 --- a/http-digest-client.js +++ /dev/null @@ -1,146 +0,0 @@ -// -// # Digest Client -// -// Use together with HTTP Client to perform requests to servers protected -// by digest authentication. -// - -var HTTPDigest = function () { - var crypto = require('crypto'); - var http = require('http'); - - var HTTPDigest = function (username, password, https) { - this.nc = 0; - this.username = username; - this.password = password; - if(https === true) { - http = require('https'); - } - }; - - // - // ## Make request - // - // Wraps the http.request function to apply digest authorization. - // - HTTPDigest.prototype.request = function (options, callback) { - var self = this; - http.request(options, function (res) { - self._handleResponse(options, res, callback); - }).end(); - }; - - // - // ## Handle authentication - // - // Parse authentication headers and set response. - // - HTTPDigest.prototype._handleResponse = function handleResponse(options, res, callback) { - var challenge = this._parseChallenge(res.headers['www-authenticate']); - var ha1 = crypto.createHash('md5'); - ha1.update([this.username, challenge.realm, this.password].join(':')); - var ha2 = crypto.createHash('md5'); - ha2.update([options.method, options.path].join(':')); - - // Generate cnonce - var cnonce = false; - var nc = false; - if (typeof challenge.qop === 'string') { - var cnonceHash = crypto.createHash('md5'); - cnonceHash.update(Math.random().toString(36)); - cnonce = cnonceHash.digest('hex').substr(0, 8); - nc = this.updateNC(); - } - - // Generate response hash - var response = crypto.createHash('md5'); - var responseParams = [ - ha1.digest('hex'), - challenge.nonce - ]; - - if (cnonce) { - responseParams.push(nc); - responseParams.push(cnonce); - } - - responseParams.push(challenge.qop); - responseParams.push(ha2.digest('hex')); - response.update(responseParams.join(':')); - - // Setup response parameters - var authParams = { - username: this.username, - realm: challenge.realm, - nonce: challenge.nonce, - uri: options.path, - qop: challenge.qop, - response: response.digest('hex'), - opaque: challenge.opaque - }; - if (cnonce) { - authParams.nc = nc; - authParams.cnonce = cnonce; - } - - var headers = options.headers || {}; - headers.Authorization = this._compileParams(authParams); - options.headers = headers; - - http.request(options, function (res) { - callback(res); - }).end(); - }; - - // - // ## Parse challenge digest - // - HTTPDigest.prototype._parseChallenge = function parseChallenge(digest) { - var prefix = "Digest "; - var challenge = digest.substr(digest.indexOf(prefix) + prefix.length); - var parts = challenge.split(','); - var length = parts.length; - var params = {}; - for (var i = 0; i < length; i++) { - var part = parts[i].match(/^\s*?([a-zA-Z0-0]+)="(.*)"\s*?$/); - if (part.length > 2) { - params[part[1]] = part[2]; - } - } - - return params; - }; - - // - // ## Compose authorization header - // - HTTPDigest.prototype._compileParams = function compileParams(params) { - var parts = []; - for (var i in params) { - parts.push(i + '="' + params[i] + '"'); - } - return 'Digest ' + parts.join(','); - }; - - // - // ## Update and zero pad nc - // - HTTPDigest.prototype.updateNC = function updateNC() { - var max = 99999999; - this.nc++; - if (this.nc > max) { - this.nc = 1; - } - var padding = new Array(8).join('0') + ""; - var nc = this.nc + ""; - return padding.substr(0, 8 - nc.length) + nc; - }; - - // Return response handler - return HTTPDigest; -}(); - -module.exports = function createDigestClient(username, password, https) { - return new HTTPDigest(username, password, https); -}; - diff --git a/lib/http-digest-client.js b/lib/http-digest-client.js new file mode 100644 index 0000000..2768d60 --- /dev/null +++ b/lib/http-digest-client.js @@ -0,0 +1,153 @@ +// +// # Digest Client +// +// Use together with HTTP Client to perform requests to servers protected +// by digest authentication. +// +var url=require('url'); + +var HTTPDigest = function() { + var crypto = require('crypto'); + var request = require('request'); + + var HTTPDigest = function(username, password) { + this.nc = 0; + this.username = username; + this.password = password; + }; + + // + // ## Make request + // + // Wraps the http.request function to apply digest authorization. + // + HTTPDigest.prototype.request = function(options, callback) { + var self = this; + options.auth.sendImmediately=false; // ensure no basic auth + request(options, function(err, res, body) { + self._handleResponse(options, err, res, body, callback); + }).end(); + }; + + // + // ## Handle authentication + // + // Parse authentication headers and set response. + // + HTTPDigest.prototype._handleResponse = function handleResponse(options, err, res, body, callback) { + // First check if there is an error condition + if(err != null && typeof res=='undefined') + callback(err, res, body); + if(res.statusCode != "401") + callback(err, res, body); + // If not check if callback is required + else if(typeof res.headers['www-authenticate'] === 'undefined') { + callback(err, res, body); + } + else { + var path=url.parse(options.url).path; // get path from url + var challenge = this._parseChallenge(res.headers['www-authenticate']); + var ha1 = crypto.createHash('md5'); + ha1.update([ this.username, challenge.realm, this.password ].join(':')); + var ha2 = crypto.createHash('md5'); + ha2.update([ options.method, path ].join(':')); + + // Generate cnonce + var cnonce = false; + var nc = false; + if (typeof challenge.qop === 'string') { + var cnonceHash = crypto.createHash('md5'); + cnonceHash.update(Math.random().toString(36)); + cnonce = cnonceHash.digest('hex').substr(0, 8); + nc = this.updateNC(); + } + + // Generate response hash + var response = crypto.createHash('md5'); + var responseParams = [ ha1.digest('hex'), challenge.nonce ]; + + if (cnonce) { + responseParams.push(nc); + responseParams.push(cnonce); + } + + responseParams.push(challenge.qop); + responseParams.push(ha2.digest('hex')); + response.update(responseParams.join(':')); + + // Setup response parameters + var authParams = { + username : this.username, + realm : challenge.realm, + nonce : challenge.nonce, + uri : path, + qop : challenge.qop, + response : response.digest('hex'), + opaque : challenge.opaque + }; + if (cnonce) { + authParams.nc = nc; + authParams.cnonce = cnonce; + } + + var headers = options.headers || {}; + headers.Authorization = this._compileParams(authParams); + options.headers = headers; + + request(options, function(err, res, body) { + callback(err, res, body); + }); + } + }; + + // + // ## Parse challenge digest + // + HTTPDigest.prototype._parseChallenge = function parseChallenge(digest) { + var prefix = "Digest "; + var challenge = digest.substr(digest.indexOf(prefix) + prefix.length); + var parts = challenge.split(','); + var length = parts.length; + var params = {}; + for (var i = 0; i < length; i++) { + var part = parts[i].match(/^\s*?([a-zA-Z0-0]+)="(.*)"\s*?$/); + if (part.length > 2) { + params[part[1]] = part[2]; + } + } + + return params; + }; + + // + // ## Compose authorization header + // + HTTPDigest.prototype._compileParams = function compileParams(params) { + var parts = []; + for ( var i in params) { + parts.push(i + '="' + params[i] + '"'); + } + return 'Digest ' + parts.join(','); + }; + + // + // ## Update and zero pad nc + // + HTTPDigest.prototype.updateNC = function updateNC() { + var max = 99999999; + this.nc++; + if (this.nc > max) { + this.nc = 1; + } + var padding = new Array(8).join('0') + ""; + var nc = this.nc + ""; + return padding.substr(0, 8 - nc.length) + nc; + }; + + // Return response handler + return HTTPDigest; +}(); + +module.exports = function createDigestClient(username, password, https) { + return new HTTPDigest(username, password, https); +}; diff --git a/package.json b/package.json index 553fa33..976539c 100644 --- a/package.json +++ b/package.json @@ -1,21 +1,46 @@ { "name": "http-digest-client", - "version": "0.0.3", + "version": "0.0.4", "description": "Perform request agains digest authenticated servers.", - "main": "http-digest-client.js", + "author": { + "name": "Simon Ljungberg", + "email": "hi@iamsim.me" + }, + "contributors": [ + { + "name": "Paul Gration", + "email": "pmgration@gmail.com" + }, + { + "name": "Mark Paxton", + "email": "markpaxton3@gmail.com" + } + ], + "readme": "# HTTP Digest Client\n\nHacked together snippet for talking to HTTP servers that employ digest\nauthentication.\n\n## Disclaimer\n\nOnly tested against one server and spec is not followed fully. It works for me\nand for what I am doing.\n\n## Usage\n\n var digest = require('http-digest-client').createDigestClient('username', 'password');\n digest.request({\n host: 'hostname.com',\n path: '/path.json',\n port: 80,\n method: 'GET',\n headers: { \"User-Agent\": \"Simon Ljungberg\" } // Set any headers you want\n }, function (res) {\n res.on('data', function (data) {\n console.log(data.toString());\n });\n res.on('error', function (err) {\n console.log('oh noes');\n });\n });\n\nThe digest client will make one reques to the server, authentication response\nis calculated and then the request is made again. Hopefully you will then\nbe authorized.\n\n## Writing to `req`\n\nI haven't yet figured out a way to write data to the final `req` object.\nMainly because I haven't really needed it. Feel free to suggest solutions! :)\n\n# License\n\nSee LICENSE.\n\n", + "readmeFilename": "README.md", + "bugs": { + "url": "https://github.com/simme/node-http-digest-client/issues" + }, + "main": "lib/http-digest-client.js", + "engines": { + "node": ">= 0.8.0" + }, "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "grunt nodeunit" + }, + "devDependencies": { + "grunt": "0.4.x", + "grunt-cli": "^0.1.13", + "grunt-contrib-jshint": "0.10.x", + "grunt-contrib-nodeunit": "0.3.x", + "grunt-contrib-watch": "0.6.x" }, "repository": { "type": "git", - "url": "https://github.com/simme/node-http-digest-client" + "url": "https://github.com/MarkPaxton/node-http-digest-client" + }, + "dependencies": { + "request": "~2.34.0" }, - "author": "Simon Ljungberg ", - "contributors": [ - { - "name": "Paul Gration", - "email": "pmgration@gmail.com" - } - ] + "homepage": "https://github.com/MarkPaxton/node-http-digest-client" } - diff --git a/test/digest_test.js b/test/digest_test.js new file mode 100644 index 0000000..4b04828 --- /dev/null +++ b/test/digest_test.js @@ -0,0 +1,119 @@ +'use strict'; +var DigestClient = require('../lib/http-digest-client.js'); +/* + * ======== A Handy Little Nodeunit Reference ======== + * https://github.com/caolan/nodeunit + * + * Test methods: test.expect(numAssertions) + * test.done() + * Test assertions: + * test.ok(value, [message]) + * test.equal(actual, expected, [message]) + * test.notEqual(actual, expected, [message]) + * test.deepEqual(actual, expected, [message]) + * test.notDeepEqual(actual, expected, [message]) + * test.strictEqual(actual, expected, [message]) + * test.notStrictEqual(actual, expected, [message]) + * test.throws(block, [error], [message]) + * test.doesNotThrow(block, [error], [message]) + * test.ifError(value) + */ + + +exports.DigestTest = { + setUp : function(done) { + // setup here + done(); + }, + 'doing a digest auth works' : function(test) { + test.expect(1); + // tests here + var auth = { + user : 'yyyy', + pass : 'xxxx' + }, digest = DigestClient(auth.user, auth.pass, false), options = { + url : 'http://xxxx/rest/workspaces', + method : 'post', + auth : auth, + json : true, + body : { + workspace : { + name : 'DeleteMe' + (new Date()).getTime() + } + }, + headers : { + "Accept" : "application/json" + } + }; + digest.request(options, function(err, res, body) { + if (err !== null) + console.log("Can't access " + res.req.url + ": " + err); + if (res.statusCode >= 400) + console.log("(" + res.statusCode + ") Can't open " + res.req.url + "\r\n" + body); + test.ok(res.statusCode < 400, "HTTP Error Code Received: " + res.statusCode); + test.done(); + }); + }, + 'doing an invalid digest auth fails' : function(test) { + test.expect(1); + // tests here + var auth = { + user : 'aaaa', + pass : 'xxxx' + }, digest = DigestClient(auth.user, auth.pass, false), options = { + url : 'http://xxxxx/rest/workspaces', + method : 'post', + auth : auth, + json : true, + body : { + workspace : { + name : 'DeleteMe' + (new Date()).getTime() + } + }, + headers : { + "Accept" : "application/json" + } + }; + digest.request(options, function(err, res, body) { + if (err !== null) + console.log("Can't access " + res.req.url + ": " + err); + if (res.statusCode > 401) + console.log("(" + res.statusCode + ") Can't open " + res.req.url + "\r\n" + body); + test.equals(res.statusCode, 401, "HTTP Error Code Received: " + res.statusCode); + + test.done(); + }); + }, + 'error 404 fails gracefully' : function(test) { + test.expect(2); + // tests here + var testUrl = 'http://xxxx/rest/workspaces/' + (new Date()).getTime(), + auth = { + user : 'aaaa', + pass : 'xxxx' + }, digest = DigestClient(auth.user, auth.pass, false), options = { + url : testUrl, + method : 'get', + auth : auth, + json : true, + body : { + workspace : { + name : 'DeleteMe' + (new Date()).getTime() + } + }, + headers : { + "Accept" : "application/json" + } + }; + digest.request(options, function(err, res, body) { + test.equal(err, null, "Error is not null"); +/* if (err !== null) + console.log("Can't access location: " + err); + if (res.statusCode >= 400) + console.log("(" + res.statusCode + ") Can't open " + testUrl + "\r\n" + body);*/ + test.equals(res.statusCode, 404, "HTTP Error Code Received: " + res.statusCode); + test.done(); + }); + } + +};