diff --git a/lib/auth.js b/lib/auth.js index abe627453..79f1ce3b4 100644 --- a/lib/auth.js +++ b/lib/auth.js @@ -8,8 +8,9 @@ var md5 = helpers.md5 , toBase64 = helpers.toBase64 -function Auth () { +function Auth (request) { // define all public properties here + this.request = request this.hasAuth = false this.sentAuth = false this.bearerToken = null @@ -108,11 +109,28 @@ Auth.prototype.digest = function (method, path, authHeader) { return authHeader } -Auth.prototype.response = function (method, path, headers) { +Auth.prototype.onRequest = function (user, pass, sendImmediately, bearer) { var self = this + , request = self.request + + var authHeader + if (bearer !== undefined) { + authHeader = self.bearer(bearer, sendImmediately) + } else { + authHeader = self.basic(user, pass, sendImmediately) + } + if (authHeader) { + request.setHeader('authorization', authHeader) + } +} + +Auth.prototype.onResponse = function (response) { + var self = this + , request = self.request + if (!self.hasAuth || self.sentAuth) { return null } - var c = caseless(headers) + var c = caseless(response.headers) var authHeader = c.get('www-authenticate') var authVerb = authHeader && authHeader.split(' ')[0].toLowerCase() @@ -126,7 +144,7 @@ Auth.prototype.response = function (method, path, headers) { return self.bearer(self.bearerToken, true) case 'digest': - return self.digest(method, path, authHeader) + return self.digest(request.method, request.path, authHeader) } } diff --git a/lib/multipart.js b/lib/multipart.js new file mode 100644 index 000000000..cddd8d392 --- /dev/null +++ b/lib/multipart.js @@ -0,0 +1,104 @@ +'use strict' + +var uuid = require('node-uuid') + , CombinedStream = require('combined-stream') + , isstream = require('isstream') + + +function Multipart (request) { + this.request = request + this.boundary = uuid() + this.chunked = false + this.body = null +} + +Multipart.prototype.isChunked = function (options) { + var self = this + , chunked = false + , parts = options.data || options + + if (!parts.forEach) { + throw new Error('Argument error, options.multipart.') + } + + if (self.request.getHeader('transfer-encoding') === 'chunked') { + chunked = true + } + + if (options.chunked !== undefined) { + chunked = options.chunked + } + + if (!chunked) { + parts.forEach(function (part) { + if(typeof part.body === 'undefined') { + throw new Error('Body attribute missing in multipart.') + } + if (isstream(part.body)) { + chunked = true + } + }) + } + + return chunked +} + +Multipart.prototype.setHeaders = function (chunked) { + var self = this + + if (chunked && !self.request.hasHeader('transfer-encoding')) { + self.request.setHeader('transfer-encoding', 'chunked') + } + + var header = self.request.getHeader('content-type') + var contentType = (!header || header.indexOf('multipart') === -1) + ? 'multipart/related' + : header.split(';')[0] + + self.request.setHeader('content-type', contentType + '; boundary=' + self.boundary) +} + +Multipart.prototype.build = function (parts, chunked) { + var self = this + var body = chunked ? new CombinedStream() : [] + + function add (part) { + return chunked ? body.append(part) : body.push(new Buffer(part)) + } + + if (self.request.preambleCRLF) { + add('\r\n') + } + + parts.forEach(function (part) { + var preamble = '--' + self.boundary + '\r\n' + Object.keys(part).forEach(function (key) { + if (key === 'body') { return } + preamble += key + ': ' + part[key] + '\r\n' + }) + preamble += '\r\n' + add(preamble) + add(part.body) + add('\r\n') + }) + add('--' + self.boundary + '--') + + if (self.request.postambleCRLF) { + add('\r\n') + } + + return body +} + +Multipart.prototype.onRequest = function (options) { + var self = this + + var chunked = self.isChunked(options) + , parts = options.data || options + + self.setHeaders(chunked) + self.chunked = chunked + self.body = self.build(parts, chunked) +} + +exports.Multipart = Multipart diff --git a/lib/oauth.js b/lib/oauth.js index 3224601cc..e44263a00 100644 --- a/lib/oauth.js +++ b/lib/oauth.js @@ -7,7 +7,11 @@ var querystring = require('querystring') , oauth = require('oauth-sign') -exports.buildParams = function (_oauth, uri, method, query, form, qsLib) { +function OAuth (request) { + this.request = request +} + +OAuth.prototype.buildParams = function (_oauth, uri, method, query, form, qsLib) { var oa = {} for (var i in _oauth) { oa['oauth_' + i] = _oauth[i] @@ -54,7 +58,7 @@ exports.buildParams = function (_oauth, uri, method, query, form, qsLib) { return oa } -exports.concatParams = function (oa, sep, wrap) { +OAuth.prototype.concatParams = function (oa, sep, wrap) { wrap = wrap || '' var params = Object.keys(oa).filter(function (i) { @@ -71,13 +75,15 @@ exports.concatParams = function (oa, sep, wrap) { }).join(sep) } -exports.oauth = function (args) { - var uri = args.uri || {} - , method = args.method || '' - , headers = caseless(args.headers) - , body = args.body || '' - , _oauth = args.oauth || {} - , qsLib = args.qsLib || qs +OAuth.prototype.onRequest = function (_oauth) { + var self = this + , request = self.request + + var uri = request.uri || {} + , method = request.method || '' + , headers = caseless(request.headers) + , body = request.body || '' + , qsLib = request.qsLib || qs var form , query @@ -99,23 +105,22 @@ exports.oauth = function (args) { var oa = this.buildParams(_oauth, uri, method, query, form, qsLib) - var data switch (transport) { case 'header': - data = 'OAuth ' + this.concatParams(oa, ',', '"') + request.setHeader('Authorization', 'OAuth ' + this.concatParams(oa, ',', '"')) break case 'query': - data = (query ? '&' : '?') + this.concatParams(oa, '&') + request.path = (query ? '&' : '?') + this.concatParams(oa, '&') break case 'body': - data = (form ? form + '&' : '') + this.concatParams(oa, '&') + request.body = (form ? form + '&' : '') + this.concatParams(oa, '&') break default: throw new Error('oauth: transport_method invalid') } - - return {oauth:data, transport:transport} } + +exports.OAuth = OAuth diff --git a/lib/redirect.js b/lib/redirect.js new file mode 100644 index 000000000..2d9a9d518 --- /dev/null +++ b/lib/redirect.js @@ -0,0 +1,146 @@ +'use strict' + +var url = require('url') +var isUrl = /^https?:/ + +function Redirect (request) { + this.request = request + this.followRedirect = true + this.followRedirects = true + this.followAllRedirects = false + this.allowRedirect = function () {return true} + this.maxRedirects = 10 + this.redirects = [] + this.redirectsFollowed = 0 +} + +Redirect.prototype.onRequest = function () { + var self = this + , request = self.request + + if (request.maxRedirects !== undefined) { + self.maxRedirects = request.maxRedirects + } + if (typeof request.followRedirect === 'function') { + self.allowRedirect = request.followRedirect + } + if (request.followRedirect !== undefined) { + self.followRedirects = !!request.followRedirect + } + if (request.followAllRedirects !== undefined) { + self.followAllRedirects = request.followAllRedirects + } + if (self.followRedirects || self.followAllRedirects) { + self.redirects = self.redirects || [] + } +} + +Redirect.prototype.redirectTo = function (response) { + var self = this + , request = self.request + + var redirectTo = null + if (response.statusCode >= 300 && response.statusCode < 400 && response.caseless.has('location')) { + var location = response.caseless.get('location') + // debug('redirect', location) + + if (self.followAllRedirects) { + redirectTo = location + } else if (self.followRedirects) { + switch (request.method) { + case 'PATCH': + case 'PUT': + case 'POST': + case 'DELETE': + // Do not follow redirects + break + default: + redirectTo = location + break + } + } + } else if (response.statusCode === 401) { + var authHeader = request._auth.onResponse(response) + if (authHeader) { + request.setHeader('authorization', authHeader) + redirectTo = request.uri + } + } + return redirectTo +} + +Redirect.prototype.onResponse = function (response) { + var self = this + , request = self.request + + var redirectTo = self.redirectTo(response) + if (!redirectTo || !self.allowRedirect.call(request, response)) { + return false + } + + + // debug('redirect to', redirectTo) + + // ignore any potential response body. it cannot possibly be useful + // to us at this point. + if (request._paused) { + response.resume() + } + + if (self.redirectsFollowed >= self.maxRedirects) { + request.emit('error', new Error('Exceeded maxRedirects. Probably stuck in a redirect loop ' + request.uri.href)) + return false + } + self.redirectsFollowed += 1 + + if (!isUrl.test(redirectTo)) { + redirectTo = url.resolve(request.uri.href, redirectTo) + } + + var uriPrev = request.uri + request.uri = url.parse(redirectTo) + + // handle the case where we change protocol from https to http or vice versa + if (request.uri.protocol !== uriPrev.protocol) { + request._updateProtocol() + } + + self.redirects.push( + { statusCode : response.statusCode + , redirectUri: redirectTo + } + ) + if (self.followAllRedirects && response.statusCode !== 401 && response.statusCode !== 307) { + request.method = 'GET' + } + // request.method = 'GET' // Force all redirects to use GET || commented out fixes #215 + delete request.src + delete request.req + delete request.agent + delete request._started + if (response.statusCode !== 401 && response.statusCode !== 307) { + // Remove parameters from the previous response, unless this is the second request + // for a server that requires digest authentication. + delete request.body + delete request._form + if (request.headers) { + request.removeHeader('host') + request.removeHeader('content-type') + request.removeHeader('content-length') + if (request.uri.hostname !== request.originalHost.split(':')[0]) { + // Remove authorization if changing hostnames (but not if just + // changing ports or protocols). This matches the behavior of curl: + // https://github.com/bagder/curl/blob/6beb0eee/lib/http.c#L710 + request.removeHeader('authorization') + } + } + } + + request.emit('redirect') + + request.init() + + return true +} + +exports.Redirect = Redirect diff --git a/request.js b/request.js index f43c8c6b5..6f1305a41 100644 --- a/request.js +++ b/request.js @@ -13,7 +13,6 @@ var http = require('http') , hawk = require('hawk') , aws = require('aws-sign2') , httpSignature = require('http-signature') - , uuid = require('node-uuid') , mime = require('mime-types') , tunnel = require('tunnel-agent') , stringstream = require('stringstream') @@ -23,11 +22,11 @@ var http = require('http') , cookies = require('./lib/cookies') , copy = require('./lib/copy') , net = require('net') - , CombinedStream = require('combined-stream') - , isstream = require('isstream') , getProxyFromURI = require('./lib/getProxyFromURI') , Auth = require('./lib/auth').Auth - , oauth = require('./lib/oauth') + , OAuth = require('./lib/oauth').OAuth + , Multipart = require('./lib/multipart').Multipart + , Redirect = require('./lib/redirect').Redirect var safeStringify = helpers.safeStringify , md5 = helpers.md5 @@ -38,7 +37,6 @@ var safeStringify = helpers.safeStringify var globalPool = {} - , isUrl = /^https?:/ var defaultProxyHeaderWhiteList = [ 'accept', @@ -261,6 +259,10 @@ function Request (options) { if (options.method) { self.explicitMethod = true } + self._auth = new Auth(self) + self._oauth = new OAuth(self) + self._multipart = new Multipart(self) + self._redirect = new Redirect(self) self.init(options) } @@ -413,16 +415,7 @@ Request.prototype.init = function (options) { return self.emit('error', new Error(message)) } - self._redirectsFollowed = self._redirectsFollowed || 0 - self.maxRedirects = (self.maxRedirects !== undefined) ? self.maxRedirects : 10 - self.allowRedirect = (typeof self.followRedirect === 'function') ? self.followRedirect : function(response) { - return true - } - self.followRedirects = (self.followRedirect !== undefined) ? !!self.followRedirect : true - self.followAllRedirects = (self.followAllRedirects !== undefined) ? self.followAllRedirects : false - if (self.followRedirects || self.followAllRedirects) { - self.redirects = self.redirects || [] - } + self._redirect.onRequest() self.setHost = false if (!self.hasHeader('host')) { @@ -495,8 +488,6 @@ Request.prototype.init = function (options) { } // Auth must happen last in case signing is dependent on other headers - self._auth = new Auth() - if (options.oauth) { self.oauth(options.oauth) } @@ -554,7 +545,6 @@ Request.prototype.init = function (options) { self.json(options.json) } if (options.multipart) { - self.boundary = uuid() self.multipart(options.multipart) } @@ -654,8 +644,8 @@ Request.prototype.init = function (options) { if (self._form) { self._form.pipe(self) } - if (self._multipart) { - self._multipart.pipe(self) + if (self._multipart && self._multipart.chunked) { + self._multipart.body.pipe(self) } if (self.body) { if (Array.isArray(self.body)) { @@ -1036,98 +1026,9 @@ Request.prototype.onRequestResponse = function (response) { } } - var redirectTo = null - if (response.statusCode >= 300 && response.statusCode < 400 && response.caseless.has('location')) { - var location = response.caseless.get('location') - debug('redirect', location) - - if (self.followAllRedirects) { - redirectTo = location - } else if (self.followRedirects) { - switch (self.method) { - case 'PATCH': - case 'PUT': - case 'POST': - case 'DELETE': - // Do not follow redirects - break - default: - redirectTo = location - break - } - } - } else if (response.statusCode === 401) { - var authHeader = self._auth.response(self.method, self.uri.path, response.headers) - if (authHeader) { - self.setHeader('authorization', authHeader) - redirectTo = self.uri - } - } - - if (redirectTo && self.allowRedirect.call(self, response)) { - debug('redirect to', redirectTo) - - // ignore any potential response body. it cannot possibly be useful - // to us at this point. - if (self._paused) { - response.resume() - } - - if (self._redirectsFollowed >= self.maxRedirects) { - self.emit('error', new Error('Exceeded maxRedirects. Probably stuck in a redirect loop ' + self.uri.href)) - return - } - self._redirectsFollowed += 1 - - if (!isUrl.test(redirectTo)) { - redirectTo = url.resolve(self.uri.href, redirectTo) - } - - var uriPrev = self.uri - self.uri = url.parse(redirectTo) - - // handle the case where we change protocol from https to http or vice versa - if (self.uri.protocol !== uriPrev.protocol) { - self._updateProtocol() - } - - self.redirects.push( - { statusCode : response.statusCode - , redirectUri: redirectTo - } - ) - if (self.followAllRedirects && response.statusCode !== 401 && response.statusCode !== 307) { - self.method = 'GET' - } - // self.method = 'GET' // Force all redirects to use GET || commented out fixes #215 - delete self.src - delete self.req - delete self.agent - delete self._started - if (response.statusCode !== 401 && response.statusCode !== 307) { - // Remove parameters from the previous response, unless this is the second request - // for a server that requires digest authentication. - delete self.body - delete self._form - if (self.headers) { - self.removeHeader('host') - self.removeHeader('content-type') - self.removeHeader('content-length') - if (self.uri.hostname !== self.originalHost.split(':')[0]) { - // Remove authorization if changing hostnames (but not if just - // changing ports or protocols). This matches the behavior of curl: - // https://github.com/bagder/curl/blob/6beb0eee/lib/http.c#L710 - self.removeHeader('authorization') - } - } - } - - self.emit('redirect') - - self.init() + if (self._redirect.onResponse(response)) { return // Ignore the rest of the response } else { - self._redirectsFollowed = self._redirectsFollowed || 0 // Be a good stream and emit end when the response is finished. // Hack to emit end on close because of a core bug that never fires end response.on('close', function () { @@ -1351,69 +1252,12 @@ Request.prototype.form = function (form) { Request.prototype.multipart = function (multipart) { var self = this - var chunked = false - var _multipart = multipart.data || multipart - - if (!_multipart.forEach) { - throw new Error('Argument error, options.multipart.') - } - - if (self.getHeader('transfer-encoding') === 'chunked') { - chunked = true - } - if (multipart.chunked !== undefined) { - chunked = multipart.chunked - } - if (!chunked) { - _multipart.forEach(function (part) { - if(typeof part.body === 'undefined') { - throw new Error('Body attribute missing in multipart.') - } - if (isstream(part.body)) { - chunked = true - } - }) - } - - if (chunked && !self.hasHeader('transfer-encoding')) { - self.setHeader('transfer-encoding', 'chunked') - } - - var headerName = self.hasHeader('content-type') - if (!headerName || self.headers[headerName].indexOf('multipart') === -1) { - self.setHeader('content-type', 'multipart/related; boundary=' + self.boundary) - } else { - self.setHeader(headerName, self.headers[headerName].split(';')[0] + '; boundary=' + self.boundary) - } - - var parts = chunked ? new CombinedStream() : [] - function add (part) { - return chunked ? parts.append(part) : parts.push(new Buffer(part)) - } - - if (self.preambleCRLF) { - add('\r\n') - } - - _multipart.forEach(function (part) { - var body = part.body - var preamble = '--' + self.boundary + '\r\n' - Object.keys(part).forEach(function (key) { - if (key === 'body') { return } - preamble += key + ': ' + part[key] + '\r\n' - }) - preamble += '\r\n' - add(preamble) - add(body) - add('\r\n') - }) - add('--' + self.boundary + '--') + self._multipart.onRequest(multipart) - if (self.postambleCRLF) { - add('\r\n') + if (!self._multipart.chunked) { + self.body = self._multipart.body } - self[chunked ? '_multipart' : 'body'] = parts return self } Request.prototype.json = function (val) { @@ -1466,24 +1310,14 @@ Request.prototype.getHeader = function (name, headers) { }) return result } -var getHeader = Request.prototype.getHeader Request.prototype.auth = function (user, pass, sendImmediately, bearer) { var self = this - var authHeader - if (bearer !== undefined) { - authHeader = self._auth.bearer(bearer, sendImmediately) - } else { - authHeader = self._auth.basic(user, pass, sendImmediately) - } - if (authHeader) { - self.setHeader('authorization', authHeader) - } + self._auth.onRequest(user, pass, sendImmediately, bearer) return self } - Request.prototype.aws = function (opts, now) { var self = this @@ -1521,7 +1355,7 @@ Request.prototype.httpSignature = function (opts) { var self = this httpSignature.signRequest({ getHeader: function(header) { - return getHeader(header, self.headers) + return self.getHeader(header, self.headers) }, setHeader: function(header, value) { self.setHeader(header, value) @@ -1533,33 +1367,14 @@ Request.prototype.httpSignature = function (opts) { return self } - Request.prototype.hawk = function (opts) { var self = this self.setHeader('Authorization', hawk.client.header(self.uri, self.method, opts).field) } - Request.prototype.oauth = function (_oauth) { var self = this - var result = oauth.oauth({ - uri: self.uri, - method: self.method, - headers: self.headers, - body: self.body, - oauth: _oauth, - qsLib: self.qsLib - }) - - if (result.transport === 'header') { - self.setHeader('Authorization', result.oauth) - } - else if (result.transport === 'query') { - self.path += result.oauth - } - else if (result.transport === 'body') { - self.body = result.oauth - } + self._oauth.onRequest(_oauth) return self } @@ -1568,7 +1383,7 @@ Request.prototype.jar = function (jar) { var self = this var cookies - if (self._redirectsFollowed === 0) { + if (self._redirect.redirectsFollowed === 0) { self.originalCookieHeader = self.getHeader('cookie') } diff --git a/tests/test-basic-auth.js b/tests/test-basic-auth.js index 62b5f8be4..1ad452352 100644 --- a/tests/test-basic-auth.js +++ b/tests/test-basic-auth.js @@ -16,13 +16,13 @@ tape('setup', function(t) { var ok if (req.headers.authorization) { - if (req.headers.authorization === 'Basic ' + new Buffer('test:testing2').toString('base64')) { + if (req.headers.authorization === 'Basic ' + new Buffer('user:pass').toString('base64')) { ok = true - } else if ( req.headers.authorization === 'Basic ' + new Buffer('test:').toString('base64')) { + } else if ( req.headers.authorization === 'Basic ' + new Buffer('user:').toString('base64')) { ok = true - } else if ( req.headers.authorization === 'Basic ' + new Buffer(':apassword').toString('base64')) { + } else if ( req.headers.authorization === 'Basic ' + new Buffer(':pass').toString('base64')) { ok = true - } else if ( req.headers.authorization === 'Basic ' + new Buffer('justauser').toString('base64')) { + } else if ( req.headers.authorization === 'Basic ' + new Buffer('user').toString('base64')) { ok = true } else { // Bad auth header, don't send back WWW-Authenticate header @@ -35,7 +35,7 @@ tape('setup', function(t) { } if (req.url === '/post/') { - var expectedContent = 'data_key=data_value' + var expectedContent = 'key=value' req.on('data', function(data) { assert.equal(data, expectedContent) }) @@ -55,77 +55,82 @@ tape('setup', function(t) { }) }) -tape('', function(t) { - request({ +tape('sendImmediately - false', function(t) { + var r = request({ 'method': 'GET', 'uri': 'http://localhost:6767/test/', 'auth': { - 'user': 'test', - 'pass': 'testing2', + 'user': 'user', + 'pass': 'pass', 'sendImmediately': false } }, function(error, res, body) { + t.equal(r._auth.user, 'user') t.equal(res.statusCode, 200) t.equal(numBasicRequests, 2) t.end() }) }) -tape('', function(t) { +tape('sendImmediately - true', function(t) { // If we don't set sendImmediately = false, request will send basic auth - request({ + var r = request({ 'method': 'GET', 'uri': 'http://localhost:6767/test2/', 'auth': { - 'user': 'test', - 'pass': 'testing2' + 'user': 'user', + 'pass': 'pass' } }, function(error, res, body) { + t.equal(r._auth.user, 'user') t.equal(res.statusCode, 200) t.equal(numBasicRequests, 3) t.end() }) }) -tape('', function(t) { - request({ +tape('credentials in url', function(t) { + var r = request({ 'method': 'GET', - 'uri': 'http://test:testing2@localhost:6767/test2/' + 'uri': 'http://user:pass@localhost:6767/test2/' }, function(error, res, body) { + t.equal(r._auth.user, 'user') t.equal(res.statusCode, 200) t.equal(numBasicRequests, 4) t.end() }) }) -tape('', function(t) { - request({ +tape('POST request', function(t) { + var r = request({ 'method': 'POST', - 'form': { 'data_key': 'data_value' }, + 'form': { 'key': 'value' }, 'uri': 'http://localhost:6767/post/', 'auth': { - 'user': 'test', - 'pass': 'testing2', + 'user': 'user', + 'pass': 'pass', 'sendImmediately': false } }, function(error, res, body) { + t.equal(r._auth.user, 'user') t.equal(res.statusCode, 200) t.equal(numBasicRequests, 6) t.end() }) }) -tape('', function(t) { +tape('user - empty string', function(t) { t.doesNotThrow( function() { - request({ + var r = request({ 'method': 'GET', 'uri': 'http://localhost:6767/allow_empty_user/', 'auth': { 'user': '', - 'pass': 'apassword', + 'pass': 'pass', 'sendImmediately': false } }, function(error, res, body ) { + t.equal(r._auth.user, '') t.equal(res.statusCode, 200) t.equal(numBasicRequests, 8) t.end() @@ -133,17 +138,18 @@ tape('', function(t) { }) }) -tape('', function(t) { +tape('pass - undefined', function(t) { t.doesNotThrow( function() { - request({ + var r = request({ 'method': 'GET', 'uri': 'http://localhost:6767/allow_undefined_password/', 'auth': { - 'user': 'justauser', + 'user': 'user', 'pass': undefined, 'sendImmediately': false } }, function(error, res, body ) { + t.equal(r._auth.user, 'user') t.equal(res.statusCode, 200) t.equal(numBasicRequests, 10) t.end() @@ -151,26 +157,28 @@ tape('', function(t) { }) }) -tape('', function(t) { - request +tape('auth method', function(t) { + var r = request .get('http://localhost:6767/test/') - .auth('test','',false) + .auth('user','',false) .on('response', function (res) { + t.equal(r._auth.user, 'user') t.equal(res.statusCode, 200) t.equal(numBasicRequests, 12) t.end() }) }) -tape('', function(t) { - request.get('http://localhost:6767/test/', +tape('get method', function(t) { + var r = request.get('http://localhost:6767/test/', { auth: { - user: 'test', + user: 'user', pass: '', sendImmediately: false } }, function (err, res) { + t.equal(r._auth.user, 'user') t.equal(err, null) t.equal(res.statusCode, 200) t.equal(numBasicRequests, 14)