0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-04-01 02:41:39 -05:00

Merge pull request #5566 from halfdan/refactor-middleware

Middleware Refactor
This commit is contained in:
Hannah Wolfe 2015-08-09 15:24:50 +01:00
commit 0f954f385d
19 changed files with 1460 additions and 1000 deletions

View file

@ -1,66 +1,70 @@
var passport = require('passport'),
BearerStrategy = require('passport-http-bearer').Strategy,
ClientPasswordStrategy = require('passport-oauth2-client-password').Strategy,
models = require('../models');
var BearerStrategy = require('passport-http-bearer').Strategy,
ClientPasswordStrategy = require('passport-oauth2-client-password').Strategy,
models = require('../models'),
strategies;
/**
* ClientPasswordStrategy
*
* This strategy is used to authenticate registered OAuth clients. It is
* employed to protect the `token` endpoint, which consumers use to obtain
* access tokens. The OAuth 2.0 specification suggests that clients use the
* HTTP Basic scheme to authenticate (not implemented yet).
strategies = {
* Use of the client password strategy is implemented to support ember-simple-auth.
*/
passport.use(new ClientPasswordStrategy(
function strategy(clientId, clientSecret, done) {
models.Client.forge({slug: clientId})
.fetch()
.then(function then(model) {
if (model) {
var client = model.toJSON();
if (client.secret === clientSecret) {
return done(null, client);
/**
* ClientPasswordStrategy
*
* This strategy is used to authenticate registered OAuth clients. It is
* employed to protect the `token` endpoint, which consumers use to obtain
* access tokens. The OAuth 2.0 specification suggests that clients use the
* HTTP Basic scheme to authenticate (not implemented yet).
* Use of the client password strategy is implemented to support ember-simple-auth.
*/
clientPasswordStrategy: new ClientPasswordStrategy(
function strategy(clientId, clientSecret, done) {
models.Client.forge({slug: clientId})
.fetch()
.then(function then(model) {
if (model) {
var client = model.toJSON();
if (client.secret === clientSecret) {
return done(null, client);
}
}
}
return done(null, false);
});
}
));
return done(null, false);
});
}
),
/**
* BearerStrategy
*
* This strategy is used to authenticate users based on an access token (aka a
* bearer token). The user must have previously authorized a client
* application, which is issued an access token to make requests on behalf of
* the authorizing user.
*/
passport.use(new BearerStrategy(
function strategy(accessToken, done) {
models.Accesstoken.forge({token: accessToken})
.fetch()
.then(function then(model) {
if (model) {
var token = model.toJSON();
if (token.expires > Date.now()) {
models.User.forge({id: token.user_id})
.fetch()
.then(function then(model) {
if (model) {
var user = model.toJSON(),
info = {scope: '*'};
return done(null, {id: user.id}, info);
}
/**
* BearerStrategy
*
* This strategy is used to authenticate users based on an access token (aka a
* bearer token). The user must have previously authorized a client
* application, which is issued an access token to make requests on behalf of
* the authorizing user.
*/
bearerStrategy: new BearerStrategy(
function strategy(accessToken, done) {
models.Accesstoken.forge({token: accessToken})
.fetch()
.then(function then(model) {
if (model) {
var token = model.toJSON();
if (token.expires > Date.now()) {
models.User.forge({id: token.user_id})
.fetch()
.then(function then(model) {
if (model) {
var user = model.toJSON(),
info = {scope: '*'};
return done(null, {id: user.id}, info);
}
return done(null, false);
});
} else {
return done(null, false);
});
}
} else {
return done(null, false);
}
} else {
return done(null, false);
}
});
}
));
});
}
)
};
module.exports = strategies;

View file

@ -0,0 +1,48 @@
var passport = require('passport'),
apiErrorHandlers = require('./api-error-handlers');
// ### Authenticate Middleware
// authentication has to be done for /ghost/* routes with
// exceptions for signin, signout, signup, forgotten, reset only
// api and frontend use different authentication mechanisms atm
function authenticate(req, res, next) {
var path,
subPath;
// SubPath is the url path starting after any default subdirectories
// it is stripped of anything after the two levels `/ghost/.*?/` as the reset link has an argument
path = req.path;
/*jslint regexp:true, unparam:true*/
subPath = path.replace(/^(\/.*?\/.*?\/)(.*)?/, function replace(match, a) {
return a;
});
if (subPath.indexOf('/ghost/api/') === 0
&& (path.indexOf('/ghost/api/v0.1/authentication/') !== 0
|| (path.indexOf('/ghost/api/v0.1/authentication/setup/') === 0 && req.method === 'PUT'))) {
return passport.authenticate('bearer', {session: false, failWithError: true},
function authenticate(err, user, info) {
if (err) {
return next(err); // will generate a 500 error
}
// Generate a JSON response reflecting authentication status
if (!user) {
var error = {
code: 401,
errorType: 'NoPermissionError',
message: 'Please Sign In'
};
return apiErrorHandlers.errorHandler(error, req, res, next);
}
// TODO: figure out, why user & authInfo is lost
req.authInfo = info;
req.user = user;
return next(null, user, info);
}
)(req, res, next);
}
next();
}
module.exports = authenticate;

View file

@ -0,0 +1,72 @@
var config = require('../config'),
url = require('url');
function isSSLrequired(isAdmin, configUrl, forceAdminSSL) {
var forceSSL = url.parse(configUrl).protocol === 'https:' ? true : false;
if (forceSSL || (isAdmin && forceAdminSSL)) {
return true;
}
return false;
}
// The guts of checkSSL. Indicate forbidden or redirect according to configuration.
// Required args: forceAdminSSL, url and urlSSL should be passed from config. reqURL from req.url
function sslForbiddenOrRedirect(opt) {
var forceAdminSSL = opt.forceAdminSSL,
reqUrl = opt.reqUrl, // expected to be relative-to-root
baseUrl = url.parse(opt.configUrlSSL || opt.configUrl),
response = {
// Check if forceAdminSSL: { redirect: false } is set, which means
// we should just deny non-SSL access rather than redirect
isForbidden: (forceAdminSSL && forceAdminSSL.redirect !== undefined && !forceAdminSSL.redirect),
// Append the request path to the base configuration path, trimming out a double "//"
redirectPathname: function redirectPathname() {
var pathname = baseUrl.path;
if (reqUrl[0] === '/' && pathname[pathname.length - 1] === '/') {
pathname += reqUrl.slice(1);
} else {
pathname += reqUrl;
}
return pathname;
},
redirectUrl: function redirectUrl(query) {
return url.format({
protocol: 'https:',
hostname: baseUrl.hostname,
port: baseUrl.port,
pathname: this.redirectPathname(),
query: query
});
}
};
return response;
}
// Check to see if we should use SSL
// and redirect if needed
function checkSSL(req, res, next) {
if (isSSLrequired(res.isAdmin, config.url, config.forceAdminSSL)) {
if (!req.secure) {
var response = sslForbiddenOrRedirect({
forceAdminSSL: config.forceAdminSSL,
configUrlSSL: config.urlSSL,
configUrl: config.url,
reqUrl: req.url
});
if (response.isForbidden) {
return res.sendStatus(403);
} else {
return res.redirect(301, response.redirectUrl(req.query));
}
}
}
next();
}
module.exports = checkSSL;
// SSL helper functions are exported primarily for unit testing.
module.exports.isSSLrequired = isSSLrequired;
module.exports.sslForbiddenOrRedirect = sslForbiddenOrRedirect;

View file

@ -1,196 +1,53 @@
// # Custom Middleware
// The following custom middleware functions cannot yet be unit tested, and as such are kept separate from
// the testable custom middleware functions in middleware.js
var bodyParser = require('body-parser'),
config = require('../config'),
errors = require('../errors'),
express = require('express'),
logger = require('morgan'),
path = require('path'),
routes = require('../routes'),
slashes = require('connect-slashes'),
storage = require('../storage'),
passport = require('passport'),
oauth2orize = require('oauth2orize'),
utils = require('../utils'),
sitemapHandler = require('../data/xml/sitemap/handler'),
var api = require('../api'),
bodyParser = require('body-parser'),
config = require('../config'),
crypto = require('crypto'),
errors = require('../errors'),
express = require('express'),
fs = require('fs'),
hbs = require('express-hbs'),
logger = require('morgan'),
middleware = require('./middleware'),
path = require('path'),
routes = require('../routes'),
slashes = require('connect-slashes'),
storage = require('../storage'),
_ = require('lodash'),
passport = require('passport'),
oauth = require('./oauth'),
oauth2orize = require('oauth2orize'),
authStrategies = require('./auth-strategies'),
utils = require('../utils'),
sitemapHandler = require('../data/xml/sitemap/handler'),
decideIsAdmin = require('./decide-is-admin'),
uncapitalise = require('./uncapitalise'),
apiErrorHandlers = require('./api-error-handlers'),
authenticate = require('./authenticate'),
authStrategies = require('./auth-strategies'),
busboy = require('./ghost-busboy'),
clientAuth = require('./client-auth'),
cacheControl = require('./cache-control'),
checkSSL = require('./check-ssl'),
decideIsAdmin = require('./decide-is-admin'),
privateBlogging = require('./private-blogging'),
redirectToSetup = require('./redirect-to-setup'),
serveSharedFile = require('./serve-shared-file'),
spamPrevention = require('./spam-prevention'),
staticTheme = require('./static-theme'),
uncapitalise = require('./uncapitalise'),
oauth = require('./oauth'),
themeHandler = require('./theme-handler'),
privateBlogging = require('./private-blogging'),
blogApp,
middleware,
setupMiddleware;
// ##Custom Middleware
// ### GhostLocals Middleware
// Expose the standard locals that every external page should have available,
// separating between the theme and the admin
function ghostLocals(req, res, next) {
// Make sure we have a locals value.
res.locals = res.locals || {};
res.locals.version = config.ghostVersion;
res.locals.safeVersion = config.ghostVersion.match(/^(\d+\.)?(\d+)/)[0];
// relative path from the URL
res.locals.relativeUrl = req.path;
next();
}
// ### Activate Theme
// Helper for manageAdminAndTheme
function activateTheme(activeTheme) {
var hbsOptions,
themePartials = path.join(config.paths.themePath, activeTheme, 'partials');
// clear the view cache
blogApp.cache = {};
// set view engine
hbsOptions = {
partialsDir: [config.paths.helperTemplates],
onCompile: function onCompile(exhbs, source) {
return exhbs.handlebars.compile(source, {preventIndent: true});
}
};
fs.stat(themePartials, function stat(err, stats) {
// Check that the theme has a partials directory before trying to use it
if (!err && stats && stats.isDirectory()) {
hbsOptions.partialsDir.push(themePartials);
}
});
blogApp.engine('hbs', hbs.express3(hbsOptions));
// Update user error template
errors.updateActiveTheme(activeTheme);
// Set active theme variable on the express server
blogApp.set('activeTheme', activeTheme);
}
// ### configHbsForContext Middleware
// Setup handlebars for the current context (admin or theme)
function configHbsForContext(req, res, next) {
var themeData = config.theme;
if (req.secure && config.urlSSL) {
// For secure requests override .url property with the SSL version
themeData = _.clone(themeData);
themeData.url = config.urlSSL.replace(/\/$/, '');
middleware = {
busboy: busboy,
cacheControl: cacheControl,
spamPrevention: spamPrevention,
privateBlogging: privateBlogging,
api: {
addClientSecret: clientAuth.addClientSecret,
cacheOauthServer: clientAuth.cacheOauthServer,
authenticateClient: clientAuth.authenticateClient,
generateAccessToken: clientAuth.generateAccessToken,
errorHandler: apiErrorHandlers.errorHandler
}
hbs.updateTemplateOptions({data: {blog: themeData}});
if (config.paths.themePath && blogApp.get('activeTheme')) {
blogApp.set('views', path.join(config.paths.themePath, blogApp.get('activeTheme')));
}
// Pass 'secure' flag to the view engine
// so that templates can choose 'url' vs 'urlSSL'
res.locals.secure = req.secure;
next();
}
// ### updateActiveTheme
// Updates the blogApp's activeTheme variable and subsequently
// activates that theme's views with the hbs templating engine if it
// is not yet activated.
function updateActiveTheme(req, res, next) {
api.settings.read({context: {internal: true}, key: 'activeTheme'}).then(function then(response) {
var activeTheme = response.settings[0];
// Check if the theme changed
if (activeTheme.value !== blogApp.get('activeTheme')) {
// Change theme
if (!config.paths.availableThemes.hasOwnProperty(activeTheme.value)) {
if (!res.isAdmin) {
// Throw an error if the theme is not available, but not on the admin UI
return errors.throwError('The currently active theme ' + activeTheme.value + ' is missing.');
} else {
// At this point the activated theme is not present and the current
// request is for the admin client. In order to allow the user access
// to the admin client we set an hbs instance on the app so that middleware
// processing can continue.
blogApp.engine('hbs', hbs.express3());
errors.logWarn('The currently active theme "' + activeTheme.value + '" is missing.');
return next();
}
} else {
activateTheme(activeTheme.value);
}
}
next();
}).catch(function handleError(err) {
// Trying to start up without the active theme present, setup a simple hbs instance
// and render an error page straight away.
blogApp.engine('hbs', hbs.express3());
next(err);
});
}
// Redirect to setup if no user exists
function redirectToSetup(req, res, next) {
/*jslint unparam:true*/
api.authentication.isSetup().then(function then(exists) {
if (!exists.setup[0].status && !req.path.match(/\/setup\//)) {
return res.redirect(config.paths.subdir + '/ghost/setup/');
}
next();
}).catch(function handleError(err) {
return next(new Error(err));
});
}
// ### ServeSharedFile Middleware
// Handles requests to robots.txt and favicon.ico (and caches them)
function serveSharedFile(file, type, maxAge) {
var content,
filePath = path.join(config.paths.corePath, 'shared', file),
re = /(\{\{blog-url\}\})/g;
return function serveSharedFile(req, res, next) {
if (req.url === '/' + file) {
if (content) {
res.writeHead(200, content.headers);
res.end(content.body);
} else {
fs.readFile(filePath, function readFile(err, buf) {
if (err) {
return next(err);
}
if (type === 'text/xsl' || type === 'text/plain') {
buf = buf.toString().replace(re, config.url.replace(/\/$/, ''));
}
content = {
headers: {
'Content-Type': type,
'Content-Length': buf.length,
ETag: '"' + crypto.createHash('md5').update(buf, 'utf8').digest('hex') + '"',
'Cache-Control': 'public, max-age=' + maxAge
},
body: buf
};
res.writeHead(200, content.headers);
res.end(content.body);
});
}
} else {
next();
}
};
}
};
setupMiddleware = function setupMiddleware(blogAppInstance, adminApp) {
var logging = config.logging,
@ -198,13 +55,13 @@ setupMiddleware = function setupMiddleware(blogAppInstance, adminApp) {
oauthServer = oauth2orize.createServer();
// silence JSHint without disabling unused check for the whole file
authStrategies = authStrategies;
passport.use(authStrategies.clientPasswordStrategy);
passport.use(authStrategies.bearerStrategy);
// Cache express server instance
blogApp = blogAppInstance;
middleware.cacheBlogApp(blogApp);
middleware.api.cacheOauthServer(oauthServer);
oauth.init(oauthServer, middleware.spamPrevention.resetCounter);
oauth.init(oauthServer, spamPrevention.resetCounter);
// Make sure 'req.secure' is valid for proxied requests
// (X-Forwarded-Proto header will be checked, if present)
@ -229,8 +86,8 @@ setupMiddleware = function setupMiddleware(blogAppInstance, adminApp) {
// First determine whether we're serving admin or theme content
blogApp.use(decideIsAdmin);
blogApp.use(updateActiveTheme);
blogApp.use(configHbsForContext);
blogApp.use(themeHandler(blogApp).updateActiveTheme);
blogApp.use(themeHandler(blogApp).configHbsForContext);
// Admin only config
blogApp.use('/ghost', express['static'](config.paths.clientAssets, {maxAge: utils.ONE_YEAR_MS}));
@ -239,15 +96,15 @@ setupMiddleware = function setupMiddleware(blogAppInstance, adminApp) {
// NOTE: Importantly this is _after_ the check above for admin-theme static resources,
// which do not need HTTPS. In fact, if HTTPS is forced on them, then 404 page might
// not display properly when HTTPS is not available!
blogApp.use(middleware.checkSSL);
blogApp.use(checkSSL);
adminApp.set('views', config.paths.adminViews);
// Theme only config
blogApp.use(middleware.staticTheme());
blogApp.use(staticTheme());
// Check if password protected blog
blogApp.use(middleware.checkIsPrivate); // check if the blog is protected
blogApp.use(middleware.filterPrivateRoutes);
blogApp.use(privateBlogging.checkIsPrivate); // check if the blog is protected
blogApp.use(privateBlogging.filterPrivateRoutes);
// Serve sitemap.xsl file
blogApp.use(serveSharedFile('sitemap.xsl', 'text/xsl', utils.ONE_DAY_S));
@ -274,24 +131,24 @@ setupMiddleware = function setupMiddleware(blogAppInstance, adminApp) {
// ### Caching
// Blog frontend is cacheable
blogApp.use(middleware.cacheControl('public'));
blogApp.use(cacheControl('public'));
// Admin shouldn't be cached
adminApp.use(middleware.cacheControl('private'));
adminApp.use(cacheControl('private'));
// API shouldn't be cached
blogApp.use(routes.apiBaseUri, middleware.cacheControl('private'));
blogApp.use(routes.apiBaseUri, cacheControl('private'));
// enable authentication
blogApp.use(middleware.authenticate);
blogApp.use(authenticate);
// local data
blogApp.use(ghostLocals);
blogApp.use(themeHandler(blogApp).ghostLocals);
// ### Routing
// Set up API routes
blogApp.use(routes.apiBaseUri, routes.api(middleware));
// Mount admin express app to /ghost and set up routes
adminApp.use(middleware.redirectToSetup);
adminApp.use(redirectToSetup);
adminApp.use(routes.admin());
blogApp.use('/ghost', adminApp);
@ -309,5 +166,3 @@ setupMiddleware = function setupMiddleware(blogAppInstance, adminApp) {
module.exports = setupMiddleware;
// Export middleware functions directly
module.exports.middleware = middleware;
// Expose middleware functions in this file as well
module.exports.middleware.redirectToSetup = redirectToSetup;

View file

@ -1,322 +0,0 @@
// # Custom Middleware
// The following custom middleware functions are all unit testable, and have accompanying unit tests in
// middleware_spec.js
var _ = require('lodash'),
fs = require('fs'),
express = require('express'),
config = require('../config'),
crypto = require('crypto'),
path = require('path'),
api = require('../api'),
passport = require('passport'),
Promise = require('bluebird'),
errors = require('../errors'),
session = require('cookie-session'),
url = require('url'),
utils = require('../utils'),
busboy = require('./ghost-busboy'),
cacheControl = require('./cache-control'),
spamPrevention = require('./spam-prevention'),
clientAuth = require('./client-auth'),
apiErrorHandlers = require('./api-error-handlers'),
middleware,
blogApp;
function isBlackListedFileType(file) {
var blackListedFileTypes = ['.hbs', '.md', '.json'],
ext = path.extname(file);
return _.contains(blackListedFileTypes, ext);
}
function cacheBlogApp(app) {
blogApp = app;
}
function isSSLrequired(isAdmin, configUrl, forceAdminSSL) {
var forceSSL = url.parse(configUrl).protocol === 'https:' ? true : false;
if (forceSSL || (isAdmin && forceAdminSSL)) {
return true;
}
return false;
}
// The guts of checkSSL. Indicate forbidden or redirect according to configuration.
// Required args: forceAdminSSL, url and urlSSL should be passed from config. reqURL from req.url
function sslForbiddenOrRedirect(opt) {
var forceAdminSSL = opt.forceAdminSSL,
reqUrl = opt.reqUrl, // expected to be relative-to-root
baseUrl = url.parse(opt.configUrlSSL || opt.configUrl),
response = {
// Check if forceAdminSSL: { redirect: false } is set, which means
// we should just deny non-SSL access rather than redirect
isForbidden: (forceAdminSSL && forceAdminSSL.redirect !== undefined && !forceAdminSSL.redirect),
// Append the request path to the base configuration path, trimming out a double "//"
redirectPathname: function redirectPathname() {
var pathname = baseUrl.path;
if (reqUrl[0] === '/' && pathname[pathname.length - 1] === '/') {
pathname += reqUrl.slice(1);
} else {
pathname += reqUrl;
}
return pathname;
},
redirectUrl: function redirectUrl(query) {
return url.format({
protocol: 'https:',
hostname: baseUrl.hostname,
port: baseUrl.port,
pathname: this.redirectPathname(),
query: query
});
}
};
return response;
}
function verifySessionHash(salt, hash) {
if (!salt || !hash) {
return Promise.resolve(false);
}
return api.settings.read({context: {internal: true}, key: 'password'}).then(function then(response) {
var hasher = crypto.createHash('sha256');
hasher.update(response.settings[0].value + salt, 'utf8');
return hasher.digest('hex') === hash;
});
}
middleware = {
// ### Authenticate Middleware
// authentication has to be done for /ghost/* routes with
// exceptions for signin, signout, signup, forgotten, reset only
// api and frontend use different authentication mechanisms atm
authenticate: function authenticate(req, res, next) {
var path,
subPath;
// SubPath is the url path starting after any default subdirectories
// it is stripped of anything after the two levels `/ghost/.*?/` as the reset link has an argument
path = req.path;
/*jslint regexp:true, unparam:true*/
subPath = path.replace(/^(\/.*?\/.*?\/)(.*)?/, function replace(match, a) {
return a;
});
if (subPath.indexOf('/ghost/api/') === 0
&& (path.indexOf('/ghost/api/v0.1/authentication/') !== 0
|| (path.indexOf('/ghost/api/v0.1/authentication/setup/') === 0 && req.method === 'PUT'))) {
return passport.authenticate('bearer', {session: false, failWithError: true},
function authenticate(err, user, info) {
if (err) {
return next(err); // will generate a 500 error
}
// Generate a JSON response reflecting authentication status
if (!user) {
var error = {
code: 401,
errorType: 'NoPermissionError',
message: 'Please Sign In'
};
return apiErrorHandlers.errorHandler(error, req, res, next);
}
// TODO: figure out, why user & authInfo is lost
req.authInfo = info;
req.user = user;
return next(null, user, info);
}
)(req, res, next);
}
next();
},
// ### whenEnabled Middleware
// Selectively use middleware
// From https://github.com/senchalabs/connect/issues/676#issuecomment-9569658
whenEnabled: function whenEnabled(setting, fn) {
return function settingEnabled(req, res, next) {
// Set from server/middleware/index.js for now
if (blogApp.enabled(setting)) {
fn(req, res, next);
} else {
next();
}
};
},
staticTheme: function staticTheme() {
return function blackListStatic(req, res, next) {
if (isBlackListedFileType(req.url)) {
return next();
}
return middleware.forwardToExpressStatic(req, res, next);
};
},
// to allow unit testing
forwardToExpressStatic: function forwardToExpressStatic(req, res, next) {
api.settings.read({context: {internal: true}, key: 'activeTheme'}).then(function then(response) {
var activeTheme = response.settings[0];
express['static'](path.join(config.paths.themePath, activeTheme.value), {maxAge: utils.ONE_YEAR_MS})(req, res, next);
});
},
// Check to see if we should use SSL
// and redirect if needed
checkSSL: function checkSSL(req, res, next) {
if (isSSLrequired(res.isAdmin, config.url, config.forceAdminSSL)) {
if (!req.secure) {
var response = sslForbiddenOrRedirect({
forceAdminSSL: config.forceAdminSSL,
configUrlSSL: config.urlSSL,
configUrl: config.url,
reqUrl: req.url
});
if (response.isForbidden) {
return res.sendStatus(403);
} else {
return res.redirect(301, response.redirectUrl(req.query));
}
}
}
next();
},
checkIsPrivate: function checkIsPrivate(req, res, next) {
return api.settings.read({context: {internal: true}, key: 'isPrivate'}).then(function then(response) {
var pass = response.settings[0];
if (_.isEmpty(pass.value) || pass.value === 'false') {
res.isPrivateBlog = false;
return next();
}
res.isPrivateBlog = true;
return session({
maxAge: utils.ONE_MONTH_MS,
signed: false
})(req, res, next);
});
},
filterPrivateRoutes: function filterPrivateRoutes(req, res, next) {
if (res.isAdmin || !res.isPrivateBlog || req.url.lastIndexOf('/private/', 0) === 0) {
return next();
}
// take care of rss and sitemap 404s
if (req.url.lastIndexOf('/rss', 0) === 0 || req.url.lastIndexOf('/sitemap', 0) === 0) {
return errors.error404(req, res, next);
} else if (req.url.lastIndexOf('/robots.txt', 0) === 0) {
fs.readFile(path.join(config.paths.corePath, 'shared', 'private-robots.txt'), function readFile(err, buf) {
if (err) {
return next(err);
}
res.writeHead(200, {
'Content-Type': 'text/plain',
'Content-Length': buf.length,
'Cache-Control': 'public, max-age=' + utils.ONE_HOUR_MS
});
res.end(buf);
});
} else {
return middleware.authenticatePrivateSession(req, res, next);
}
},
authenticatePrivateSession: function authenticatePrivateSession(req, res, next) {
var hash = req.session.token || '',
salt = req.session.salt || '',
url;
return verifySessionHash(salt, hash).then(function then(isVerified) {
if (isVerified) {
return next();
} else {
url = config.urlFor({relativeUrl: '/private/'});
url += req.url === '/' ? '' : '?r=' + encodeURIComponent(req.url);
return res.redirect(url);
}
});
},
// This is here so a call to /private/ after a session is verified will redirect to home;
isPrivateSessionAuth: function isPrivateSessionAuth(req, res, next) {
if (!res.isPrivateBlog) {
return res.redirect(config.urlFor('home', true));
}
var hash = req.session.token || '',
salt = req.session.salt || '';
return verifySessionHash(salt, hash).then(function then(isVerified) {
if (isVerified) {
// redirect to home if user is already authenticated
return res.redirect(config.urlFor('home', true));
} else {
return next();
}
});
},
authenticateProtection: function authenticateProtection(req, res, next) {
// if errors have been generated from the previous call
if (res.error) {
return next();
}
var bodyPass = req.body.password;
return api.settings.read({context: {internal: true}, key: 'password'}).then(function then(response) {
var pass = response.settings[0],
hasher = crypto.createHash('sha256'),
salt = Date.now().toString(),
forward = req.query && req.query.r ? req.query.r : '/';
if (pass.value === bodyPass) {
hasher.update(bodyPass + salt, 'utf8');
req.session.token = hasher.digest('hex');
req.session.salt = salt;
return res.redirect(config.urlFor({relativeUrl: decodeURIComponent(forward)}));
} else {
res.error = {
message: 'Wrong password'
};
return next();
}
});
},
busboy: busboy,
cacheControl: cacheControl,
spamPrevention: spamPrevention
};
module.exports = middleware;
module.exports.cacheBlogApp = cacheBlogApp;
module.exports.api = {
addClientSecret: clientAuth.addClientSecret,
cacheOauthServer: clientAuth.cacheOauthServer,
authenticateClient: clientAuth.authenticateClient,
generateAccessToken: clientAuth.generateAccessToken,
methodNotAllowed: apiErrorHandlers.methodNotAllowed,
errorHandler: apiErrorHandlers.errorHandler
};
// SSL helper functions are exported primarily for unity testing.
module.exports.isSSLrequired = isSSLrequired;
module.exports.sslForbiddenOrRedirect = sslForbiddenOrRedirect;

View file

@ -0,0 +1,136 @@
var _ = require('lodash'),
fs = require('fs'),
config = require('../config'),
crypto = require('crypto'),
path = require('path'),
api = require('../api'),
Promise = require('bluebird'),
errors = require('../errors'),
session = require('cookie-session'),
utils = require('../utils'),
private;
function verifySessionHash(salt, hash) {
if (!salt || !hash) {
return Promise.resolve(false);
}
return api.settings.read({context: {internal: true}, key: 'password'}).then(function then(response) {
var hasher = crypto.createHash('sha256');
hasher.update(response.settings[0].value + salt, 'utf8');
return hasher.digest('hex') === hash;
});
}
private = {
checkIsPrivate: function checkIsPrivate(req, res, next) {
return api.settings.read({context: {internal: true}, key: 'isPrivate'}).then(function then(response) {
var pass = response.settings[0];
if (_.isEmpty(pass.value) || pass.value === 'false') {
res.isPrivateBlog = false;
return next();
}
res.isPrivateBlog = true;
return session({
maxAge: utils.ONE_MONTH_MS,
signed: false
})(req, res, next);
});
},
filterPrivateRoutes: function filterPrivateRoutes(req, res, next) {
if (res.isAdmin || !res.isPrivateBlog || req.url.lastIndexOf('/private/', 0) === 0) {
return next();
}
// take care of rss and sitemap 404s
if (req.url.lastIndexOf('/rss', 0) === 0 || req.url.lastIndexOf('/sitemap', 0) === 0) {
return errors.error404(req, res, next);
} else if (req.url.lastIndexOf('/robots.txt', 0) === 0) {
fs.readFile(path.join(config.paths.corePath, 'shared', 'private-robots.txt'), function readFile(err, buf) {
if (err) {
return next(err);
}
res.writeHead(200, {
'Content-Type': 'text/plain',
'Content-Length': buf.length,
'Cache-Control': 'public, max-age=' + utils.ONE_HOUR_MS
});
res.end(buf);
});
} else {
return private.authenticatePrivateSession(req, res, next);
}
},
authenticatePrivateSession: function authenticatePrivateSession(req, res, next) {
var hash = req.session.token || '',
salt = req.session.salt || '',
url;
return verifySessionHash(salt, hash).then(function then(isVerified) {
if (isVerified) {
return next();
} else {
url = config.urlFor({relativeUrl: '/private/'});
url += req.url === '/' ? '' : '?r=' + encodeURIComponent(req.url);
return res.redirect(url);
}
});
},
// This is here so a call to /private/ after a session is verified will redirect to home;
isPrivateSessionAuth: function isPrivateSessionAuth(req, res, next) {
if (!res.isPrivateBlog) {
return res.redirect(config.urlFor('home', true));
}
var hash = req.session.token || '',
salt = req.session.salt || '';
return verifySessionHash(salt, hash).then(function then(isVerified) {
if (isVerified) {
// redirect to home if user is already authenticated
return res.redirect(config.urlFor('home', true));
} else {
return next();
}
});
},
authenticateProtection: function authenticateProtection(req, res, next) {
// if errors have been generated from the previous call
if (res.error) {
return next();
}
var bodyPass = req.body.password;
return api.settings.read({context: {internal: true}, key: 'password'}).then(function then(response) {
var pass = response.settings[0],
hasher = crypto.createHash('sha256'),
salt = Date.now().toString(),
forward = req.query && req.query.r ? req.query.r : '/';
if (pass.value === bodyPass) {
hasher.update(bodyPass + salt, 'utf8');
req.session.token = hasher.digest('hex');
req.session.salt = salt;
return res.redirect(config.urlFor({relativeUrl: decodeURIComponent(forward)}));
} else {
res.error = {
message: 'Wrong password'
};
return next();
}
});
}
};
module.exports = private;

View file

@ -0,0 +1,16 @@
var api = require('../api'),
config = require('../config');
// Redirect to setup if no user exists
function redirectToSetup(req, res, next) {
api.authentication.isSetup().then(function then(exists) {
if (!exists.setup[0].status && !req.path.match(/\/setup\//)) {
return res.redirect(config.paths.subdir + '/ghost/setup/');
}
next();
}).catch(function handleError(err) {
return next(new Error(err));
});
}
module.exports = redirectToSetup;

View file

@ -0,0 +1,45 @@
var crypto = require('crypto'),
fs = require('fs'),
path = require('path'),
config = require('../config');
// ### ServeSharedFile Middleware
// Handles requests to robots.txt and favicon.ico (and caches them)
function serveSharedFile(file, type, maxAge) {
var content,
filePath = path.join(config.paths.corePath, 'shared', file),
re = /(\{\{blog-url\}\})/g;
return function serveSharedFile(req, res, next) {
if (req.url === '/' + file) {
if (content) {
res.writeHead(200, content.headers);
res.end(content.body);
} else {
fs.readFile(filePath, function readFile(err, buf) {
if (err) {
return next(err);
}
if (type === 'text/xsl' || type === 'text/plain') {
buf = buf.toString().replace(re, config.url.replace(/\/$/, ''));
}
content = {
headers: {
'Content-Type': type,
'Content-Length': buf.length,
ETag: '"' + crypto.createHash('md5').update(buf, 'utf8').digest('hex') + '"',
'Cache-Control': 'public, max-age=' + maxAge
},
body: buf
};
res.writeHead(200, content.headers);
res.end(content.body);
});
}
} else {
next();
}
};
}
module.exports = serveSharedFile;

View file

@ -0,0 +1,31 @@
var _ = require('lodash'),
express = require('express'),
path = require('path'),
api = require('../api'),
config = require('../config'),
utils = require('../utils');
function isBlackListedFileType(file) {
var blackListedFileTypes = ['.hbs', '.md', '.json'],
ext = path.extname(file);
return _.contains(blackListedFileTypes, ext);
}
function forwardToExpressStatic(req, res, next) {
api.settings.read({context: {internal: true}, key: 'activeTheme'}).then(function then(response) {
var activeTheme = response.settings[0];
express['static'](path.join(config.paths.themePath, activeTheme.value), {maxAge: utils.ONE_YEAR_MS})(req, res, next);
});
}
function staticTheme() {
return function blackListStatic(req, res, next) {
if (isBlackListedFileType(req.url)) {
return next();
}
return forwardToExpressStatic(req, res, next);
};
}
module.exports = staticTheme;

View file

@ -0,0 +1,124 @@
var _ = require('lodash'),
fs = require('fs'),
path = require('path'),
hbs = require('express-hbs'),
api = require('../api'),
config = require('../config'),
errors = require('../errors'),
themeHandler;
themeHandler = function (blogApp) {
var handlerMethods = {
// ### GhostLocals Middleware
// Expose the standard locals that every external page should have available,
// separating between the theme and the admin
ghostLocals: function ghostLocals(req, res, next) {
// Make sure we have a locals value.
res.locals = res.locals || {};
res.locals.version = config.ghostVersion;
res.locals.safeVersion = config.ghostVersion.match(/^(\d+\.)?(\d+)/)[0];
// relative path from the URL
res.locals.relativeUrl = req.path;
next();
},
// ### configHbsForContext Middleware
// Setup handlebars for the current context (admin or theme)
configHbsForContext: function configHbsForContext(req, res, next) {
var themeData = config.theme;
if (req.secure && config.urlSSL) {
// For secure requests override .url property with the SSL version
themeData = _.clone(themeData);
themeData.url = config.urlSSL.replace(/\/$/, '');
}
hbs.updateTemplateOptions({data: {blog: themeData}});
if (config.paths.themePath && blogApp.get('activeTheme')) {
blogApp.set('views', path.join(config.paths.themePath, blogApp.get('activeTheme')));
}
// Pass 'secure' flag to the view engine
// so that templates can choose 'url' vs 'urlSSL'
res.locals.secure = req.secure;
next();
},
// ### Activate Theme
// Helper for updateActiveTheme
activateTheme: function activateTheme(activeTheme) {
var hbsOptions,
themePartials = path.join(config.paths.themePath, activeTheme, 'partials');
// clear the view cache
blogApp.cache = {};
// set view engine
hbsOptions = {
partialsDir: [config.paths.helperTemplates],
onCompile: function onCompile(exhbs, source) {
return exhbs.handlebars.compile(source, {preventIndent: true});
}
};
fs.stat(themePartials, function stat(err, stats) {
// Check that the theme has a partials directory before trying to use it
if (!err && stats && stats.isDirectory()) {
hbsOptions.partialsDir.push(themePartials);
}
});
blogApp.engine('hbs', hbs.express3(hbsOptions));
// Update user error template
errors.updateActiveTheme(activeTheme);
// Set active theme variable on the express server
blogApp.set('activeTheme', activeTheme);
},
// ### updateActiveTheme
// Updates the blogApp's activeTheme variable and subsequently
// activates that theme's views with the hbs templating engine if it
// is not yet activated.
updateActiveTheme: function updateActiveTheme(req, res, next) {
api.settings.read({context: {internal: true}, key: 'activeTheme'}).then(function then(response) {
var activeTheme = response.settings[0];
// Check if the theme changed
if (activeTheme.value !== blogApp.get('activeTheme')) {
// Change theme
if (!config.paths.availableThemes.hasOwnProperty(activeTheme.value)) {
if (!res.isAdmin) {
// Throw an error if the theme is not available, but not on the admin UI
return errors.throwError('The currently active theme ' + activeTheme.value + ' is missing.');
} else {
// At this point the activated theme is not present and the current
// request is for the admin client. In order to allow the user access
// to the admin client we set an hbs instance on the app so that middleware
// processing can continue.
blogApp.engine('hbs', hbs.express3());
errors.logWarn('The currently active theme "' + activeTheme.value + '" is missing.');
return next();
}
} else {
handlerMethods.activateTheme(activeTheme.value);
}
}
next();
}).catch(function handleError(err) {
// Trying to start up without the active theme present, setup a simple hbs instance
// and render an error page straight away.
blogApp.engine('hbs', hbs.express3());
next(err);
});
}
};
return handlerMethods;
};
module.exports = themeHandler;

View file

@ -36,13 +36,13 @@ frontendRoutes = function frontendRoutes(middleware) {
// password-protected frontend route
privateRouter.route('/')
.get(
middleware.isPrivateSessionAuth,
middleware.privateBlogging.isPrivateSessionAuth,
frontend.private
)
.post(
middleware.isPrivateSessionAuth,
middleware.privateBlogging.isPrivateSessionAuth,
middleware.spamPrevention.protected,
middleware.authenticateProtection,
middleware.privateBlogging.authenticateProtection,
frontend.private
);

View file

@ -0,0 +1,145 @@
/*globals describe, it, beforeEach, afterEach */
/*jshint expr:true*/
var sinon = require('sinon'),
should = require('should'),
passport = require('passport'),
authenticate = require('../../../server/middleware/authenticate'),
BearerStrategy = require('passport-http-bearer').Strategy,
user = {id: 1},
info = {scope: '*'},
token = 'test_token';
should.equal(true, true);
function registerSuccessfulBearerStrategy() {
// register fake BearerStrategy which always authenticates
passport.use(new BearerStrategy(
function strategy(accessToken, done) {
accessToken.should.eql(token);
return done(null, user, info);
}
));
}
function registerUnsuccessfulBearerStrategy() {
// register fake BearerStrategy which always authenticates
passport.use(new BearerStrategy(
function strategy(accessToken, done) {
accessToken.should.eql(token);
return done(null, false);
}
));
}
function registerSuccessfulBearerStrategy() {
// register fake BearerStrategy which always authenticates
passport.use(new BearerStrategy(
function strategy(accessToken, done) {
accessToken.should.eql(token);
return done(null, user, info);
}
));
}
describe('authenticate', function () {
var res, req, next, sandbox;
beforeEach(function () {
sandbox = sinon.sandbox.create();
req = {};
res = {};
next = sandbox.spy();
});
afterEach(function () {
sandbox.restore();
});
it('should skip authentication if not hitting /ghost', function (done) {
req.path = '/tag/foo/';
req.method = 'GET';
registerSuccessfulBearerStrategy();
authenticate(req, res, next);
next.called.should.be.true;
next.calledWith().should.be.true;
done();
});
it('should skip authentication if hitting /ghost/api/v0.1/authenticaton/', function (done) {
req.path = '/ghost/api/v0.1/authentication/';
req.method = 'GET';
registerSuccessfulBearerStrategy();
authenticate(req, res, next);
next.called.should.be.true;
next.calledWith().should.be.true;
done();
});
it('should skip authentication if hitting GET /ghost/api/v0.1/authenticaton/setup/', function (done) {
req.path = '/ghost/api/v0.1/authentication/setup/';
req.method = 'GET';
registerSuccessfulBearerStrategy();
authenticate(req, res, next);
next.called.should.be.true;
next.calledWith().should.be.true;
done();
});
it('should authentication if hitting PUT /ghost/api/v0.1/authenticaton/setup/', function (done) {
req.path = '/ghost/api/v0.1/authentication/setup/';
req.method = 'PUT';
req.headers = {};
req.headers.authorization = 'Bearer ' + token;
registerSuccessfulBearerStrategy();
authenticate(req, res, next);
next.called.should.be.true;
next.calledWith(null, user, info).should.be.true;
done();
});
it('should authenticate if hitting /ghost/api/ endpoint', function (done) {
req.path = '/ghost/api/v0.1/test/';
req.method = 'PUT';
req.headers = {};
req.headers.authorization = 'Bearer ' + token;
registerSuccessfulBearerStrategy();
authenticate(req, res, next);
next.called.should.be.true;
next.calledWith(null, user, info).should.be.true;
done();
});
it('shouldn\'t authenticate if hitting /ghost/ auth endpoint with invalid credentials', function (done) {
res.status = {};
req.path = '/ghost/api/v0.1/test/';
req.method = 'PUT';
req.headers = {};
req.headers.authorization = 'Bearer ' + token;
registerUnsuccessfulBearerStrategy();
// stub res.status for error handling
sandbox.stub(res, 'status', function (statusCode) {
statusCode.should.eql(401);
return {
json: function (err) {
err.errors[0].errorType.should.eql('NoPermissionError');
}
};
});
authenticate(req, res, next);
next.called.should.be.false;
done();
});
});

View file

@ -0,0 +1,92 @@
/*globals describe, it, beforeEach, afterEach */
/*jshint expr:true*/
var sinon = require('sinon'),
checkSSL = require('../../../server/middleware/check-ssl');
describe('checkSSL', function () {
var sandbox, res, req, next;
beforeEach(function () {
sandbox = sinon.sandbox.create();
req = sinon.spy();
res = sinon.spy();
next = sinon.spy();
});
afterEach(function () {
sandbox.restore();
});
it('skips if already on SSL', function () {
res.isAdmin = true;
req.isSecure = true;
checkSSL(req, res, next);
next.called.should.be.true;
});
});
describe('isSSLRequired', function () {
var isSSLrequired = checkSSL.isSSLrequired;
it('SSL is required if config.url starts with https', function () {
isSSLrequired(undefined, 'https://example.com', undefined).should.be.true;
});
it('SSL is required if isAdmin and config.forceAdminSSL is set', function () {
isSSLrequired(true, 'http://example.com', true).should.be.true;
});
it('SSL is not required if config.url starts with "http:/" and forceAdminSSL is not set', function () {
isSSLrequired(false, 'http://example.com', false).should.be.false;
});
});
describe('sslForbiddenOrRedirect', function () {
var sslForbiddenOrRedirect = checkSSL.sslForbiddenOrRedirect;
it('Return forbidden if config forces admin SSL for AdminSSL redirect is false.', function () {
var response = sslForbiddenOrRedirect({
forceAdminSSL: {redirect: false},
configUrl: 'http://example.com'
});
response.isForbidden.should.be.true;
});
it('If not forbidden, should produce SSL to redirect to when config.url ends with no slash', function () {
var response = sslForbiddenOrRedirect({
forceAdminSSL: {redirect: true},
configUrl: 'http://example.com/config/path',
reqUrl: '/req/path'
});
response.isForbidden.should.be.false;
response.redirectUrl({}).should.equal('https://example.com/config/path/req/path');
});
it('If config ends is slash, potential double-slash in resulting URL is removed', function () {
var response = sslForbiddenOrRedirect({
forceAdminSSL: {redirect: true},
configUrl: 'http://example.com/config/path/',
reqUrl: '/req/path'
});
response.redirectUrl({}).should.equal('https://example.com/config/path/req/path');
});
it('If config.urlSSL is provided it is preferred over config.url', function () {
var response = sslForbiddenOrRedirect({
forceAdminSSL: {redirect: true},
configUrl: 'http://example.com/config/path/',
configUrlSSL: 'https://example.com/ssl/config/path/',
reqUrl: '/req/path'
});
response.redirectUrl({}).should.equal('https://example.com/ssl/config/path/req/path');
});
it('query string in request is preserved in redirect URL', function () {
var response = sslForbiddenOrRedirect({
forceAdminSSL: {redirect: true},
configUrl: 'http://example.com/config/path/',
configUrlSSL: 'https://example.com/ssl/config/path/',
reqUrl: '/req/path'
});
response.redirectUrl({a: 'b'}).should.equal('https://example.com/ssl/config/path/req/path?a=b');
});
});

View file

@ -0,0 +1,252 @@
/*globals describe, beforeEach, afterEach, it*/
/*jshint expr:true*/
var crypto = require('crypto'),
should = require('should'),
sinon = require('sinon'),
Promise = require('bluebird'),
privateBlogging = require('../../../server/middleware/private-blogging'),
api = require('../../../server/api'),
errors = require('../../../server/errors'),
fs = require('fs');
should.equal(true, true);
function hash(password, salt) {
var hasher = crypto.createHash('sha256');
hasher.update(password + salt, 'utf8');
return hasher.digest('hex');
}
describe('Private Blogging', function () {
var sandbox,
apiSettingsStub;
beforeEach(function () {
sandbox = sinon.sandbox.create();
});
afterEach(function () {
sandbox.restore();
});
describe('passProtect', function () {
var req, res, next;
beforeEach(function () {
req = {}, res = {};
apiSettingsStub = sandbox.stub(api.settings, 'read');
next = sinon.spy();
});
it('checkIsPrivate should call next if not private', function (done) {
apiSettingsStub.withArgs(sinon.match.has('key', 'isPrivate')).returns(Promise.resolve({
settings: [{
key: 'isPrivate',
value: 'false'
}]
}));
privateBlogging.checkIsPrivate(req, res, next).then(function () {
next.called.should.be.true;
res.isPrivateBlog.should.be.false;
done();
}).catch(done);
});
it('checkIsPrivate should load session if private', function (done) {
apiSettingsStub.withArgs(sinon.match.has('key', 'isPrivate')).returns(Promise.resolve({
settings: [{
key: 'isPrivate',
value: 'true'
}]
}));
privateBlogging.checkIsPrivate(req, res, next).then(function () {
res.isPrivateBlog.should.be.true;
done();
}).catch(done);
});
describe('not private', function () {
beforeEach(function () {
res.isPrivateBlog = false;
});
it('filterPrivateRoutes should call next if not private', function () {
privateBlogging.filterPrivateRoutes(req, res, next);
next.called.should.be.true;
});
it('isPrivateSessionAuth should redirect if blog is not private', function () {
res = {
redirect: sinon.spy(),
isPrivateBlog: false
};
privateBlogging.isPrivateSessionAuth(req, res, next);
res.redirect.called.should.be.true;
});
});
describe('private', function () {
var errorSpy;
beforeEach(function () {
res.isPrivateBlog = true;
errorSpy = sandbox.spy(errors, 'error404');
res = {
status: function () {
return this;
},
send: function () {},
set: function () {},
isPrivateBlog: true
};
});
it('filterPrivateRoutes should call next if admin', function () {
res.isAdmin = true;
privateBlogging.filterPrivateRoutes(req, res, next);
next.called.should.be.true;
});
it('filterPrivateRoutes should call next if is the "private" route', function () {
req.url = '/private/';
privateBlogging.filterPrivateRoutes(req, res, next);
next.called.should.be.true;
});
it('filterPrivateRoutes should throw 404 if url is sitemap', function () {
req.url = '/sitemap.xml';
privateBlogging.filterPrivateRoutes(req, res, next);
errorSpy.called.should.be.true;
});
it('filterPrivateRoutes should throw 404 if url is rss', function () {
req.url = '/rss';
privateBlogging.filterPrivateRoutes(req, res, next);
errorSpy.called.should.be.true;
});
it('filterPrivateRoutes should throw 404 if url is rss plus something', function () {
req.url = '/rss/sometag';
privateBlogging.filterPrivateRoutes(req, res, next);
errorSpy.called.should.be.true;
});
it('filterPrivateRoutes should render custom robots.txt', function () {
req.url = '/robots.txt';
res.writeHead = sinon.spy();
res.end = sinon.spy();
sandbox.stub(fs, 'readFile', function (file, cb) {
cb(null, 'User-agent: * Disallow: /');
});
privateBlogging.filterPrivateRoutes(req, res, next);
res.writeHead.called.should.be.true;
res.end.called.should.be.true;
});
it('authenticateProtection should call next if error', function () {
res.error = 'Test Error';
privateBlogging.authenticateProtection(req, res, next);
next.called.should.be.true;
});
describe('with hash verification', function () {
beforeEach(function () {
apiSettingsStub.withArgs(sinon.match.has('key', 'password')).returns(Promise.resolve({
settings: [{
key: 'password',
value: 'rightpassword'
}]
}));
});
it('authenticatePrivateSession should return next if hash is verified', function (done) {
var salt = Date.now().toString();
req.session = {
token: hash('rightpassword', salt),
salt: salt
};
privateBlogging.authenticatePrivateSession(req, res, next).then(function () {
next.called.should.be.true;
done();
}).catch(done);
});
it('authenticatePrivateSession should redirect if hash is not verified', function (done) {
req.url = '/welcome-to-ghost';
req.session = {
token: 'wrongpassword',
salt: Date.now().toString()
};
res.redirect = sinon.spy();
privateBlogging.authenticatePrivateSession(req, res, next).then(function () {
res.redirect.called.should.be.true;
done();
}).catch(done);
});
it('isPrivateSessionAuth should redirect if hash is verified', function (done) {
var salt = Date.now().toString();
req.session = {
token: hash('rightpassword', salt),
salt: salt
};
res.redirect = sandbox.spy();
privateBlogging.isPrivateSessionAuth(req, res, next).then(function () {
res.redirect.called.should.be.true;
done();
}).catch(done);
});
it('isPrivateSessionAuth should return next if hash is not verified', function (done) {
req.session = {
token: 'wrongpassword',
salt: Date.now().toString()
};
privateBlogging.isPrivateSessionAuth(req, res, next).then(function () {
next.called.should.be.true;
done();
}).catch(done);
});
it('authenticateProtection should return next if password is incorrect', function (done) {
req.body = {password: 'wrongpassword'};
privateBlogging.authenticateProtection(req, res, next).then(function () {
res.error.should.not.be.empty;
next.called.should.be.true;
done();
}).catch(done);
});
it('authenticateProtection should redirect if password is correct', function (done) {
req.body = {password: 'rightpassword'};
req.session = {};
res.redirect = sandbox.spy();
privateBlogging.authenticateProtection(req, res, next).then(function () {
res.redirect.called.should.be.true;
done();
}).catch(done);
});
});
});
});
});

View file

@ -0,0 +1,74 @@
/*globals describe, it, beforeEach, afterEach */
/*jshint expr:true*/
var sinon = require('sinon'),
should = require('should'),
Promise = require('bluebird'),
api = require('../../../server/api'),
redirectToSetup = require('../../../server/middleware/redirect-to-setup');
should.equal(true, true);
describe('redirectToSetup', function () {
var res, req, next, sandbox;
beforeEach(function () {
sandbox = sinon.sandbox.create();
res = sinon.spy();
req = sinon.spy();
next = sinon.spy();
});
afterEach(function () {
sandbox.restore();
});
it('should redirect to setup if not on setup', function (done) {
sandbox.stub(api.authentication, 'isSetup', function () {
return Promise.resolve({setup: [{status: false}]});
});
req.path = '/';
res.redirect = sinon.spy(function () {
next.called.should.be.false;
res.redirect.called.should.be.true;
done();
});
redirectToSetup(req, res, next);
});
it('should not redirect if setup is done', function (done) {
sandbox.stub(api.authentication, 'isSetup', function () {
return Promise.resolve({setup: [{status: true}]});
});
res = {redirect: sinon.spy()};
req.path = '/';
next = sinon.spy(function () {
next.called.should.be.true;
res.redirect.called.should.be.false;
done();
});
redirectToSetup(req, res, next);
});
it('should not redirect if already on setup', function (done) {
sandbox.stub(api.authentication, 'isSetup', function () {
return Promise.resolve({setup: [{status: false}]});
});
res = {redirect: sinon.spy()};
req.path = '/ghost/setup/';
next = sinon.spy(function () {
next.called.should.be.true;
res.redirect.called.should.be.false;
done();
});
redirectToSetup(req, res, next);
});
});

View file

@ -0,0 +1,105 @@
/*globals describe, it, beforeEach, afterEach */
/*jshint expr:true*/
var fs = require('fs'),
sinon = require('sinon'),
serveSharedFile = require('../../../server/middleware/serve-shared-file');
describe('serveSharedFile', function () {
var res, req, next, sandbox;
beforeEach(function () {
sandbox = sinon.sandbox.create();
res = sinon.spy();
req = sinon.spy();
next = sinon.spy();
});
afterEach(function () {
sandbox.restore();
});
it('should return a middleware', function () {
var result = serveSharedFile('robots.txt', 'text/plain', 3600);
result.should.be.a.Function;
});
it('should skip if the request does NOT match the file', function () {
var middleware = serveSharedFile('robots.txt', 'text/plain', 3600);
req.url = '/favicon.ico';
middleware(req, res, next);
next.called.should.be.true;
});
it('should load the file and send it', function () {
var middleware = serveSharedFile('robots.txt', 'text/plain', 3600),
body = 'User-agent: * Disallow: /';
req.url = '/robots.txt';
sandbox.stub(fs, 'readFile', function (file, cb) {
cb(null, body);
});
res = {
writeHead: sinon.spy(),
end: sinon.spy()
};
middleware(req, res, next);
next.called.should.be.false;
res.writeHead.called.should.be.true;
res.writeHead.args[0][0].should.equal(200);
res.writeHead.calledWith(200, sinon.match.has('Content-Type')).should.be.true;
res.writeHead.calledWith(200, sinon.match.has('Content-Length')).should.be.true;
res.writeHead.calledWith(200, sinon.match.has('ETag')).should.be.true;
res.writeHead.calledWith(200, sinon.match.has('Cache-Control', 'public, max-age=3600')).should.be.true;
res.end.calledWith(body).should.be.true;
});
it('should send the correct headers', function () {
var middleware = serveSharedFile('robots.txt', 'text/plain', 3600),
body = 'User-agent: * Disallow: /';
req.url = '/robots.txt';
sandbox.stub(fs, 'readFile', function (file, cb) {
cb(null, body);
});
res = {
writeHead: sinon.spy(),
end: sinon.spy()
};
middleware(req, res, next);
next.called.should.be.false;
res.writeHead.called.should.be.true;
res.writeHead.args[0][0].should.equal(200);
res.writeHead.calledWith(200, sinon.match.has('Content-Type')).should.be.true;
res.writeHead.calledWith(200, sinon.match.has('Content-Length')).should.be.true;
res.writeHead.calledWith(200, sinon.match.has('ETag')).should.be.true;
res.writeHead.calledWith(200, sinon.match.has('Cache-Control', 'public, max-age=3600')).should.be.true;
});
it('should replace {{blog-url}} in text/plain', function () {
var middleware = serveSharedFile('robots.txt', 'text/plain', 3600),
body = 'User-agent: {{blog-url}}';
req.url = '/robots.txt';
sandbox.stub(fs, 'readFile', function (file, cb) {
cb(null, body);
});
res = {
writeHead: sinon.spy(),
end: sinon.spy()
};
middleware(req, res, next);
next.called.should.be.false;
res.writeHead.called.should.be.true;
res.end.calledWith('User-agent: http://default.com:2368').should.be.true;
});
});

View file

@ -0,0 +1,71 @@
/*globals describe, it, beforeEach */
/*jshint expr:true*/
var sinon = require('sinon'),
should = require('should'),
Promise = require('bluebird'),
api = require('../../../server/api'),
express = require('express'),
staticTheme = require('../../../server/middleware/static-theme');
should.equal(true, true);
describe('staticTheme', function () {
var next;
beforeEach(function () {
next = sinon.spy();
});
it('should call next if hbs file type', function () {
var req = {
url: 'mytemplate.hbs'
};
staticTheme(null)(req, null, next);
next.called.should.be.true;
});
it('should call next if md file type', function () {
var req = {
url: 'README.md'
};
staticTheme(null)(req, null, next);
next.called.should.be.true;
});
it('should call next if json file type', function () {
var req = {
url: 'sample.json'
};
staticTheme(null)(req, null, next);
next.called.should.be.true;
});
it('should call express.static if valid file type', function (done) {
var req = {
url: 'myvalidfile.css'
},
settingsStub,
sandbox = sinon.sandbox.create(),
expressStatic = sinon.spy(express, 'static');
settingsStub = sandbox.stub(api.settings, 'read').withArgs(sinon.match.has('key', 'activeTheme')).returns(Promise.resolve({
settings: [{
key: 'activeKey',
value: 'casper'
}]
}));
staticTheme(null)(req, null, function (reqArg, res, next2) {
/*jshint unused:false */
sandbox.restore();
next.called.should.be.false;
expressStatic.called.should.be.true;
expressStatic.args[0][1].maxAge.should.exist;
done();
});
});
});

View file

@ -0,0 +1,125 @@
/*globals describe, it, beforeEach, afterEach */
/*jshint expr:true*/
var _ = require('lodash'),
sinon = require('sinon'),
should = require('should'),
express = require('express'),
// Stuff we test
themeHandler = require('../../../server/middleware/theme-handler'),
errors = require('../../../server/errors'),
config = require('../../../server/config'),
origConfig = _.cloneDeep(config),
defaultConfig = require('../../../../config.example')[process.env.NODE_ENV];
should.equal(true, true);
describe('Theme Handler', function () {
var req, res, next, blogApp, handler, sandbox;
beforeEach(function () {
req = sinon.spy();
res = sinon.spy();
next = sinon.spy();
blogApp = express();
handler = themeHandler(blogApp);
sandbox = sinon.sandbox.create();
});
afterEach(function () {
sandbox.restore();
// Reset config
config.set(_.merge({}, origConfig, defaultConfig));
});
describe('ghostLocals', function () {
it('sets all locals', function () {
req.path = '/awesome-post';
handler.ghostLocals(req, res, next);
res.locals.should.be.an.Object;
res.locals.version.should.exist;
res.locals.safeVersion.should.exist;
res.locals.relativeUrl.should.equal(req.path);
next.called.should.be.true;
});
});
describe('activateTheme', function () {
it('should activate new theme', function () {
var errorStub = sandbox.stub(errors, 'updateActiveTheme');
handler.activateTheme('casper');
errorStub.calledWith('casper').should.be.true;
blogApp.get('activeTheme').should.equal('casper');
});
});
describe('configHbsForContext', function () {
it('calls next', function () {
req.secure = true;
res.locals = {};
handler.configHbsForContext(req, res, next);
next.called.should.be.true;
});
it('sets secure local variable', function () {
req.secure = true;
res.locals = {};
handler.configHbsForContext(req, res, next);
res.locals.secure.should.equal(req.secure);
});
it('sets view path', function () {
req.secure = true;
res.locals = {};
blogApp.set('activeTheme', 'casper');
handler.configHbsForContext(req, res, next);
blogApp.get('views').should.not.be.undefined;
});
});
// describe('updateActiveTheme', function () {
// it('updates the active theme if changed', function () {
// var activateThemeSpy = sinon.spy(handler, 'activateTheme');
// sandbox.stub(api.settings, 'read').withArgs(sinon.match.has('key', 'activeTheme')).returns(Promise.resolve({
// settings: [{
// key: 'activeKey',
// value: 'casper'
// }]
// }));
// blogApp.set('activeTheme', 'not-casper');
// config.set({paths: {availableThemes: {casper: {}}}});
//
// handler.updateActiveTheme(req, res, next);
//
// activateThemeSpy.called.should.be.false;
// next.called.should.be.false;
// });
//
// it('throws error if theme is missing', function () {
// var errorSpy = sinon.spy(errors, 'throwError');
// sandbox.stub(api.settings, 'read').withArgs(sinon.match.has('key', 'activeTheme')).returns(Promise.resolve({
// settings: [{
// key: 'activeKey',
// value: 'rasper'
// }]
// }));
// blogApp.set('activeTheme', 'not-casper');
// config.set({paths: {availableThemes: {casper: {}}}});
//
// handler.updateActiveTheme(req, res, next);
//
// errorSpy.called.should.be.true;
// next.called.should.be.false;
// });
// });
});

View file

@ -1,413 +0,0 @@
/*globals describe, beforeEach, afterEach, it*/
/*jshint expr:true*/
var assert = require('assert'),
crypto = require('crypto'),
should = require('should'),
sinon = require('sinon'),
Promise = require('bluebird'),
middleware = require('../../server/middleware').middleware,
api = require('../../server/api'),
errors = require('../../server/errors'),
fs = require('fs');
function hash(password, salt) {
var hasher = crypto.createHash('sha256');
hasher.update(password + salt, 'utf8');
return hasher.digest('hex');
}
describe('Middleware', function () {
var sandbox,
apiSettingsStub;
beforeEach(function () {
sandbox = sinon.sandbox.create();
});
afterEach(function () {
sandbox.restore();
});
describe('whenEnabled', function () {
var cbFn, blogApp;
beforeEach(function () {
cbFn = sinon.spy();
blogApp = {
enabled: function (setting) {
if (setting === 'enabled') {
return true;
} else {
return false;
}
}
};
middleware.cacheBlogApp(blogApp);
});
it('should call function if setting is enabled', function (done) {
var req = 1, res = 2, next = 3;
middleware.whenEnabled('enabled', function (a, b, c) {
assert.equal(a, 1);
assert.equal(b, 2);
assert.equal(c, 3);
done();
})(req, res, next);
});
it('should call next() if setting is disabled', function (done) {
middleware.whenEnabled('rando', cbFn)(null, null, function (a) {
should.not.exist(a);
cbFn.calledOnce.should.be.false;
done();
});
});
});
describe('staticTheme', function () {
beforeEach(function () {
sinon.stub(middleware, 'forwardToExpressStatic').yields();
});
afterEach(function () {
middleware.forwardToExpressStatic.restore();
});
it('should call next if hbs file type', function (done) {
var req = {
url: 'mytemplate.hbs'
};
middleware.staticTheme(null)(req, null, function (a) {
should.not.exist(a);
middleware.forwardToExpressStatic.calledOnce.should.be.false;
done();
});
});
it('should call next if md file type', function (done) {
var req = {
url: 'README.md'
};
middleware.staticTheme(null)(req, null, function (a) {
should.not.exist(a);
middleware.forwardToExpressStatic.calledOnce.should.be.false;
done();
});
});
it('should call next if json file type', function (done) {
var req = {
url: 'sample.json'
};
middleware.staticTheme(null)(req, null, function (a) {
should.not.exist(a);
middleware.forwardToExpressStatic.calledOnce.should.be.false;
done();
});
});
it('should call express.static if valid file type', function (done) {
var req = {
url: 'myvalidfile.css'
};
middleware.staticTheme(null)(req, null, function (reqArg, res, next) {
/*jshint unused:false */
middleware.forwardToExpressStatic.calledOnce.should.be.true;
assert.deepEqual(middleware.forwardToExpressStatic.args[0][0], req);
done();
});
});
});
describe('isSSLRequired', function () {
var isSSLrequired = middleware.isSSLrequired;
it('SSL is required if config.url starts with https', function () {
isSSLrequired(undefined, 'https://example.com', undefined).should.be.true;
});
it('SSL is required if isAdmin and config.forceAdminSSL is set', function () {
isSSLrequired(true, 'http://example.com', true).should.be.true;
});
it('SSL is not required if config.url starts with "http:/" and forceAdminSSL is not set', function () {
isSSLrequired(false, 'http://example.com', false).should.be.false;
});
});
describe('sslForbiddenOrRedirect', function () {
var sslForbiddenOrRedirect = middleware.sslForbiddenOrRedirect;
it('Return forbidden if config forces admin SSL for AdminSSL redirect is false.', function () {
var response = sslForbiddenOrRedirect({
forceAdminSSL: {redirect: false},
configUrl: 'http://example.com'
});
response.isForbidden.should.be.true;
});
it('If not forbidden, should produce SSL to redirect to when config.url ends with no slash', function () {
var response = sslForbiddenOrRedirect({
forceAdminSSL: {redirect: true},
configUrl: 'http://example.com/config/path',
reqUrl: '/req/path'
});
response.isForbidden.should.be.false;
response.redirectUrl({}).should.equal('https://example.com/config/path/req/path');
});
it('If config ends is slash, potential double-slash in resulting URL is removed', function () {
var response = sslForbiddenOrRedirect({
forceAdminSSL: {redirect: true},
configUrl: 'http://example.com/config/path/',
reqUrl: '/req/path'
});
response.redirectUrl({}).should.equal('https://example.com/config/path/req/path');
});
it('If config.urlSSL is provided it is preferred over config.url', function () {
var response = sslForbiddenOrRedirect({
forceAdminSSL: {redirect: true},
configUrl: 'http://example.com/config/path/',
configUrlSSL: 'https://example.com/ssl/config/path/',
reqUrl: '/req/path'
});
response.redirectUrl({}).should.equal('https://example.com/ssl/config/path/req/path');
});
it('query string in request is preserved in redirect URL', function () {
var response = sslForbiddenOrRedirect({
forceAdminSSL: {redirect: true},
configUrl: 'http://example.com/config/path/',
configUrlSSL: 'https://example.com/ssl/config/path/',
reqUrl: '/req/path'
});
response.redirectUrl({a: 'b'}).should.equal('https://example.com/ssl/config/path/req/path?a=b');
});
});
describe('passProtect', function () {
var req, res, next;
beforeEach(function () {
req = {}, res = {};
apiSettingsStub = sandbox.stub(api.settings, 'read');
next = sinon.spy();
});
it('checkIsPrivate should call next if not private', function (done) {
apiSettingsStub.withArgs(sinon.match.has('key', 'isPrivate')).returns(Promise.resolve({
settings: [{
key: 'isPrivate',
value: 'false'
}]
}));
middleware.checkIsPrivate(req, res, next).then(function () {
next.called.should.be.true;
res.isPrivateBlog.should.be.false;
done();
}).catch(done);
});
it('checkIsPrivate should load session if private', function (done) {
apiSettingsStub.withArgs(sinon.match.has('key', 'isPrivate')).returns(Promise.resolve({
settings: [{
key: 'isPrivate',
value: 'true'
}]
}));
middleware.checkIsPrivate(req, res, next).then(function () {
res.isPrivateBlog.should.be.true;
done();
}).catch(done);
});
describe('not private', function () {
beforeEach(function () {
res.isPrivateBlog = false;
});
it('filterPrivateRoutes should call next if not private', function () {
middleware.filterPrivateRoutes(req, res, next);
next.called.should.be.true;
});
it('isPrivateSessionAuth should redirect if blog is not private', function () {
res = {
redirect: sinon.spy(),
isPrivateBlog: false
};
middleware.isPrivateSessionAuth(req, res, next);
res.redirect.called.should.be.true;
});
});
describe('private', function () {
var errorSpy;
beforeEach(function () {
res.isPrivateBlog = true;
errorSpy = sandbox.spy(errors, 'error404');
res = {
status: function () {
return this;
},
send: function () {},
set: function () {},
isPrivateBlog: true
};
});
it('filterPrivateRoutes should call next if admin', function () {
res.isAdmin = true;
middleware.filterPrivateRoutes(req, res, next);
next.called.should.be.true;
});
it('filterPrivateRoutes should call next if is the "private" route', function () {
req.url = '/private/';
middleware.filterPrivateRoutes(req, res, next);
next.called.should.be.true;
});
it('filterPrivateRoutes should throw 404 if url is sitemap', function () {
req.url = '/sitemap.xml';
middleware.filterPrivateRoutes(req, res, next);
errorSpy.called.should.be.true;
});
it('filterPrivateRoutes should throw 404 if url is rss', function () {
req.url = '/rss';
middleware.filterPrivateRoutes(req, res, next);
errorSpy.called.should.be.true;
});
it('filterPrivateRoutes should throw 404 if url is rss plus something', function () {
req.url = '/rss/sometag';
middleware.filterPrivateRoutes(req, res, next);
errorSpy.called.should.be.true;
});
it('filterPrivateRoutes should render custom robots.txt', function () {
req.url = '/robots.txt';
res.writeHead = sinon.spy();
res.end = sinon.spy();
sandbox.stub(fs, 'readFile', function (file, cb) {
cb(null, 'User-agent: * Disallow: /');
});
middleware.filterPrivateRoutes(req, res, next);
res.writeHead.called.should.be.true;
res.end.called.should.be.true;
});
it('authenticateProtection should call next if error', function () {
res.error = 'Test Error';
middleware.authenticateProtection(req, res, next);
next.called.should.be.true;
});
describe('with hash verification', function () {
beforeEach(function () {
apiSettingsStub.withArgs(sinon.match.has('key', 'password')).returns(Promise.resolve({
settings: [{
key: 'password',
value: 'rightpassword'
}]
}));
});
it('authenticatePrivateSession should return next if hash is verified', function (done) {
var salt = Date.now().toString();
req.session = {
token: hash('rightpassword', salt),
salt: salt
};
middleware.authenticatePrivateSession(req, res, next).then(function () {
next.called.should.be.true;
done();
}).catch(done);
});
it('authenticatePrivateSession should redirect if hash is not verified', function (done) {
req.url = '/welcome-to-ghost';
req.session = {
token: 'wrongpassword',
salt: Date.now().toString()
};
res.redirect = sinon.spy();
middleware.authenticatePrivateSession(req, res, next).then(function () {
res.redirect.called.should.be.true;
done();
}).catch(done);
});
it('isPrivateSessionAuth should redirect if hash is verified', function (done) {
var salt = Date.now().toString();
req.session = {
token: hash('rightpassword', salt),
salt: salt
};
res.redirect = sandbox.spy();
middleware.isPrivateSessionAuth(req, res, next).then(function () {
res.redirect.called.should.be.true;
done();
}).catch(done);
});
it('isPrivateSessionAuth should return next if hash is not verified', function (done) {
req.session = {
token: 'wrongpassword',
salt: Date.now().toString()
};
middleware.isPrivateSessionAuth(req, res, next).then(function () {
next.called.should.be.true;
done();
}).catch(done);
});
it('authenticateProtection should return next if password is incorrect', function (done) {
req.body = {password: 'wrongpassword'};
middleware.authenticateProtection(req, res, next).then(function () {
res.error.should.not.be.empty;
next.called.should.be.true;
done();
}).catch(done);
});
it('authenticateProtection should redirect if password is correct', function (done) {
req.body = {password: 'rightpassword'};
req.session = {};
res.redirect = sandbox.spy();
middleware.authenticateProtection(req, res, next).then(function () {
res.redirect.called.should.be.true;
done();
}).catch(done);
});
});
});
});
});