diff --git a/lib/auth/index.js b/lib/auth/index.js index 5339e6ad6..317d7d303 100755 --- a/lib/auth/index.js +++ b/lib/auth/index.js @@ -18,77 +18,84 @@ exports = module.exports = internals.Auth = function (server, options) { Utils.assert(this.constructor === internals.Auth, 'Auth must be instantiated using new'); Utils.assert(options, 'Invalid options'); Utils.assert(!!options.scheme ^ !!options.strategies, 'Auth options must include one of scheme or strategies but not both'); + Utils.assert(options.scheme || Object.keys(options.strategies).length, 'Number of authentication strategies must be greater than zero'); this.server = server; - this.options = Utils.clone(options); - this.strategies = {}; - if (options.scheme) { - this.options = { - 'strategies': { + // Move single strategy into default + + var settings = options; + if (!settings.strategies) { + settings = { + strategies: { 'default': options } }; } - this.setStrategies(this.options.strategies); + // Load strategies + + this.strategies = {}; + for (var name in settings.strategies) { + if (settings.strategies.hasOwnProperty(name)) { + var strategy = settings.strategies[name]; + + Utils.assert(strategy.scheme, name + ' is missing a scheme'); + Utils.assert(['oz', 'basic', 'hawk'].indexOf(strategy.scheme) !== -1 || strategy.scheme.indexOf('ext:') === 0, name + ' has an unknown scheme: ' + strategy.scheme); + Utils.assert(strategy.scheme.indexOf('ext:') !== 0 || strategy.implementation, name + ' has extension scheme missing implementation'); + Utils.assert(!strategy.implementation || (typeof strategy.implementation === 'object' && typeof strategy.implementation.authenticate === 'function'), name + ' has invalid extension scheme implementation'); + + switch (strategy.scheme) { + case 'oz': this.strategies[name] = new Oz(this.server, strategy); break; + case 'hawk': this.strategies[name] = new Hawk(this.server, strategy); break; + case 'basic': this.strategies[name] = new Basic(this.server, strategy); break; + default: this.strategies[name] = strategy.implementation; break; + } + } + } Log.event(['info', 'config', 'auth'], server.settings.nickname + ': Authentication enabled'); + return this; }; -internals.Auth.prototype.setStrategies = function (strategies) { - - Utils.assert(Object.keys(strategies).length > 0, 'Number of Authentication Strategies must be greater than zero'); +internals.Auth.prototype.authenticate = function (request, next) { - for (var strategy in strategies) { - if (strategies.hasOwnProperty(strategy)) { - var strat = strategies[strategy]; - Utils.assert(strat.scheme, strategy + ' is missing a scheme'); - Utils.assert(['oz', 'basic', 'hawk'].indexOf(strat.scheme) !== -1 || strat.scheme.indexOf('ext:') === 0, strategy + ' has an unknown scheme: ' + strat.scheme); - Utils.assert(strat.scheme.indexOf('ext:') !== 0 || strat.implementation, strategy + ' has extension scheme missing implementation'); - Utils.assert(!strat.implementation || (typeof strat.implementation === 'object' && typeof strat.implementation.authenticate === 'function'), strategy + ' has invalid extension scheme implementation'); + var self = this; - this.strategies[strategy] = this.loadScheme(strat); - } - } + var config = request._route.config.auth; - return this; -}; + var authErrors = []; + var strategyPos = 0; + var authenticate = function () { -internals.Auth.prototype.loadScheme = function (options) { + // Injection - if (options.scheme === 'oz') { - return new Oz(this.server, options); - } - else if (options.scheme === 'hawk') { - return new Hawk(this.server, options); - } - else if (options.scheme === 'basic') { - return new Basic(this.server, options); - } - else { - return options.implementation; - } -}; + if (request.session) { + return validate(null, request.session); + } + // Authenticate -internals.Auth.prototype.authenticate = function (request, next) { - - var self = this; + if (strategyPos >= config.strategies.length) { + return next(Err.unauthorized('Missing authentication', authErrors)); + } - var config = request._route.config.auth; - var authResults = []; - var strategyName = config.strategies[0]; - var strategy = this.strategies[strategyName]; + var strategy = self.strategies[config.strategies[strategyPos++]]; // Increments counter after fetching current strategy + return strategy.authenticate(request, validate); + }; var validate = function (err, session, wasLogged) { // Unauthenticated - if (err || !session) { + if (!err && !session) { + return next(Err.internal('Authentication response missing both error and session')); + } + + if (err) { if (config.mode === 'optional' && !request.raw.req.headers.authorization) { @@ -101,7 +108,20 @@ internals.Auth.prototype.authenticate = function (request, next) { request.log(['auth', 'unauthenticated'], err); } - return nextStrategy(err); + if (!err.isMissing || + err.code !== 401) { // An actual error (not just missing authentication) + + return next(err); + } + + // Try next strategy + + var response = err.toResponse(); + if (response.headers['WWW-Authenticate']) { + authErrors.push(response.headers['WWW-Authenticate']); + } + + return authenticate(); } // Authenticated @@ -114,7 +134,7 @@ internals.Auth.prototype.authenticate = function (request, next) { (!session.scope || session.scope.indexOf(config.scope) === -1)) { request.log(['auth', 'error', 'scope'], { got: session.scope, need: config.scope }); - return nextStrategy(Err.forbidden('Insufficient scope (\'' + config.scope + '\' expected)')); + return next(Err.forbidden('Insufficient scope (\'' + config.scope + '\' expected)')); } // Check TOS @@ -124,7 +144,7 @@ internals.Auth.prototype.authenticate = function (request, next) { (!session.ext || !session.ext.tos || session.ext.tos < tos)) { request.log(['auth', 'error', 'tos'], { min: tos, received: session.ext && session.ext.tos }); - return nextStrategy(Err.forbidden('Insufficient TOS accepted')); + return next(Err.forbidden('Insufficient TOS accepted')); } // Check entity @@ -154,77 +174,12 @@ internals.Auth.prototype.authenticate = function (request, next) { if (session.user) { request.log(['auth', 'error'], 'App session required'); - return nextStrategy(Err.forbidden('User session cannot be used on an application endpoint')); + return next(Err.forbidden('User session cannot be used on an application endpoint')); } request.log(['auth']); return next(); }; - var nextStrategy = function (err) { - - if (err) { - authResults.push(err); - } - - if (config.strategies.length === authResults.length) { - return next(combineUnauthorizedErrors()); - } - - strategyName = config.strategies[authResults.length]; - strategy = self.strategies[strategyName]; - - return strategy ? strategy.authenticate(request, validate) : next(err); - }; - - var combineUnauthorizedErrors = function () { - - var wwwAuthenticate = ''; - var message = ''; - - while (authResults.length > 0) { - - var currentError = authResults.shift(); - if (currentError.code !== 401) { - return currentError; - } - - if (currentError.isMissing === false) { - return currentError; - } - - var response = currentError.toResponse(); - if (message.length > 0 && response.payload.message) { - message += ', '; - } - - if (wwwAuthenticate.length > 0 && response.headers['WWW-Authenticate']) { - wwwAuthenticate += ', '; - } - - wwwAuthenticate += response.headers['WWW-Authenticate']; - message += response.payload.message ? response.payload.message : ''; - } - - var outError = new Err(401, message); - var errResponse = outError.toResponse(); - - outError.toResponse = function () { - - errResponse.headers = { 'WWW-Authenticate': wwwAuthenticate }; - return errResponse; - }; - - return outError; - }; - - // Injection - - if (request.session) { - return validate(null, request.session); - } - - // Authenticate - - return strategy.authenticate(request, validate); - }; \ No newline at end of file + authenticate(); +}; \ No newline at end of file diff --git a/lib/route.js b/lib/route.js index 77bb4e0fc..cbbc6aee6 100755 --- a/lib/route.js +++ b/lib/route.js @@ -52,21 +52,29 @@ exports = module.exports = internals.Route = function (options, server) { // Authentication configuration this.config.auth = this.config.auth || {}; - this.config.auth.mode = this.config.auth.mode || (server.settings.auth ? 'required' : 'none'); - this.config.auth.strategies = this.config.auth.strategies || [this.config.auth.strategy || 'default']; + this.config.auth.mode = this.config.auth.mode || (server.auth ? 'required' : 'none'); Utils.assert(['required', 'optional', 'none'].indexOf(this.config.auth.mode) !== -1, 'Unknown authentication mode: ' + this.config.auth.mode); - Utils.assert(this.config.auth.mode === 'none' || server.settings.auth, 'Route requires authentication but none configured'); - if (server.auth) { - Utils.assert(this.config.auth.mode !== 'none' || !!server.auth.strategies.default, "Route has no default authentication strategy to fallback on"); + if (this.config.auth.mode !== 'none') { + + // Authentication enabled + + Utils.assert(server.auth, 'Route requires authentication but none configured'); + Utils.assert(!this.config.auth.entity || ['user', 'app', 'any'].indexOf(this.config.auth.entity) !== -1, 'Unknown authentication entity type: ' + this.config.auth.entity); + + Utils.assert(!(this.config.auth.strategy && this.config.auth.strategies), 'Route can only have a auth.strategy or auth.strategies (or use the default) but not both') + this.config.auth.strategies = this.config.auth.strategies || [this.config.auth.strategy || 'default']; + delete this.config.auth.strategy; this.config.auth.strategies.forEach(function (strategy) { - Utils.assert(server.auth.strategies.hasOwnProperty(strategy), 'Unknown authentication strategy: ' + strategy); + Utils.assert(server.auth.strategies[strategy], 'Unknown authentication strategy: ' + strategy); }); } - - Utils.assert(this.config.auth.mode === 'none' || !this.config.auth.entity || ['user', 'app', 'any'].indexOf(this.config.auth.entity) !== -1, 'Unknown authentication entity type: ' + this.config.auth.entity); + else { + // No authentication + Utils.assert(Utils.matchKeys(this.config.auth, ['strategy', 'strategies', 'entity', 'tos', 'scope']).length === 0, 'Route auth is off but auth is configured'); + } // Parse path diff --git a/package.json b/package.json index c83f28518..3f136203d 100755 --- a/package.json +++ b/package.json @@ -21,8 +21,8 @@ "node": ">=0.8.0" }, "dependencies": { - "hoek": "0.0.x", - "boom": "0.1.x", + "hoek": "0.1.x", + "boom": "0.2.x", "joi": "0.0.x", "lout": "0.0.x", "hapi-helmet": "0.0.x", diff --git a/test/unit/auth/index.js b/test/unit/auth/index.js index fc39de2af..a1aea8eb7 100755 --- a/test/unit/auth/index.js +++ b/test/unit/auth/index.js @@ -73,6 +73,154 @@ describe('Auth', function () { expect(fn).to.not.throw(Error); done(); }); + + it('throws an error if no strategies are defined', function (done) { + + var request = { + _timestamp: Date.now(), + _route: { + config: { + auth: {} + } + }, + log: function () { } + }; + + var server = { + settings: {}, + addRoutes: function () { } + }; + + var scheme = { + strategies: {} + }; + + var a = function () { + + var auth = new Auth(server, scheme); + }; + + expect(a).to.throw(Error); + done(); + }); + + it('doesn\'t throw an error if strategies are defined but not used', function (done) { + + var server = { + settings: {}, + addRoutes: function () { } + }; + + var scheme = { + strategies: { + 'test': { + scheme: 'basic', + loadUserFunc: function () { } + } + } + }; + + var a = function () { + + var auth = new Auth(server, scheme); + }; + + expect(a).to.not.throw(Error); + done(); + }); + + it('doesn\'t throw an error if strategies are defined and used', function (done) { + + var request = { + _timestamp: Date.now(), + _route: { + config: { + auth: { + mode: 'required', + strategies: ['test'] + } + } + }, + log: function () { }, + raw: { + res: { + setHeader: function () { } + }, + req: { + headers: { + host: 'localhost', + authorization: 'basic d2FsbWFydDp3YWxtYXJ0' + }, + url: 'http://localhost/test' + } + } + }; + + + var server = { + settings: {}, + addRoutes: function () { } + }; + + var scheme = { + strategies: { + 'test': { + scheme: 'basic', + loadUserFunc: function (username, callback) { + + return callback(null, { id: 'walmart', password: 'walmart' }); + } + } + } + }; + + var a = function () { + + var auth = new Auth(server, scheme); + auth.authenticate(request, function (err) { + + expect(err).to.not.exist; + }); + }; + + expect(a).to.not.throw(Error); + done(); + }); + + it('cannot create a route with a strategy not configured on the server', function (done) { + + var config = { + auth: { + mode: 'required', + strategies: ['missing'] + } + }; + + var options = { + auth: { + strategies: { + 'test': { + scheme: 'basic', + loadUserFunc: function (username, callback) { + + return callback(null, { id: 'walmart', password: 'walmart' }); + } + } + } + } + }; + + var server = new Hapi.Server(options); + + var a = function () { + + var handler = function (request) { }; + server.addRoute({ method: 'GET', path: '/', handler: handler, config: config }); + }; + + expect(a).to.throw(Error, 'Unknown authentication strategy: missing'); + done(); + }); }); describe('#authenticate', function () { @@ -366,80 +514,19 @@ describe('Auth', function () { }; test(hawk); - }); - - describe('#setStrategies', function () { - - var handler = function (request) { - - }; - it('throws an error if no strategies are defined', function (done) { - - var request = { - _timestamp: Date.now(), - _route: { - config: { - auth: {} - } - }, - log: function () { } - }; + it('returns error on bad ext scheme callback', function (done) { var server = { - settings: {}, - addRoutes: function () { } - }; - - var scheme = { - strategies: {} + settings: {} }; - - - var a = function () { - - var auth = new Auth(server, scheme); - }; - - expect(a).to.throw(Error); - done(); - }); - - it('doesn\'t throw an error if strategies are defined but not used', function (done) { - - var server = { - settings: {}, - addRoutes: function () { } - }; - - var scheme = { - strategies: { - 'test': { - scheme: 'basic', - loadUserFunc: function () { } - } - } - }; - - var a = function () { - - var auth = new Auth(server, scheme); - }; - - expect(a).to.not.throw(Error); - done(); - }); - - it('doesn\'t throw an error if strategies are defined and used', function (done) { - var request = { _timestamp: Date.now(), _route: { config: { auth: { - mode: 'required', - strategies: ['test'] + strategies: ['default'] } } }, @@ -450,77 +537,33 @@ describe('Auth', function () { }, req: { headers: { - host: 'localhost', - authorization: 'basic d2FsbWFydDp3YWxtYXJ0' + host: 'localhost' }, url: 'http://localhost/test' } - } - }; - - - var server = { - settings: {}, - addRoutes: function () { } + }, + server: server }; var scheme = { - strategies: { - 'test': { - scheme: 'basic', - loadUserFunc: function (username, callback) { + scheme: 'ext:test', + implementation: { + authenticate: function (request, callback) { - return callback(null, { id: 'walmart', password: 'walmart' }); - } + return callback(null, null, false); } } }; - var a = function () { + var auth = new Auth(server, scheme); - var auth = new Auth(server, scheme); - auth.authenticate(request, function (err) { - - expect(err).to.not.exist; - }); - }; - - expect(a).to.not.throw(Error); - done(); - }); + auth.authenticate(request, function (err) { - it('cannot create a route with a strategy not configured on the server', function (done) { - - var config = { - auth: { - mode: 'required', - strategies: ['missing'] - } - }; - - var options = { - auth: { - strategies: { - 'test': { - scheme: 'basic', - loadUserFunc: function (username, callback) { - - return callback(null, { id: 'walmart', password: 'walmart' }); - } - } - } - } - }; - - var server = new Hapi.Server(options); - - var a = function () { - - server.addRoute({ method: 'GET', path: '/', handler: handler, config: config }); - }; - - expect(a).to.throw(Error, 'Unknown authentication strategy: missing'); - done(); + expect(err).to.exist; + expect(err).to.be.instanceOf(Error); + expect(err.message).to.equal('Authentication response missing both error and session'); + done(); + }); }); }); }); \ No newline at end of file