diff --git a/conf/full.yaml b/conf/full.yaml index 642a830e7..3a5bff577 100644 --- a/conf/full.yaml +++ b/conf/full.yaml @@ -60,6 +60,11 @@ packages: # # you can override storage directory for a group of packages this way: # storage: 'local_storage' + # Delegate handling package access authorization to an external + # plugin for packages with this prefix + #'external-*': + # plugin: my_plugin + '*': # allow all users to read packages (including non-authenticated users) # diff --git a/lib/auth.js b/lib/auth.js index b9521f016..8c7d559a7 100644 --- a/lib/auth.js +++ b/lib/auth.js @@ -1,7 +1,10 @@ -var Crypto = require('crypto') -var jju = require('jju') -var Error = require('http-errors') -var Logger = require('./logger') +var assert = require('assert') +var Crypto = require('crypto') +var jju = require('jju') +var Error = require('http-errors') +var Path = require('path') +var Logger = require('./logger') +var load_plugins = require('./plugin-loader').load_plugins module.exports = Auth @@ -11,9 +14,9 @@ function Auth(config) { self.logger = Logger.logger.child({ sub: 'auth' }) self.secret = config.secret - var stuff = { + var plugin_params = { config: config, - logger: self.logger, + logger: self.logger } if (config.users_file) { @@ -24,33 +27,7 @@ function Auth(config) { } } - self.plugins = Object.keys(config.auth || {}).map(function(p) { - var plugin, name - try { - name = 'sinopia-' + p - plugin = require(name) - } catch(x) { - try { - name = p - plugin = require(name) - } catch(x) {} - } - - if (plugin == null) { - throw Error('"' + p + '" auth plugin not found\n' - + 'try "npm install sinopia-' + p + '"') - } - - if (typeof(plugin) !== 'function') - throw Error('"' + name + '" doesn\'t look like a valid auth plugin') - - plugin = plugin(config.auth[p], stuff) - - if (plugin == null || typeof(plugin.authenticate) !== 'function') - throw Error('"' + name + '" doesn\'t look like a valid auth plugin') - - return plugin - }) + self.plugins = load_plugins(config.auth, plugin_params, 'auth', ['authenticate']) self.plugins.unshift({ authenticate: function(user, password, cb) { diff --git a/lib/config.js b/lib/config.js index 231238056..4977177fc 100644 --- a/lib/config.js +++ b/lib/config.js @@ -84,6 +84,11 @@ function Config(config) { function check_userlist(i, hash, action) { if (hash[action] == null) hash[action] = [] + check_stringlist(i, hash, action) + } + + function check_stringlist(i, hash, action) { + if (!hash[action]) return // if it's a string, split it to array if (typeof(hash[action]) === 'string') { @@ -97,6 +102,20 @@ function Config(config) { hash[action] = flatten(hash[action]) } + // if a field in a string or array, converts into a hash with keys + // of the string and values of empty object + function check_objectset(i, hash, action) { + if (!hash[action]) return + if (Array.isArray(hash[action]) || typeof(hash[action]) === 'string') { + check_stringlist(i, hash, action) + var new_object = {} + hash[action].forEach(function(string) { + new_object[string] = {} + }) + hash[action] = new_object + } + } + for (var i in self.packages) { assert( typeof(self.packages[i]) === 'object' && @@ -108,6 +127,8 @@ function Config(config) { check_userlist(i, self.packages[i], 'proxy_access') check_userlist(i, self.packages[i], 'proxy_publish') + check_objectset(i, self.packages[i], 'plugin') + // deprecated check_userlist(i, self.packages[i], 'access') check_userlist(i, self.packages[i], 'proxy') diff --git a/lib/index-api.js b/lib/index-api.js index d1610f706..f1d7aa8fa 100644 --- a/lib/index-api.js +++ b/lib/index-api.js @@ -10,10 +10,11 @@ var match = Middleware.match var media = Middleware.media var validate_name = Middleware.validate_name var validate_pkg = Middleware.validate_package +var async = require('async') -module.exports = function(config, auth, storage) { +module.exports = function(config, auth, storage, package_provider) { var app = express.Router() - var can = Middleware.allow(config) + var can = Middleware.allow(config, package_provider) // validate all of these params as a package name // this might be too harsh, so ask if it causes trouble @@ -85,12 +86,16 @@ module.exports = function(config, auth, storage) { app.get('/-/all/:anything?', function(req, res, next) { storage.search(req.param.startkey || 0, {req: req}, function(err, result) { if (err) return next(err) - for (var pkg in result) { - if (!config.allow_access(pkg, req.remote_user)) { - delete result[pkg] - } - } - return next(result) + async.eachSeries(Object.keys(result), function(pkg, cb) { + package_provider.allow_access(pkg, req.remote_user, function(err, allowed) { + if(err) return cb(err) + if(!allowed) delete result[pkg] + cb() + }) + }, function(err) { + if(err) return next(err) + next(result) + }) }) }) diff --git a/lib/index-web.js b/lib/index-web.js index 2942ca4aa..8e43456bc 100644 --- a/lib/index-web.js +++ b/lib/index-web.js @@ -4,15 +4,16 @@ var express = require('express') var fs = require('fs') var Handlebars = require('handlebars') var renderReadme = require('render-readme') +var async = require('async') var Search = require('./search') var Middleware = require('./middleware') var match = Middleware.match var validate_name = Middleware.validate_name var validate_pkg = Middleware.validate_package -module.exports = function(config, auth, storage) { +module.exports = function(config, auth, storage, package_provider) { var app = express.Router() - var can = Middleware.allow(config) + var can = Middleware.allow(config, package_provider) // validate all of these params as a package name // this might be too harsh, so ask if it causes trouble @@ -47,17 +48,20 @@ module.exports = function(config, auth, storage) { storage.get_local(function(err, packages) { if (err) throw err // that function shouldn't produce any - next(template({ - name: config.web && config.web.title ? config.web.title : 'Sinopia', - packages: packages.filter(allow), - baseUrl: base, - username: req.remote_user.name, - })) + async.filterSeries(packages, function(package, cb) { + package_provider.allow_access(package.name, req.remote_user, function(err, allowed) { + if(err) cb(false) + else cb(allowed) + }) + }, function(packages) { + next(template({ + name: config.web && config.web.title ? config.web.title : 'Sinopia', + packages: packages, + baseUrl: base, + username: req.remote_user.name, + })) + }) }) - - function allow(package) { - return config.allow_access(package.name, req.remote_user) - } }) // Static diff --git a/lib/index.js b/lib/index.js index 1e21f1fa6..6690f50e3 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,20 +1,22 @@ -var express = require('express') -var Error = require('http-errors') -var compression = require('compression') -var Auth = require('./auth') -var Logger = require('./logger') -var Config = require('./config') -var Middleware = require('./middleware') -var Cats = require('./status-cats') -var Storage = require('./storage') +var express = require('express') +var Error = require('http-errors') +var compression = require('compression') +var Auth = require('./auth') +var Logger = require('./logger') +var Config = require('./config') +var Middleware = require('./middleware') +var Cats = require('./status-cats') +var Storage = require('./storage') +var PackageProvider = require('./packages') module.exports = function(config_hash) { Logger.setup(config_hash.logs) - var config = Config(config_hash) - var storage = Storage(config) - var auth = Auth(config) - var app = express() + var config = Config(config_hash) + var storage = Storage(config) + var auth = Auth(config) + var packages = PackageProvider(config) + var app = express() // run in production mode by default, just in case // it shouldn't make any difference anyway @@ -85,14 +87,14 @@ module.exports = function(config_hash) { }) } - app.use(require('./index-api')(config, auth, storage)) + app.use(require('./index-api')(config, auth, storage, packages)) if (config.web && config.web.enable === false) { app.get('/', function(req, res, next) { next( Error[404]('web interface is disabled in the config file') ) }) } else { - app.use(require('./index-web')(config, auth, storage)) + app.use(require('./index-web')(config, auth, storage, packages)) } app.get('/*', function(req, res, next) { diff --git a/lib/middleware.js b/lib/middleware.js index 9e9c860f9..955ca8b00 100644 --- a/lib/middleware.js +++ b/lib/middleware.js @@ -76,24 +76,29 @@ function md5sum(data) { return crypto.createHash('md5').update(data).digest('hex') } -module.exports.allow = function(config) { +module.exports.allow = function(config, packages) { return function(action) { return function(req, res, next) { - if (config['allow_'+action](req.params.package, req.remote_user)) { - next() - } else { - if (!req.remote_user.name) { - if (req.remote_user.error) { - var message = "can't "+action+' restricted package, ' + req.remote_user.error + req.pause(); + packages['allow_'+action](req.params.package, req.remote_user, function(error, is_allowed) { + req.resume(); + if(error) { + next(error) + } else if(is_allowed) { + next() + } else { + if (!req.remote_user.name) { + if (req.remote_user.error) { + var message = "can't "+action+' restricted package, ' + req.remote_user.error + } else { + var message = "can't "+action+" restricted package without auth, did you forget 'npm set always-auth true'?" + } + next( Error[403](message) ) } else { var message = "can't "+action+" restricted package, you are not logged in" } - next( Error[403](message) ) - } else { - next( Error[403]('user ' + req.remote_user.name - + ' not allowed to ' + action + ' it') ) } - } + }) } } } diff --git a/lib/packages.js b/lib/packages.js new file mode 100644 index 000000000..4ba6dbc43 --- /dev/null +++ b/lib/packages.js @@ -0,0 +1,117 @@ +var Logger = require('./logger') +var load_plugins = require('./plugin-loader').load_plugins +var async = require('async') + +module.exports = PackageProvider + +// provides configuration and access control information for packages +function PackageProvider(config) { + var self = Object.create(PackageProvider.prototype) + self.config = config + self.logger = Logger.logger.child({ sub: 'packages' }) + + var plugin_params = { + config: config, + logger: self.logger + } + + // load all plugins referenced by any package + for (var i in config.packages) { + if(config.packages[i].plugin) { + config.packages[i].loaded_plugins = load_plugins(config.packages[i].plugin, plugin_params, 'package_provider', ['allow_access']) + } + } + + self.default_plugin = new ConfigPackageProvider({}, plugin_params) + + return self +} + +function check_plugin_result(function_name, package, arg, cb) { + var self = this + var plugins = self.config.get_package_setting(package, 'loaded_plugins') + if (!plugins || !plugins.length) { + self.default_plugin[function_name](package, arg, cb) + return + } + + var current_result + async.eachSeries(plugins, function(plugin, next) { + if(current_result === undefined && typeof plugin[function_name] === 'function') { + plugin[function_name](package, arg, function(error, result) { + if(error) { + next(error) + } else { + current_result = result + next() + } + }) + } else { + next() + } + }, function(error) { + if(error) { + cb(error) + } else { + if(current_result === undefined) { + self.default_plugin[function_name](package, arg, cb) + } else { + cb(null, current_result) + } + } + }) +} + +PackageProvider.prototype.allow_access = function(package, user, cb) { + check_plugin_result.call(this, 'allow_access', package, user, cb) +} + +PackageProvider.prototype.allow_publish = function(package, user, cb) { + check_plugin_result.call(this, 'allow_publish', package, user, cb) +} + +PackageProvider.prototype.proxy_access = function(package, user, cb) { + check_plugin_result.call(this, 'proxy_access', package, user, cb) +} + +PackageProvider.prototype.get_package_setting = function(package, user, cb) { + check_plugin_result.call(this, 'get_package_setting', package, user, cb) +} + + +// default fallthrough package provider engine to read packages from config file +function ConfigPackageProvider(settings, params) { + var self = Object.create(ConfigPackageProvider.prototype) + self.config = params.config + + return self +} + +ConfigPackageProvider.prototype.allow_access = function(package, user, cb) { + var self = this + setImmediate(function() { + cb(null, self.config.allow_access(package, user)) + }) +} + +ConfigPackageProvider.prototype.allow_publish = function(package, user, cb) { + var self = this + setImmediate(function() { + cb(null, self.config.allow_publish(package, user)) + }) +} + +ConfigPackageProvider.prototype.proxy_access = function(package, uplink, cb) { + var self = this + setImmediate(function() { + cb(null, self.config.proxy_access(package, uplink)) + }) +} + +ConfigPackageProvider.prototype.get_package_setting = function(package, setting, cb) { + var self = this + setImmediate(function() { + cb(null, self.config.get_package_setting(package, setting)) + }) +} + diff --git a/lib/plugin-loader.js b/lib/plugin-loader.js new file mode 100644 index 000000000..5343e37c4 --- /dev/null +++ b/lib/plugin-loader.js @@ -0,0 +1,41 @@ + +function load_plugins(plugin_configs, params, type, required_functions) { + var plugins = Object.keys(plugin_configs || {}).map(function(p) { + var plugin, name + try { + name = 'sinopia-' + p + plugin = require(name) + } catch(x) { + try { + name = p + plugin = require(name) + } catch(x) {} + } + + if (plugin == null) { + throw Error('"' + p + '" plugin not found\n' + + 'try "npm install sinopia-' + p + '"') + } + + if (typeof(plugin) !== 'function') + throw Error('"' + name + '" doesn\'t look like a valid plugin') + + plugin = plugin(plugin_configs[p], params) + + if (plugin == null) + throw Error('"' + name + '" doesn\'t look like a valid plugin') + + if(required_functions) { + for(var i = 0; i < required_functions.length; ++i) { + if(typeof plugin[required_functions[i]] !== 'function') + throw Error('"' + name + '" doesn\'t look like a valid ' + type + ' plugin') + } + } + + return plugin + }) + + return plugins +} + +exports.load_plugins = load_plugins;