mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-01-20 22:42:53 -05:00
added password protection
closes #4993 - brings password protection to the frontend of blogs - adds testing for password protection - upgrades bcrypt-js to 2.1.0
This commit is contained in:
parent
f4a9f0c82d
commit
2865662ee5
24 changed files with 697 additions and 9 deletions
|
@ -24,6 +24,10 @@ var FeatureController = Ember.Controller.extend(Ember.PromiseProxyMixin, {
|
||||||
}
|
}
|
||||||
|
|
||||||
return value;
|
return value;
|
||||||
|
}),
|
||||||
|
|
||||||
|
passProtectUI: Ember.computed('config.passProtectUI', 'labs.passProtectUI', function () {
|
||||||
|
return this.get('config.passProtectUI') || this.get('labs.passProtectUI');
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import Ember from 'ember';
|
import Ember from 'ember';
|
||||||
var SettingsController = Ember.Controller.extend({
|
var SettingsController = Ember.Controller.extend({
|
||||||
|
|
||||||
|
needs: ['feature'],
|
||||||
|
|
||||||
showGeneral: Ember.computed('session.user.name', function () {
|
showGeneral: Ember.computed('session.user.name', function () {
|
||||||
return this.get('session.user.isAuthor') || this.get('session.user.isEditor') ? false : true;
|
return this.get('session.user.isAuthor') || this.get('session.user.isEditor') ? false : true;
|
||||||
}),
|
}),
|
||||||
|
@ -21,6 +23,9 @@ var SettingsController = Ember.Controller.extend({
|
||||||
}),
|
}),
|
||||||
showAbout: Ember.computed('session.user.name', function () {
|
showAbout: Ember.computed('session.user.name', function () {
|
||||||
return this.get('session.user.isAuthor') ? false : true;
|
return this.get('session.user.isAuthor') ? false : true;
|
||||||
|
}),
|
||||||
|
showPassProtection: Ember.computed('session.user.name', 'controllers.feature.passProtectUI', function () {
|
||||||
|
return this.get('session.user.isAuthor') || this.get('session.user.isEditor') || !this.get('controllers.feature.passProtectUI') ? false : true;
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -23,6 +23,16 @@ var LabsController = Ember.Controller.extend(Ember.Evented, {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
usePassProtectUI: Ember.computed('controllers.feature.passProtectUI', function (key, value) {
|
||||||
|
// setter
|
||||||
|
if (arguments.length > 1) {
|
||||||
|
this.saveLabs('passProtectUI', value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// getter
|
||||||
|
return this.get('controllers.feature.passProtectUI');
|
||||||
|
}),
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
onUpload: function (file) {
|
onUpload: function (file) {
|
||||||
var self = this,
|
var self = this,
|
||||||
|
|
27
core/client/app/controllers/settings/pass-protect.js
Normal file
27
core/client/app/controllers/settings/pass-protect.js
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import Ember from 'ember';
|
||||||
|
|
||||||
|
var SettingsPassProtectController = Ember.Controller.extend({
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
save: function () {
|
||||||
|
var self = this;
|
||||||
|
if (this.get('model.isPrivate') && this.get('model.password') === '') {
|
||||||
|
self.notifications.closePassive();
|
||||||
|
self.notifications.showError('Password must have a value.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.get('model').save().then(function (model) {
|
||||||
|
self.notifications.closePassive();
|
||||||
|
self.notifications.showSuccess('Settings successfully saved.');
|
||||||
|
|
||||||
|
return model;
|
||||||
|
}).catch(function (errors) {
|
||||||
|
self.notifications.closePassive();
|
||||||
|
self.notifications.showErrors(errors);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default SettingsPassProtectController;
|
|
@ -19,7 +19,9 @@ var Setting = DS.Model.extend(NProgressSaveMixin, ValidationEngine, {
|
||||||
ghost_head: DS.attr('string'),
|
ghost_head: DS.attr('string'),
|
||||||
ghost_foot: DS.attr('string'),
|
ghost_foot: DS.attr('string'),
|
||||||
labs: DS.attr('string'),
|
labs: DS.attr('string'),
|
||||||
navigation: DS.attr('string')
|
navigation: DS.attr('string'),
|
||||||
|
isPrivate: DS.attr('boolean'),
|
||||||
|
password: DS.attr('string')
|
||||||
});
|
});
|
||||||
|
|
||||||
export default Setting;
|
export default Setting;
|
||||||
|
|
|
@ -43,6 +43,7 @@ Router.map(function () {
|
||||||
this.route('labs');
|
this.route('labs');
|
||||||
this.route('code-injection');
|
this.route('code-injection');
|
||||||
this.route('navigation');
|
this.route('navigation');
|
||||||
|
this.route('pass-protect');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Redirect debug to settings labs
|
// Redirect debug to settings labs
|
||||||
|
|
45
core/client/app/routes/settings/pass-protect.js
Normal file
45
core/client/app/routes/settings/pass-protect.js
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
import AuthenticatedRoute from 'ghost/routes/authenticated';
|
||||||
|
import loadingIndicator from 'ghost/mixins/loading-indicator';
|
||||||
|
import CurrentUserSettings from 'ghost/mixins/current-user-settings';
|
||||||
|
import styleBody from 'ghost/mixins/style-body';
|
||||||
|
|
||||||
|
var SettingsPassProtectRoute = AuthenticatedRoute.extend(styleBody, loadingIndicator, CurrentUserSettings, {
|
||||||
|
|
||||||
|
classNames: ['settings-view-pass'],
|
||||||
|
|
||||||
|
beforeModel: function () {
|
||||||
|
var feature = this.controllerFor('feature'),
|
||||||
|
self = this;
|
||||||
|
|
||||||
|
if (!feature) {
|
||||||
|
this.generateController('feature');
|
||||||
|
feature = this.controllerFor('feature');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.get('session.user')
|
||||||
|
.then(this.transitionAuthor())
|
||||||
|
.then(this.transitionEditor())
|
||||||
|
.then(function () {
|
||||||
|
return feature.then(function () {
|
||||||
|
if (!feature.get('passProtectUI')) {
|
||||||
|
return self.transitionTo('settings.general');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
model: function () {
|
||||||
|
return this.store.find('setting', {type: 'blog,theme'}).then(function (records) {
|
||||||
|
return records.get('firstObject');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
save: function () {
|
||||||
|
this.get('controller').send('save');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
export default SettingsPassProtectRoute;
|
|
@ -27,6 +27,10 @@
|
||||||
{{gh-activating-list-item route="settings.code-injection" title="Code Injection" classNames="settings-nav-code icon-code"}}
|
{{gh-activating-list-item route="settings.code-injection" title="Code Injection" classNames="settings-nav-code icon-code"}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if showPassProtection}}
|
||||||
|
{{gh-activating-list-item route="settings.pass-protect" title="Password Protection" classNames="settings-nav-pass icon-lock"}}
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
{{#if showLabs}}
|
{{#if showLabs}}
|
||||||
{{gh-activating-list-item route="settings.labs" title="Labs" classNames="settings-nav-labs icon-atom"}}
|
{{gh-activating-list-item route="settings.labs" title="Labs" classNames="settings-nav-labs icon-atom"}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
|
@ -44,4 +44,21 @@
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<form>
|
||||||
|
<fieldset>
|
||||||
|
<div class="form-group for-checkbox">
|
||||||
|
<label for="labs-passProtectUI">Password Protection</label>
|
||||||
|
<label class="checkbox" for="labs-passProtectUI">
|
||||||
|
{{input id="labs-passProtectUI" name="labs[passProtectUI]" type="checkbox"
|
||||||
|
checked=usePassProtectUI}}
|
||||||
|
<span class="input-toggle-component"></span>
|
||||||
|
<p>Enable the password protection interface</p>
|
||||||
|
</label>
|
||||||
|
<p>A settings screen which enables you to add password protection to the front of your blog (work in progress)</p>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|
29
core/client/app/templates/settings/pass-protect.hbs
Normal file
29
core/client/app/templates/settings/pass-protect.hbs
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
<header class="settings-view-header">
|
||||||
|
{{#link-to "settings" class="btn btn-default btn-back"}}Back{{/link-to}}
|
||||||
|
<h2 class="page-title">Password protect your blog</h2>
|
||||||
|
<section class="page-actions">
|
||||||
|
<button type="button" class="btn btn-blue" {{action "save"}}>Save</button>
|
||||||
|
</section>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="content settings-pass">
|
||||||
|
<form id="settings-pass" novalidate="novalidate">
|
||||||
|
<fieldset>
|
||||||
|
<div class="form-group for-checkbox">
|
||||||
|
<label for="blog-isPrivate">Make this blog private</label>
|
||||||
|
<label class="checkbox" for="blog-isPrivate">
|
||||||
|
{{input id="blog-isPrivate" name="labs[passProtectUI]" type="checkbox"
|
||||||
|
checked=model.isPrivate}}
|
||||||
|
<span class="input-toggle-component"></span>
|
||||||
|
<p>Enable password protection</p>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{{#if model.isPrivate}}
|
||||||
|
<div class="form-group">
|
||||||
|
{{input name="private[password]" type="text" value=model.password}}
|
||||||
|
<p>This password will be needed to access your blog. All search engine optimization and social features are now disabled.</p>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</section>
|
5
core/client/app/views/settings/pass-protect.js
Normal file
5
core/client/app/views/settings/pass-protect.js
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import BaseView from 'ghost/views/settings/content-base';
|
||||||
|
|
||||||
|
var SettingsGeneralView = BaseView.extend();
|
||||||
|
|
||||||
|
export default SettingsGeneralView;
|
|
@ -10,6 +10,7 @@ var _ = require('lodash'),
|
||||||
function getValidKeys() {
|
function getValidKeys() {
|
||||||
var validKeys = {
|
var validKeys = {
|
||||||
fileStorage: config.fileStorage === false ? false : true,
|
fileStorage: config.fileStorage === false ? false : true,
|
||||||
|
passProtectUI: config.passProtectUI === true ? true : false,
|
||||||
apps: config.apps === true ? true : false,
|
apps: config.apps === true ? true : false,
|
||||||
version: config.ghostVersion,
|
version: config.ghostVersion,
|
||||||
environment: process.env.NODE_ENV,
|
environment: process.env.NODE_ENV,
|
||||||
|
|
|
@ -94,7 +94,7 @@ function urlPathForPost(post, permalinks) {
|
||||||
// Usage:
|
// Usage:
|
||||||
// urlFor('home', true) -> http://my-ghost-blog.com/
|
// urlFor('home', true) -> http://my-ghost-blog.com/
|
||||||
// E.g. /blog/ subdir
|
// E.g. /blog/ subdir
|
||||||
// urlFor({relativeUrl: '/my-static-page/') -> /blog/my-static-page/
|
// urlFor({relativeUrl: '/my-static-page/'}) -> /blog/my-static-page/
|
||||||
// E.g. if post object represents welcome post, and slugs are set to standard
|
// E.g. if post object represents welcome post, and slugs are set to standard
|
||||||
// urlFor('post', {...}) -> /welcome-to-ghost/
|
// urlFor('post', {...}) -> /welcome-to-ghost/
|
||||||
// E.g. if post object represents welcome post, and slugs are set to date
|
// E.g. if post object represents welcome post, and slugs are set to date
|
||||||
|
|
|
@ -14,6 +14,7 @@ var moment = require('moment'),
|
||||||
template = require('../helpers/template'),
|
template = require('../helpers/template'),
|
||||||
errors = require('../errors'),
|
errors = require('../errors'),
|
||||||
routeMatch = require('path-match')(),
|
routeMatch = require('path-match')(),
|
||||||
|
path = require('path'),
|
||||||
|
|
||||||
frontendControllers,
|
frontendControllers,
|
||||||
staticPostPermalink;
|
staticPostPermalink;
|
||||||
|
@ -451,7 +452,23 @@ frontendControllers = {
|
||||||
return handleError(next)(err);
|
return handleError(next)(err);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
rss: rss
|
rss: rss,
|
||||||
|
private: function (req, res) {
|
||||||
|
var defaultPage = path.resolve(config.paths.adminViews, 'password.hbs');
|
||||||
|
return getActiveThemePaths().then(function (paths) {
|
||||||
|
var data = {
|
||||||
|
forward: req.query.r
|
||||||
|
};
|
||||||
|
if (res.error) {
|
||||||
|
data.error = res.error;
|
||||||
|
}
|
||||||
|
if (paths.hasOwnProperty('password.hbs')) {
|
||||||
|
return res.render('password', data);
|
||||||
|
} else {
|
||||||
|
return res.render(defaultPage, data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = frontendControllers;
|
module.exports = frontendControllers;
|
||||||
|
|
|
@ -73,6 +73,12 @@
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"defaultValue": "[{\"label\":\"Home\", \"url\":\"/\"}]"
|
"defaultValue": "[{\"label\":\"Home\", \"url\":\"/\"}]"
|
||||||
|
},
|
||||||
|
"isPrivate": {
|
||||||
|
"defaultValue": "false"
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"defaultValue": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
|
|
|
@ -249,7 +249,6 @@ setupMiddleware = function (blogAppInstance, adminApp) {
|
||||||
|
|
||||||
// Favicon
|
// Favicon
|
||||||
blogApp.use(serveSharedFile('favicon.ico', 'image/x-icon', utils.ONE_DAY_S));
|
blogApp.use(serveSharedFile('favicon.ico', 'image/x-icon', utils.ONE_DAY_S));
|
||||||
blogApp.use(serveSharedFile('sitemap.xsl', 'text/xsl', utils.ONE_DAY_S));
|
|
||||||
|
|
||||||
// Static assets
|
// Static assets
|
||||||
blogApp.use('/shared', express['static'](path.join(corePath, '/shared'), {maxAge: utils.ONE_HOUR_MS}));
|
blogApp.use('/shared', express['static'](path.join(corePath, '/shared'), {maxAge: utils.ONE_HOUR_MS}));
|
||||||
|
@ -274,6 +273,13 @@ setupMiddleware = function (blogAppInstance, adminApp) {
|
||||||
// Theme only config
|
// Theme only config
|
||||||
blogApp.use(middleware.staticTheme());
|
blogApp.use(middleware.staticTheme());
|
||||||
|
|
||||||
|
// Check if password protected blog
|
||||||
|
blogApp.use(middleware.checkIsPrivate); // check if the blog is protected
|
||||||
|
blogApp.use(middleware.filterPrivateRoutes);
|
||||||
|
|
||||||
|
// Serve sitemap.xsl file
|
||||||
|
blogApp.use(serveSharedFile('sitemap.xsl', 'text/xsl', utils.ONE_DAY_S));
|
||||||
|
|
||||||
// Serve robots.txt if not found in theme
|
// Serve robots.txt if not found in theme
|
||||||
blogApp.use(serveSharedFile('robots.txt', 'text/plain', utils.ONE_HOUR_S));
|
blogApp.use(serveSharedFile('robots.txt', 'text/plain', utils.ONE_HOUR_S));
|
||||||
|
|
||||||
|
@ -315,7 +321,7 @@ setupMiddleware = function (blogAppInstance, adminApp) {
|
||||||
blogApp.use('/ghost', adminApp);
|
blogApp.use('/ghost', adminApp);
|
||||||
|
|
||||||
// Set up Frontend routes
|
// Set up Frontend routes
|
||||||
blogApp.use(routes.frontend());
|
blogApp.use(routes.frontend(middleware));
|
||||||
|
|
||||||
// ### Error handling
|
// ### Error handling
|
||||||
// 404 Handler
|
// 404 Handler
|
||||||
|
|
|
@ -3,13 +3,17 @@
|
||||||
// middleware_spec.js
|
// middleware_spec.js
|
||||||
|
|
||||||
var _ = require('lodash'),
|
var _ = require('lodash'),
|
||||||
|
fs = require('fs'),
|
||||||
express = require('express'),
|
express = require('express'),
|
||||||
|
bcrypt = require('bcryptjs'),
|
||||||
busboy = require('./ghost-busboy'),
|
busboy = require('./ghost-busboy'),
|
||||||
config = require('../config'),
|
config = require('../config'),
|
||||||
path = require('path'),
|
path = require('path'),
|
||||||
api = require('../api'),
|
api = require('../api'),
|
||||||
passport = require('passport'),
|
passport = require('passport'),
|
||||||
|
Promise = require('bluebird'),
|
||||||
errors = require('../errors'),
|
errors = require('../errors'),
|
||||||
|
session = require('cookie-session'),
|
||||||
url = require('url'),
|
url = require('url'),
|
||||||
utils = require('../utils'),
|
utils = require('../utils'),
|
||||||
|
|
||||||
|
@ -17,7 +21,8 @@ var _ = require('lodash'),
|
||||||
blogApp,
|
blogApp,
|
||||||
oauthServer,
|
oauthServer,
|
||||||
loginSecurity = [],
|
loginSecurity = [],
|
||||||
forgottenSecurity = [];
|
forgottenSecurity = [],
|
||||||
|
protectedSecurity = [];
|
||||||
|
|
||||||
function isBlackListedFileType(file) {
|
function isBlackListedFileType(file) {
|
||||||
var blackListedFileTypes = ['.hbs', '.md', '.json'],
|
var blackListedFileTypes = ['.hbs', '.md', '.json'],
|
||||||
|
@ -76,6 +81,17 @@ function sslForbiddenOrRedirect(opt) {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function verifySessionHash(hash) {
|
||||||
|
if (!hash) {
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
var bcryptCompare = Promise.promisify(bcrypt.compare);
|
||||||
|
return api.settings.read({context: {internal: true}, key: 'password'}).then(function (response) {
|
||||||
|
var pass = response.settings[0].value;
|
||||||
|
return bcryptCompare(pass, hash);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
middleware = {
|
middleware = {
|
||||||
|
|
||||||
// ### Authenticate Middleware
|
// ### Authenticate Middleware
|
||||||
|
@ -325,6 +341,132 @@ middleware = {
|
||||||
next();
|
next();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
checkIsPrivate: function (req, res, next) {
|
||||||
|
return api.settings.read({context: {internal: true}, key: 'isPrivate'}).then(function (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,
|
||||||
|
keys: ['isPrivateBlog']
|
||||||
|
})(req, res, next);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
filterPrivateRoutes: function (req, res, next) {
|
||||||
|
if (res.isAdmin || !res.isPrivateBlog || req.url.lastIndexOf('/private/', 0) === 0) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
if (req.url.lastIndexOf('/rss', 0) === 0 || req.url.lastIndexOf('/sitemap', 0) === 0) { // take care of rss and sitemap 404's
|
||||||
|
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 (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 (req, res, next) {
|
||||||
|
var clientHash = req.session.token || '';
|
||||||
|
return verifySessionHash(clientHash).then(function (isVerified) {
|
||||||
|
if (isVerified) {
|
||||||
|
return next();
|
||||||
|
} else {
|
||||||
|
return res.redirect(config.urlFor({relativeUrl: '/private/'}) + '?r=' + encodeURI(req.url));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// This is here so a call to /private/ after a session is verified will redirect to home;
|
||||||
|
isPrivateSessionAuth: function (req, res, next) {
|
||||||
|
if (!res.isPrivateBlog) {
|
||||||
|
return res.redirect(config.urlFor('home', true));
|
||||||
|
}
|
||||||
|
var hash = req.session.token || '';
|
||||||
|
return verifySessionHash(hash).then(function (isVerified) {
|
||||||
|
if (isVerified) {
|
||||||
|
return res.redirect(config.urlFor('home', true)); // redirect to home if user is already authenticated
|
||||||
|
} else {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
spamProtectedPrevention: function (req, res, next) {
|
||||||
|
var currentTime = process.hrtime()[0],
|
||||||
|
remoteAddress = req.connection.remoteAddress,
|
||||||
|
rateProtectedPeriod = config.rateProtectedPeriod || 3600,
|
||||||
|
rateProtectedAttempts = config.rateProtectedAttempts || 10,
|
||||||
|
ipCount = '',
|
||||||
|
message = 'Too many attempts.',
|
||||||
|
deniedRateLimit = '',
|
||||||
|
password = req.body.password;
|
||||||
|
|
||||||
|
if (password) {
|
||||||
|
protectedSecurity.push({ip: remoteAddress, time: currentTime});
|
||||||
|
} else {
|
||||||
|
res.error = {
|
||||||
|
message: 'No password entered'
|
||||||
|
};
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// filter entries that are older than rateProtectedPeriod
|
||||||
|
protectedSecurity = _.filter(protectedSecurity, function (logTime) {
|
||||||
|
return (logTime.time + rateProtectedPeriod > currentTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipCount = _.chain(protectedSecurity).countBy('ip').value();
|
||||||
|
deniedRateLimit = (ipCount[remoteAddress] > rateProtectedAttempts);
|
||||||
|
|
||||||
|
if (deniedRateLimit) {
|
||||||
|
errors.logError(
|
||||||
|
'Only ' + rateProtectedAttempts + ' tries per IP address every ' + rateProtectedPeriod + ' seconds.',
|
||||||
|
'Too many login attempts.'
|
||||||
|
);
|
||||||
|
message += rateProtectedPeriod === 3600 ? ' Please wait 1 hour.' : ' Please try again later';
|
||||||
|
res.error = {
|
||||||
|
message: message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
},
|
||||||
|
|
||||||
|
authenticateProtection: function (req, res, next) {
|
||||||
|
if (res.error) { // if errors have been generated from the previous call
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
var bodyPass = req.body.password,
|
||||||
|
bcryptHash = Promise.promisify(bcrypt.hash);
|
||||||
|
return api.settings.read({context: {internal: true}, key: 'password'}).then(function (response) {
|
||||||
|
var pass = response.settings[0];
|
||||||
|
if (pass.value === bodyPass) {
|
||||||
|
return bcryptHash(pass.value, 10).then(function (hash) {
|
||||||
|
req.session.token = hash;
|
||||||
|
return res.redirect(config.urlFor({relativeUrl: decodeURI(req.body.forward)}));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.error = {
|
||||||
|
message: 'Wrong password'
|
||||||
|
};
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
busboy: busboy
|
busboy: busboy
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@ var frontend = require('../controllers/frontend'),
|
||||||
|
|
||||||
frontendRoutes;
|
frontendRoutes;
|
||||||
|
|
||||||
frontendRoutes = function () {
|
frontendRoutes = function (middleware) {
|
||||||
var router = express.Router(),
|
var router = express.Router(),
|
||||||
subdir = config.paths.subdir,
|
subdir = config.paths.subdir,
|
||||||
routeKeywords = config.routeKeywords;
|
routeKeywords = config.routeKeywords;
|
||||||
|
@ -28,6 +28,18 @@ frontendRoutes = function () {
|
||||||
res.redirect(subdir + '/ghost/');
|
res.redirect(subdir + '/ghost/');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// password-protected frontend route
|
||||||
|
router.get('/private/',
|
||||||
|
middleware.isPrivateSessionAuth,
|
||||||
|
frontend.private
|
||||||
|
);
|
||||||
|
router.post('/private/',
|
||||||
|
middleware.isPrivateSessionAuth,
|
||||||
|
middleware.spamProtectedPrevention,
|
||||||
|
middleware.authenticateProtection,
|
||||||
|
frontend.private
|
||||||
|
);
|
||||||
|
|
||||||
// ### Frontend routes
|
// ### Frontend routes
|
||||||
router.get('/rss/', frontend.rss);
|
router.get('/rss/', frontend.rss);
|
||||||
router.get('/rss/:page/', frontend.rss);
|
router.get('/rss/:page/', frontend.rss);
|
||||||
|
|
|
@ -24,6 +24,7 @@ utils = {
|
||||||
ONE_YEAR_S: 31536000,
|
ONE_YEAR_S: 31536000,
|
||||||
ONE_HOUR_MS: 3600000,
|
ONE_HOUR_MS: 3600000,
|
||||||
ONE_DAY_MS: 86400000,
|
ONE_DAY_MS: 86400000,
|
||||||
|
ONE_MONTH_MS: 2628000000,
|
||||||
ONE_YEAR_MS: 31536000000,
|
ONE_YEAR_MS: 31536000000,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
56
core/server/views/password.hbs
Normal file
56
core/server/views/password.hbs
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
<!doctype html>
|
||||||
|
<!--[if (IE 8)&!(IEMobile)]><html class="no-js lt-ie9" lang="en"><![endif]-->
|
||||||
|
<!--[if (gte IE 9)| IEMobile |!(IE)]><!--><html class="no-js" lang="en"><!--<![endif]-->
|
||||||
|
<head>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html" charset="UTF-8" />
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
|
||||||
|
|
||||||
|
<title>Ghost - Private Blog Access</title>
|
||||||
|
|
||||||
|
<meta name="description" content="{{siteDescription}}">
|
||||||
|
<meta name="author" content="">
|
||||||
|
<meta name="HandheldFriendly" content="True">
|
||||||
|
<meta name="MobileOptimized" content="320">
|
||||||
|
<meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
|
||||||
|
<link rel="shortcut icon" href="{{asset "favicon.ico"}}">
|
||||||
|
<meta http-equiv="cleartype" content="on">
|
||||||
|
|
||||||
|
<link rel="stylesheet" type='text/css' href='//fonts.googleapis.com/css?family=Open+Sans:400,300,700'>
|
||||||
|
<link rel="stylesheet" href="{{asset "ghost.css" ghost="true" minifyInProduction="true"}}" />
|
||||||
|
</head>
|
||||||
|
<body class="ghost-setup">
|
||||||
|
<main role="main" id="main" class="viewport">
|
||||||
|
<section class="setup-box js-setup-box fade-in">
|
||||||
|
<div class="vertical">
|
||||||
|
<form id="setup" class="setup-form" method="post" novalidate="novalidate">
|
||||||
|
<header>
|
||||||
|
<h1>This blog is private</h1>
|
||||||
|
</header>
|
||||||
|
<input type="hidden" name="forward" value="{{forward}}">
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="text" name="password" autofocus="autofocus" class="icon-lock">
|
||||||
|
</div>
|
||||||
|
<footer>
|
||||||
|
<button type="submit" class="btn btn-green btn-lg">
|
||||||
|
Enter
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{{#if error}}
|
||||||
|
<aside class="notification bottom">
|
||||||
|
<div class="js-bb-notification">
|
||||||
|
<section class="js-notification notification-error">
|
||||||
|
<span class="notification-message">{{error.message}}</span>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
{{/if}}
|
||||||
|
<script src="{{asset "ghost.js" ghost="true" minifyInProduction="true"}}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
2
core/shared/private-robots.txt
Normal file
2
core/shared/private-robots.txt
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
User-agent: *
|
||||||
|
Disallow: /
|
|
@ -1438,4 +1438,79 @@ describe('Frontend Controller', function () {
|
||||||
}).catch(done);
|
}).catch(done);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('private', function () {
|
||||||
|
var req, res, config, defaultPath;
|
||||||
|
|
||||||
|
defaultPath = '/core/server/views/password.hbs';
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
res = {
|
||||||
|
locals: {verson: ''},
|
||||||
|
render: sandbox.spy()
|
||||||
|
},
|
||||||
|
req = {
|
||||||
|
query: {
|
||||||
|
r: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
config = {
|
||||||
|
paths: {
|
||||||
|
adminViews: '/core/server/views',
|
||||||
|
availableThemes: {
|
||||||
|
casper: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
apiSettingsStub = sandbox.stub(api.settings, 'read');
|
||||||
|
apiSettingsStub.withArgs(sinon.match.has('key', 'activeTheme')).returns(Promise.resolve({
|
||||||
|
settings: [{
|
||||||
|
key: 'activeTheme',
|
||||||
|
value: 'casper'
|
||||||
|
}]
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should render default password page when theme has no password template', function (done) {
|
||||||
|
frontend.__set__('config', config);
|
||||||
|
|
||||||
|
frontend.private(req, res, done).then(function () {
|
||||||
|
res.render.calledWith(defaultPath).should.be.true;
|
||||||
|
done();
|
||||||
|
}).catch(done);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should render theme password page when it exists', function (done) {
|
||||||
|
config.paths.availableThemes.casper = {
|
||||||
|
'password.hbs': '/content/themes/casper/password.hbs'
|
||||||
|
};
|
||||||
|
frontend.__set__('config', config);
|
||||||
|
|
||||||
|
frontend.private(req, res, done).then(function () {
|
||||||
|
res.render.calledWith('password').should.be.true;
|
||||||
|
done();
|
||||||
|
}).catch(done);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should render with forward data when it is passed in', function (done) {
|
||||||
|
frontend.__set__('config', config);
|
||||||
|
req.query.r = '/test-redirect/';
|
||||||
|
|
||||||
|
frontend.private(req, res, done).then(function () {
|
||||||
|
res.render.calledWith(defaultPath, {forward: '/test-redirect/'}).should.be.true;
|
||||||
|
done();
|
||||||
|
}).catch(done);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should render with error when error is passed in', function (done) {
|
||||||
|
frontend.__set__('config', config);
|
||||||
|
res.error = 'Test Error';
|
||||||
|
|
||||||
|
frontend.private(req, res, done).then(function () {
|
||||||
|
res.render.calledWith(defaultPath, {forward: '', error: 'Test Error'}).should.be.true;
|
||||||
|
done();
|
||||||
|
}).catch(done);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -3,9 +3,24 @@
|
||||||
var assert = require('assert'),
|
var assert = require('assert'),
|
||||||
should = require('should'),
|
should = require('should'),
|
||||||
sinon = require('sinon'),
|
sinon = require('sinon'),
|
||||||
middleware = require('../../server/middleware').middleware;
|
Promise = require('bluebird'),
|
||||||
|
middleware = require('../../server/middleware').middleware,
|
||||||
|
api = require('../../server/api'),
|
||||||
|
errors = require('../../server/errors'),
|
||||||
|
bcrypt = require('bcryptjs'),
|
||||||
|
fs = require('fs');
|
||||||
|
|
||||||
describe('Middleware', function () {
|
describe('Middleware', function () {
|
||||||
|
var sandbox,
|
||||||
|
apiSettingsStub;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
sandbox = sinon.sandbox.create();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(function () {
|
||||||
|
sandbox.restore();
|
||||||
|
});
|
||||||
// TODO: needs new test for ember admin
|
// TODO: needs new test for ember admin
|
||||||
// describe('redirectToDashboard', function () {
|
// describe('redirectToDashboard', function () {
|
||||||
// var req, res;
|
// var req, res;
|
||||||
|
@ -236,4 +251,209 @@ describe('Middleware', function () {
|
||||||
response.redirectUrl({a: 'b'}).should.equal('https://example.com/ssl/config/path/req/path?a=b');
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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 () {
|
||||||
|
sandbox.stub(bcrypt, 'compare', function (check, hash, cb) {
|
||||||
|
var isVerified = (check === hash) ? true : false;
|
||||||
|
cb(null, isVerified);
|
||||||
|
});
|
||||||
|
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) {
|
||||||
|
req.session = {
|
||||||
|
token: 'rightpassword'
|
||||||
|
};
|
||||||
|
middleware.authenticatePrivateSession(req, res, next).then(function () {
|
||||||
|
next.called.should.be.true;
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('authenticatePrivateSession should redirect if hash is not verified', function (done) {
|
||||||
|
req.url = '/welcome-to-ghost';
|
||||||
|
req.session = {
|
||||||
|
token: 'wrongpassword'
|
||||||
|
};
|
||||||
|
res.redirect = sinon.spy();
|
||||||
|
middleware.authenticatePrivateSession(req, res, next).then(function () {
|
||||||
|
res.redirect.called.should.be.true;
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isPrivateSessionAuth should redirect if hash is verified', function (done) {
|
||||||
|
req.session = {
|
||||||
|
token: 'rightpassword'
|
||||||
|
};
|
||||||
|
res.redirect = sandbox.spy();
|
||||||
|
middleware.isPrivateSessionAuth(req, res, next).then(function () {
|
||||||
|
res.redirect.called.should.be.true;
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isPrivateSessionAuth should return next if hash is not verified', function (done) {
|
||||||
|
req.session = {
|
||||||
|
token: 'wrongpassword'
|
||||||
|
};
|
||||||
|
middleware.isPrivateSessionAuth(req, res, next).then(function () {
|
||||||
|
next.called.should.be.true;
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('authenticateProtection should redirect if password is correct', function (done) {
|
||||||
|
req.body = {password:'rightpassword'};
|
||||||
|
req.session = {};
|
||||||
|
res.redirect = sandbox.spy();
|
||||||
|
sandbox.stub(bcrypt, 'hash', function (pass, salt, cb) {
|
||||||
|
cb(null, pass + 'hash');
|
||||||
|
});
|
||||||
|
middleware.authenticateProtection(req, res, next).then(function () {
|
||||||
|
req.session.token.should.equal('rightpasswordhash');
|
||||||
|
res.redirect.called.should.be.true;
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -31,7 +31,7 @@
|
||||||
"iojs": "~1.2.0"
|
"iojs": "~1.2.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "2.0.2",
|
"bcryptjs": "2.1.0",
|
||||||
"bluebird": "2.4.2",
|
"bluebird": "2.4.2",
|
||||||
"body-parser": "1.10.0",
|
"body-parser": "1.10.0",
|
||||||
"bookshelf": "0.7.9",
|
"bookshelf": "0.7.9",
|
||||||
|
@ -40,6 +40,7 @@
|
||||||
"colors": "0.6.2",
|
"colors": "0.6.2",
|
||||||
"compression": "1.2.2",
|
"compression": "1.2.2",
|
||||||
"connect-slashes": "1.3.0",
|
"connect-slashes": "1.3.0",
|
||||||
|
"cookie-session": "^1.1.0",
|
||||||
"downsize": "0.0.8",
|
"downsize": "0.0.8",
|
||||||
"express": "4.12.0",
|
"express": "4.12.0",
|
||||||
"express-hbs": "0.8.4",
|
"express-hbs": "0.8.4",
|
||||||
|
|
Loading…
Add table
Reference in a new issue