Merge pull request #651 from redhog/master

Actually usable plugin manager, and way better security/authentication management
This commit is contained in:
John McLear 2012-04-19 07:11:24 -07:00
commit fc6e5177a9
11 changed files with 383 additions and 181 deletions

View File

@ -46,12 +46,28 @@
/* This is the path to the Abiword executable. Setting it to null, disables abiword.
Abiword is needed to enable the import/export of pads*/
"abiword" : null,
/* This setting is used if you need http basic auth */
// "httpAuth" : "user:pass",
/* This setting is used if you require authentication of all users.
Note: /admin always requires authentication. */
"requireAuthentication": false,
/* This setting is used for http basic auth for admin pages. If not set, the admin page won't be accessible from web*/
// "adminHttpAuth" : "user:pass",
/* Require authorization by a module, or a user with is_admin set, see below. */
"requireAuthorization": false,
/* Users for basic authentication. is_admin = true gives access to /admin.
If you do not uncomment this, /admin will not be available! */
/*
"users": {
"admin": {
"password": "changeme1",
"is_admin": true
},
"user": {
"password": "changeme1",
"is_admin": false
}
},
*/
/* The log level we are using, can be: DEBUG, INFO, WARN, ERROR */
"loglevel": "INFO"

View File

@ -21,13 +21,15 @@ exports.expressCreateServer = function (hook_name, args, cb) {
exports.socketio = function (hook_name, args, cb) {
var io = args.io.of("/pluginfw/installer");
io.on('connection', function (socket) {
if (!socket.handshake.session.user || !socket.handshake.session.user.is_admin) return;
socket.on("load", function (query) {
socket.emit("installed-results", {results: plugins.plugins});
});
socket.on("search", function (query) {
socket.emit("progress", {progress:0, message:'Fetching results...'});
installer.search(query, function (progress) {
installer.search(query, true, function (progress) {
if (progress.results)
socket.emit("search-result", progress);
socket.emit("progress", progress);

View File

@ -7,11 +7,27 @@ var hooks = require("ep_etherpad-lite/static/js/pluginfw/hooks");
var padMessageHandler = require("../../handler/PadMessageHandler");
var timesliderMessageHandler = require("../../handler/TimesliderMessageHandler");
var connect = require('connect');
exports.expressCreateServer = function (hook_name, args, cb) {
//init socket.io and redirect all requests to the MessageHandler
var io = socketio.listen(args.app);
/* Require an express session cookie to be present, and load the
* session. See http://www.danielbaulig.de/socket-ioexpress for more
* info */
io.set('authorization', function (data, accept) {
if (!data.headers.cookie) return accept('No session cookie transmitted.', false);
data.cookie = connect.utils.parseCookie(data.headers.cookie);
data.sessionID = data.cookie.express_sid;
args.app.sessionStore.get(data.sessionID, function (err, session) {
if (err || !session) return accept('Bad session / session has expired', false);
data.session = new connect.middleware.session.Session(data, session);
accept(null, true);
});
});
//this is only a workaround to ensure it works with all browers behind a proxy
//we should remove this when the new socket.io version is more stable
io.set('transports', ['xhr-polling']);

View File

@ -2,55 +2,108 @@ var express = require('express');
var log4js = require('log4js');
var httpLogger = log4js.getLogger("http");
var settings = require('../../utils/Settings');
var randomString = require('ep_etherpad-lite/static/js/pad_utils').randomString;
var hooks = require('ep_etherpad-lite/static/js/pluginfw/hooks');
//checks for basic http auth
exports.basicAuth = function (req, res, next) {
// When handling HTTP-Auth, an undefined password will lead to no authorization at all
var pass = settings.httpAuth || '';
if (req.path.indexOf('/admin') == 0) {
var pass = settings.adminHttpAuth;
}
// Just pass if password is an empty string
if (pass === '') {
return next();
}
// If a password has been set and auth headers are present...
if (pass && req.headers.authorization && req.headers.authorization.search('Basic ') === 0) {
// ...check login and password
if (new Buffer(req.headers.authorization.split(' ')[1], 'base64').toString() === pass) {
return next();
var hookResultMangle = function (cb) {
return function (err, data) {
return cb(!err && data.length && data[0]);
}
}
// Do not require auth for static paths...this could be a bit brittle
else if (req.path.match(/^\/(static|javascripts|pluginfw)/)) {
return next();
var authorize = function (cb) {
// Do not require auth for static paths...this could be a bit brittle
if (req.path.match(/^\/(static|javascripts|pluginfw)/)) return cb(true);
if (req.path.indexOf('/admin') != 0) {
if (!settings.requireAuthentication) return cb(true);
if (!settings.requireAuthorization && req.session && req.session.user) return cb(true);
}
if (req.session && req.session.user && req.session.user.is_admin) return cb(true);
hooks.aCallFirst("authorize", {req: req, res:res, next:next, resource: req.path}, hookResultMangle(cb));
}
var authenticate = function (cb) {
// If auth headers are present use them to authenticate...
if (req.headers.authorization && req.headers.authorization.search('Basic ') === 0) {
var userpass = new Buffer(req.headers.authorization.split(' ')[1], 'base64').toString().split(":")
var username = userpass[0];
var password = userpass[1];
if (settings.users[username] != undefined && settings.users[username].password == password) {
settings.users[username].username = username;
req.session.user = settings.users[username];
return cb(true);
}
return hooks.aCallFirst("authenticate", {req: req, res:res, next:next, username: username, password: password}, hookResultMangle(cb));
}
hooks.aCallFirst("authenticate", {req: req, res:res, next:next}, hookResultMangle(cb));
}
// Otherwise return Auth required Headers, delayed for 1 second, if auth failed.
res.header('WWW-Authenticate', 'Basic realm="Protected Area"');
if (req.headers.authorization) {
setTimeout(function () {
res.send('Authentication required', 401);
}, 1000);
} else {
res.send('Authentication required', 401);
/* Authentication OR authorization failed. */
var failure = function () {
return hooks.aCallFirst("authFailure", {req: req, res:res, next:next}, hookResultMangle(function (ok) {
if (ok) return;
/* No plugin handler for invalid auth. Return Auth required
* Headers, delayed for 1 second, if authentication failed
* before. */
res.header('WWW-Authenticate', 'Basic realm="Protected Area"');
if (req.headers.authorization) {
setTimeout(function () {
res.send('Authentication required', 401);
}, 1000);
} else {
res.send('Authentication required', 401);
}
}));
}
/* This is the actual authentication/authorization hoop. It is done in four steps:
1) Try to just access the thing
2) If not allowed using whatever creds are in the current session already, try to authenticate
3) If authentication using already supplied credentials succeeds, try to access the thing again
4) If all els fails, give the user a 401 to request new credentials
Note that the process could stop already in step 3 with a redirect to login page.
*/
authorize(function (ok) {
if (ok) return next();
authenticate(function (ok) {
if (!ok) return failure();
authorize(function (ok) {
if (ok) return next();
failure();
});
});
});
}
exports.expressConfigure = function (hook_name, args, cb) {
args.app.use(exports.basicAuth);
// If the log level specified in the config file is WARN or ERROR the application server never starts listening to requests as reported in issue #158.
// Not installing the log4js connect logger when the log level has a higher severity than INFO since it would not log at that level anyway.
if (!(settings.loglevel === "WARN" || settings.loglevel == "ERROR"))
args.app.use(log4js.connectLogger(httpLogger, { level: log4js.levels.INFO, format: ':status, :method :url'}));
args.app.use(express.cookieParser());
/* Do not let express create the session, so that we can retain a
* reference to it for socket.io to use. Also, set the key (cookie
* name) to a javascript identifier compatible string. Makes code
* handling it cleaner :) */
args.app.sessionStore = new express.session.MemoryStore();
args.app.use(express.session({store: args.app.sessionStore,
key: 'express_sid',
secret: apikey = randomString(32)}));
args.app.use(exports.basicAuth);
}

View File

@ -30,6 +30,7 @@ var path = require('path');
var plugins = require("ep_etherpad-lite/static/js/pluginfw/plugins");
var hooks = require("ep_etherpad-lite/static/js/pluginfw/hooks");
var npm = require("npm/lib/npm.js");
var _ = require("underscore");
//try to get the git version
var version = "";
@ -88,11 +89,11 @@ async.waterfall([
//let the server listen
app.listen(settings.port, settings.ip);
console.log("Server is listening at " + settings.ip + ":" + settings.port);
if(settings.adminHttpAuth){
if(!_.isEmpty(settings.users)){
console.log("Plugin admin page listening at " + settings.ip + ":" + settings.port + "/admin/plugins");
}
else{
console.log("Admin username and password not set in settings.json. To access admin please uncomment and edit adminHttpAuth in settings.json");
console.log("Admin username and password not set in settings.json. To access admin please uncomment and edit 'users' in settings.json");
}
callback(null);
}

View File

@ -80,15 +80,12 @@ exports.abiword = null;
*/
exports.loglevel = "INFO";
/**
* Http basic auth, with "user:password" format
*/
exports.httpAuth = null;
/**
* Http basic auth, with "user:password" format
*/
exports.adminHttpAuth = null;
/* This setting is used if you need authentication and/or
* authorization. Note: /admin always requires authentication, and
* either authorization by a module, or a user with is_admin set */
exports.requireAuthentication = false;
exports.requireAuthorization = false;
exports.users = {};
//checks if abiword is avaiable
exports.abiwordAvailable = function()

View File

@ -17,6 +17,7 @@
"ueberDB" : "0.1.7",
"async" : "0.1.18",
"express" : "2.5.8",
"connect" : "1.8.7",
"clean-css" : "0.3.2",
"uglify-js" : "1.2.5",
"formidable" : "1.0.9",

View File

@ -0,0 +1,132 @@
$(document).ready(function () {
var socket = io.connect().of("/pluginfw/installer");
$('.search-results').data('query', {
pattern: '',
offset: 0,
limit: 4,
});
var doUpdate = false;
var search = function () {
socket.emit("search", $('.search-results').data('query'));
}
function updateHandlers() {
$("#progress.dialog .close").unbind('click').click(function () {
$("#progress.dialog").hide();
});
$("#do-search").unbind('click').click(function () {
var query = $('.search-results').data('query');
query.pattern = $("#search-query")[0].value;
query.offset = 0;
search();
});
$(".do-install").unbind('click').click(function (e) {
var row = $(e.target).closest("tr");
doUpdate = true;
socket.emit("install", row.find(".name").html());
});
$(".do-uninstall").unbind('click').click(function (e) {
var row = $(e.target).closest("tr");
doUpdate = true;
socket.emit("uninstall", row.find(".name").html());
});
$(".do-prev-page").unbind('click').click(function (e) {
var query = $('.search-results').data('query');
query.offset -= query.limit;
if (query.offset < 0) {
query.offset = 0;
}
search();
});
$(".do-next-page").unbind('click').click(function (e) {
var query = $('.search-results').data('query');
var total = $('.search-results').data('total');
if (query.offset + query.limit < total) {
query.offset += query.limit;
}
search();
});
}
updateHandlers();
socket.on('progress', function (data) {
if (data.progress > 0 && $('#progress.dialog').data('progress') > data.progress) return;
$("#progress.dialog .close").hide();
$("#progress.dialog").show();
$('#progress.dialog').data('progress', data.progress);
var message = "Unknown status";
if (data.message) {
message = "<span class='status'>" + data.message.toString() + "</span>";
}
if (data.error) {
message = "<span class='error'>" + data.error.toString() + "<span>";
}
$("#progress.dialog .message").html(message);
$("#progress.dialog .history").append("<div>" + message + "</div>");
if (data.progress >= 1) {
if (data.error) {
$("#progress.dialog .close").show();
} else {
if (doUpdate) {
doUpdate = false;
socket.emit("load");
}
$("#progress.dialog").hide();
}
}
});
socket.on('search-result', function (data) {
var widget=$(".search-results");
widget.data('query', data.query);
widget.data('total', data.total);
widget.find('.offset').html(data.query.offset);
widget.find('.limit').html(data.query.offset + data.query.limit);
widget.find('.total').html(data.total);
widget.find(".results *").remove();
for (plugin_name in data.results) {
var plugin = data.results[plugin_name];
var row = widget.find(".template tr").clone();
for (attr in plugin) {
row.find("." + attr).html(plugin[attr]);
}
widget.find(".results").append(row);
}
updateHandlers();
});
socket.on('installed-results', function (data) {
$("#installed-plugins *").remove();
for (plugin_name in data.results) {
var plugin = data.results[plugin_name];
var row = $("#installed-plugin-template").clone();
for (attr in plugin.package) {
row.find("." + attr).html(plugin.package[attr]);
}
$("#installed-plugins").append(row);
}
updateHandlers();
});
socket.emit("load");
search();
});

View File

@ -4,27 +4,63 @@ var _;
/* FIXME: Ugly hack, in the future, use same code for server & client */
if (plugins.isClient) {
var async = require("ep_etherpad-lite/static/js/pluginfw/async");
_ = require("ep_etherpad-lite/static/js/underscore");
var _ = require("ep_etherpad-lite/static/js/underscore");
} else {
var async = require("async");
_ = require("underscore");
var _ = require("underscore");
}
exports.bubbleExceptions = true
var hookCallWrapper = function (hook, hook_name, args, cb) {
if (cb === undefined) cb = function (x) { return x; };
// Normalize output to list for both sync and async cases
var normalize = function(x) {
if (x == undefined) return [];
return x;
}
var normalizedhook = function () {
return normalize(hook.hook_fn(hook_name, args, function (x) {
return cb(normalize(x));
}));
}
if (exports.bubbleExceptions) {
return hook.hook_fn(hook_name, args, cb);
return normalizedhook();
} else {
try {
return hook.hook_fn(hook_name, args, cb);
return normalizedhook();
} catch (ex) {
console.error([hook_name, hook.part.full_name, ex.stack || ex]);
}
}
}
exports.syncMapFirst = function (lst, fn) {
var i;
var result;
for (i = 0; i < lst.length; i++) {
result = fn(lst[i])
if (result.length) return result;
}
return undefined;
}
exports.mapFirst = function (lst, fn, cb) {
var i = 0;
next = function () {
if (i >= lst.length) return cb(undefined);
fn(lst[i++], function (err, result) {
if (err) return cb(err);
if (result.length) return cb(null, result);
next();
});
}
next();
}
/* Don't use Array.concat as it flatterns arrays within the array */
exports.flatten = function (lst) {
@ -44,9 +80,9 @@ exports.flatten = function (lst) {
exports.callAll = function (hook_name, args) {
if (!args) args = {};
if (plugins.hooks[hook_name] === undefined) return [];
return exports.flatten(_.map(plugins.hooks[hook_name], function (hook) {
return _.flatten(_.map(plugins.hooks[hook_name], function (hook) {
return hookCallWrapper(hook, hook_name, args);
}));
}), true);
}
exports.aCallAll = function (hook_name, args, cb) {
@ -59,7 +95,7 @@ exports.aCallAll = function (hook_name, args, cb) {
hookCallWrapper(hook, hook_name, args, function (res) { cb(null, res); });
},
function (err, res) {
cb(null, exports.flatten(res));
cb(null, _.flatten(res, true));
}
);
}
@ -67,14 +103,22 @@ exports.aCallAll = function (hook_name, args, cb) {
exports.callFirst = function (hook_name, args) {
if (!args) args = {};
if (plugins.hooks[hook_name][0] === undefined) return [];
return exports.flatten(hookCallWrapper(plugins.hooks[hook_name][0], hook_name, args));
return exports.syncMapFirst(plugins.hooks[hook_name], function (hook) {
return hookCallWrapper(hook, hook_name, args);
});
}
exports.aCallFirst = function (hook_name, args, cb) {
if (!args) args = {};
if (!cb) cb = function () {};
if (plugins.hooks[hook_name][0] === undefined) return cb(null, []);
hookCallWrapper(plugins.hooks[hook_name][0], hook_name, args, function (res) { cb(null, exports.flatten(res)); });
if (plugins.hooks[hook_name] === undefined) return cb(null, []);
exports.mapFirst(
plugins.hooks[hook_name],
function (hook, cb) {
hookCallWrapper(hook, hook_name, args, function (res) { cb(null, res); });
},
cb
);
}
exports.callAllStr = function(hook_name, args, sep, pre, post) {

View File

@ -55,19 +55,41 @@ exports.install = function(plugin_name, cb) {
);
};
exports.search = function(pattern, cb) {
exports.searchCache = null;
exports.search = function(query, cache, cb) {
withNpm(
function (cb) {
registry.get(
"/-/all", null, 600, false, true,
var getData = function (cb) {
if (cache && exports.searchCache) {
cb(null, exports.searchCache);
} else {
registry.get(
"/-/all", null, 600, false, true,
function (er, data) {
if (er) return cb(er);
exports.searchCache = data;
cb(er, data);
}
);
}
}
getData(
function (er, data) {
if (er) return cb(er);
var res = {};
var i = 0;
for (key in data) {
if (key.indexOf(plugins.prefix) == 0 && key.indexOf(pattern) != -1)
res[key] = data[key];
if ( key.indexOf(plugins.prefix) == 0
&& key.indexOf(query.pattern) != -1) {
i++;
if (i > query.offset
&& i <= query.offset + query.limit) {
res[key] = data[key];
}
}
}
cb(null, {results:res});
cb(null, {results:res, query: query, total:i});
}
);
},

View File

@ -4,95 +4,7 @@
<link href="../../static/css/admin.css" rel="stylesheet" type="text/css" />
<script src="../../static/js/jquery.js"></script>
<script src="../../socket.io/socket.io.js"></script>
<script>
$(document).ready(function () {
var socket = io.connect().of("/pluginfw/installer");
var doUpdate = false;
function updateHandlers() {
$("#progress.dialog .close").unbind('click').click(function () {
$("#progress.dialog").hide();
});
$("#do-search").unbind('click').click(function () {
if ($("#search-query")[0].value != "")
socket.emit("search", $("#search-query")[0].value);
});
$(".do-install").unbind('click').click(function (e) {
var row = $(e.target).closest("tr");
doUpdate = true;
socket.emit("install", row.find(".name").html());
});
$(".do-uninstall").unbind('click').click(function (e) {
var row = $(e.target).closest("tr");
doUpdate = true;
socket.emit("uninstall", row.find(".name").html());
});
}
updateHandlers();
socket.on('progress', function (data) {
$("#progress.dialog .close").hide();
$("#progress.dialog").show();
var message = "Unknown status";
if (data.message) {
message = "<span class='status'>" + data.message.toString() + "</span>";
}
if (data.error) {
message = "<span class='error'>" + data.error.toString() + "<span>";
}
$("#progress.dialog .message").html(message);
$("#progress.dialog .history").append("<div>" + message + "</div>");
if (data.progress >= 1) {
if (data.error) {
$("#progress.dialog .close").show();
} else {
if (doUpdate) {
doUpdate = false;
socket.emit("load");
}
$("#progress.dialog").hide();
}
}
});
socket.on('search-result', function (data) {
$("#search-results *").remove();
for (plugin_name in data.results) {
var plugin = data.results[plugin_name];
var row = $("#search-result-template").clone();
for (attr in plugin) {
row.find("." + attr).html(plugin[attr]);
}
$("#search-results").append(row);
}
updateHandlers();
});
socket.on('installed-results', function (data) {
$("#installed-plugins *").remove();
for (plugin_name in data.results) {
var plugin = data.results[plugin_name];
var row = $("#installed-plugin-template").clone();
for (attr in plugin.package) {
row.find("." + attr).html(plugin.package[attr]);
}
$("#installed-plugins").append(row);
}
updateHandlers();
});
socket.emit("load");
});
</script>
<script src="../../static/js/admin/plugins.js"></script>
</head>
<body>
<div id="wrapper">
@ -128,31 +40,37 @@
</tbody>
</table>
<h1>Search for plugins to install</h1>
<form>
<input type="text" name="search" value="" id="search-query">
<input type="button" value="Search" id="do-search">
</form>
<table>
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<td></td>
</tr>
</thead>
<tbody class="template">
<tr id="search-result-template">
<td class="name"></td>
<td class="description"></td>
<td class="actions">
<input type="button" value="Install" class="do-install">
</td>
</tr>
</tbody>
<tbody id="search-results">
</tbody>
</table>
<div class="paged listing search-results">
<h1>Search for plugins to install</h1>
<form>
<input type="text" name="search" value="" id="search-query">
<input type="button" value="Search" id="do-search">
</form>
<table>
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<td></td>
</tr>
</thead>
<tbody class="template">
<tr>
<td class="name"></td>
<td class="description"></td>
<td class="actions">
<input type="button" value="Install" class="do-install">
</td>
</tr>
</tbody>
<tbody class="results">
</tbody>
</table>
<input type="button" value="<<" class="do-prev-page">
<span class="offset"></span>..<span class="limit"></span> of <span class="total"></span>.
<input type="button" value=">>" class="do-next-page">
</div>
<div id="progress" class="dialog">
<h1 class="title">