diff --git a/core/server/api/authentication.js b/core/server/api/authentication.js index e73576b1cd..fc5aef900a 100644 --- a/core/server/api/authentication.js +++ b/core/server/api/authentication.js @@ -127,8 +127,17 @@ authentication = { }, isSetup: function () { - return dataProvider.User.findOne({status: 'active'}).then(function (user) { - if (user) { + + return dataProvider.User.query(function (qb) { + qb.where('status', '=', 'active') + .orWhere('status', '=', 'warn-1') + .orWhere('status', '=', 'warn-2') + .orWhere('status', '=', 'warn-3') + .orWhere('status', '=', 'warn-4') + .orWhere('status', '=', 'locked'); + }).fetch().then(function (users) { + + if (users) { return when.resolve({ setup: [{status: true}]}); } else { return when.resolve({ setup: [{status: false}]}); diff --git a/core/server/errors/index.js b/core/server/errors/index.js index 75205dc3d6..aaed368efb 100644 --- a/core/server/errors/index.js +++ b/core/server/errors/index.js @@ -245,7 +245,15 @@ errors = { } errors.renderErrorPage(err.status || 500, err, req, res, next); } else { - res.send(err.status || 500, err); + // generate a valid JSON response + var statusCode = 500, + errorContent = {}; + + statusCode = err.code || 500; + + errorContent.message = _.isString(err) ? err : (_.isObject(err) ? err.message : 'Unknown Error'); + errorContent.type = err.type || 'InternalServerError'; + res.json(statusCode, errorContent); } } }; diff --git a/core/server/middleware/middleware.js b/core/server/middleware/middleware.js index d6fb889b9c..c8bc7b1bdf 100644 --- a/core/server/middleware/middleware.js +++ b/core/server/middleware/middleware.js @@ -9,11 +9,13 @@ var _ = require('lodash'), path = require('path'), api = require('../api'), passport = require('passport'), + errors = require('../errors'), expressServer, oauthServer, ONE_HOUR_MS = 60 * 60 * 1000, - ONE_YEAR_MS = 365 * 24 * ONE_HOUR_MS; + ONE_YEAR_MS = 365 * 24 * ONE_HOUR_MS, + loginSecurity = []; function isBlackListedFileType(file) { var blackListedFileTypes = ['.hbs', '.md', '.json'], @@ -149,6 +151,31 @@ var middleware = { }); }, + // ### Spam prevention Middleware + // limit signin requests to one every two seconds + spamPrevention: function (req, res, next) { + var currentTime = process.hrtime()[0], + remoteAddress = req.connection.remoteAddress, + denied = ''; + + // filter for IPs that tried to login in the last 2 sec + loginSecurity = _.filter(loginSecurity, function (logTime) { + return (logTime.time + 2 > currentTime); + }); + + // check if IP tried to login in the last 2 sec + denied = _.find(loginSecurity, function (logTime) { + return (logTime.ip === remoteAddress); + }); + + if (!denied || expressServer.get('disableLoginLimiter') === true) { + loginSecurity.push({ip: remoteAddress, time: currentTime}); + next(); + } else { + return next(new errors.UnauthorizedError('Slow down, there are way too many login attempts!')); + } + }, + // work around to handle missing client_secret // oauth2orize needs it, but untrusted clients don't have it addClientSecret: function (req, res, next) { @@ -157,9 +184,15 @@ var middleware = { } next(); }, + + // ### Authenticate Client Middleware + // authenticate client that is asking for an access token authenticateClient: function (req, res, next) { return passport.authenticate(['oauth2-client-password'], { session: false })(req, res, next); }, + + // ### Generate access token Middleware + // register the oauth2orize middleware for password and refresh token grants generateAccessToken: function (req, res, next) { return oauthServer.token()(req, res, next); }, diff --git a/core/server/middleware/oauth.js b/core/server/middleware/oauth.js index 5695c2257b..c97dd48d5e 100644 --- a/core/server/middleware/oauth.js +++ b/core/server/middleware/oauth.js @@ -43,8 +43,8 @@ oauth = { }).catch(function () { return done(null, false); }); - }).catch(function () { - return done(null, false); + }).catch(function (error) { + return done(error); }); }); })); diff --git a/core/server/models/user.js b/core/server/models/user.js index 39585ab25a..250dd0c19c 100644 --- a/core/server/models/user.js +++ b/core/server/models/user.js @@ -270,15 +270,16 @@ User = ghostBookshelf.Model.extend({ var self = this, s; return this.getByEmail(object.email).then(function (user) { - if (!user || user.get('status') === 'invited' || user.get('status') === 'inactive') { - return when.reject(new Error('NotFound')); + if (!user || user.get('status') === 'invited' || user.get('status') === 'invited-pending' + || user.get('status') === 'inactive') { + return when.reject(new errors.NotFoundError('NotFound')); } if (user.get('status') !== 'locked') { return nodefn.call(bcrypt.compare, object.password, user.get('password')).then(function (matched) { if (!matched) { return when(self.setWarning(user)).then(function (remaining) { s = (remaining > 1) ? 's' : ''; - return when.reject(new Error('Your password is incorrect.
' + + return when.reject(new errors.UnauthorizedError('Your password is incorrect.
' + remaining + ' attempt' + s + ' remaining!')); }); } @@ -288,7 +289,7 @@ User = ghostBookshelf.Model.extend({ }); }, errors.logAndThrowError); } - return when.reject(new Error('Your account is locked due to too many ' + + return when.reject(new errors.NoPermissionError('Your account is locked due to too many ' + 'login attempts. Please reset your password to log in again by clicking ' + 'the "Forgotten password?" link!')); diff --git a/core/server/routes/api.js b/core/server/routes/api.js index 07f2f88e53..4d549c5566 100644 --- a/core/server/routes/api.js +++ b/core/server/routes/api.js @@ -73,6 +73,7 @@ apiRoutes = function (middleware) { router.post('/authentication/setup', api.http(api.authentication.setup)); router.get('/authentication/setup', api.http(api.authentication.isSetup)); router.post('/authentication/token', + middleware.spamPrevention, middleware.addClientSecret, middleware.authenticateClient, middleware.generateAccessToken diff --git a/core/test/functional/client/signin_test.js b/core/test/functional/client/signin_test.js index 687263ec4f..5d0b5b4b3c 100644 --- a/core/test/functional/client/signin_test.js +++ b/core/test/functional/client/signin_test.js @@ -3,38 +3,8 @@ /*globals CasperTest, casper, url, newUser, user, falseUser */ -// TODO fix signup vs setup testing -//CasperTest.begin('Ensure a User is Registered', 3, function suite(test) { -// casper.thenOpenAndWaitForPageLoad('signup', function checkUrl() { -// test.assertUrlMatch(/ghost\/signup\/$/, 'Landed on the correct URL'); -// }); -// -// casper.waitForOpaque('.signup-box', -// function then() { -// this.fillAndSave('#signup', newUser); -// }, -// function onTimeout() { -// test.fail('Sign up form didn\'t fade in.'); -// }); -// -// casper.captureScreenshot('login_register_test.png'); -// -// casper.waitForSelectorTextChange('.notification-error', function onSuccess() { -// test.assertSelectorHasText('.notification-error', 'already registered'); -// // If the previous assert succeeds, then we should skip the next check and just pass. -// casper.echoConcise('Already registered!'); -// casper.captureScreenshot('already_registered.png'); -// }, function onTimeout() { -// test.assertUrlMatch(/ghost\/\d+\/$/, 'If we\'re not already registered, we should be logged in.'); -// casper.echoConcise('Successfully registered.'); -// }, 2000); -// -// casper.thenOpenAndWaitForPageLoad('signout', function then() { -// test.assertUrlMatch(/ghost\/signin/, 'We got redirected to signin page.'); -// }); -//}, true); - CasperTest.begin('Ghost admin will load login page', 3, function suite(test) { + CasperTest.Routines.signout.run(test); casper.thenOpenAndWaitForPageLoad('signin', function testTitleAndUrl() { test.assertTitle('Ghost Admin', 'Ghost admin has no title'); test.assertUrlMatch(/ghost\/signin\/$/, 'We should be presented with the signin page.'); @@ -48,84 +18,90 @@ CasperTest.begin('Ghost admin will load login page', 3, function suite(test) { test.assert(link === '/ghost/forgotten/', 'Has correct forgotten password link'); }); }); -}, true); +}); // Note, this test applies to a global redirect, which sends us to the standard admin. // Once Ember becomes the standard admin, this test should still pass. CasperTest.begin('Redirects login to signin', 2, function suite(test) { + CasperTest.Routines.signout.run(test); casper.start(url + 'ghost/login/', function testRedirect(response) { test.assertEqual(response.status, 200, 'Response status should be 200.'); test.assertUrlMatch(/ghost\/signin\//, 'Should be redirected to /signin/.'); }); -}, true); - -// TODO: please uncomment when the spam prevention bug is fixed (https://github.com/TryGhost/Ghost/issues/3128) -// CasperTest.begin('Can\'t spam it', 4, function suite(test) { -// casper.thenOpenAndWaitForPageLoad('signin', function testTitle() { -// test.assertTitle('Ghost Admin', 'Ghost admin has no title'); -// test.assertUrlMatch(/ghost\/signin\/$/, 'Landed on the correct URL'); -// }); - -// casper.waitForOpaque('.login-box', -// function then() { -// this.fillAndSave('#login', falseUser); -// }, -// function onTimeout() { -// test.fail('Sign in form didn\'t fade in.'); -// }); +}); -// casper.captureScreenshot('login_spam_test.png'); +CasperTest.begin('Can\'t spam it', 4, function suite(test) { + CasperTest.Routines.signout.run(test); -// casper.waitForText('attempts remaining!', function then() { -// this.fillAndSave('#login', falseUser); -// }); + casper.thenOpenAndWaitForPageLoad('signin', function testTitle() { + test.assertTitle('Ghost Admin', 'Ghost admin has no title'); + test.assertUrlMatch(/ghost\/signin\/$/, 'Landed on the correct URL'); + }); -// casper.captureScreenshot('login_spam_test2.png'); + casper.waitForOpaque('.login-box', + function then() { + this.fillAndSave('#login', falseUser); + }, + function onTimeout() { + test.fail('Sign in form didn\'t fade in.'); + }); -// casper.waitForText('Slow down, there are way too many login attempts!', function onSuccess() { -// test.assert(true, 'Spamming the login did result in an error notification'); -// test.assertSelectorDoesntHaveText('.notification-error', '[object Object]'); -// }, function onTimeout() { -// test.assert(false, 'Spamming the login did not result in an error notification'); -// }); -// // This test causes the spam notification -// // add a wait to ensure future tests don't get tripped up by this. -// casper.wait(2000); -// }, true); + casper.captureScreenshot('login_spam_test.png'); -// TODO: please uncomment when the spam prevention bug is fixed (https://github.com/TryGhost/Ghost/issues/3128) -// CasperTest.begin('Login limit is in place', 4, function suite(test) { -// casper.thenOpenAndWaitForPageLoad('signin', function testTitleAndUrl() { -// test.assertTitle('Ghost Admin', 'Ghost admin has no title'); -// test.assertUrlMatch(/ghost\/signin\/$/, 'Landed on the correct URL'); -// }); + casper.waitForText('attempts remaining!', function then() { + this.fillAndSave('#login', falseUser); + }); -// casper.waitForOpaque('.login-box', -// function then() { -// this.fillAndSave('#login', falseUser); -// }, -// function onTimeout() { -// test.fail('Sign in form didn\'t fade in.'); -// }); + casper.captureScreenshot('login_spam_test2.png'); -// casper.wait(2100, function doneWait() { -// this.fillAndSave('#login', falseUser); -// }); + casper.waitForText('Slow down, there are way too many login attempts!', function onSuccess() { + test.assert(true, 'Spamming the login did result in an error notification'); + test.assertSelectorDoesntHaveText('.notification-error', '[object Object]'); + }, function onTimeout() { + test.assert(false, 'Spamming the login did not result in an error notification'); + }); -// casper.waitForText('remaining', function onSuccess() { -// test.assert(true, 'The login limit is in place.'); -// test.assertSelectorDoesntHaveText('.notification-error', '[object Object]'); -// }, function onTimeout() { -// test.assert(false, 'We did not trip the login limit.'); -// }); -// // This test used login, add a wait to -// // ensure future tests don't get tripped up by this. -// casper.wait(2000); -// }, true); + // This test causes the spam notification + // add a wait to ensure future tests don't get tripped up by this. + casper.wait(2000); +}); + +CasperTest.begin('Login limit is in place', 4, function suite(test) { + CasperTest.Routines.signout.run(test); + + casper.thenOpenAndWaitForPageLoad('signin', function testTitleAndUrl() { + test.assertTitle('Ghost Admin', 'Ghost admin has no title'); + test.assertUrlMatch(/ghost\/signin\/$/, 'Landed on the correct URL'); + }); + + casper.waitForOpaque('.login-box', + function then() { + this.fillAndSave('#login', falseUser); + }, + function onTimeout() { + test.fail('Sign in form didn\'t fade in.'); + }); + + casper.wait(2100, function doneWait() { + this.fillAndSave('#login', falseUser); + }); + + casper.waitForText('remaining', function onSuccess() { + test.assert(true, 'The login limit is in place.'); + test.assertSelectorDoesntHaveText('.notification-error', '[object Object]'); + }, function onTimeout() { + test.assert(false, 'We did not trip the login limit.'); + }); + // This test used login, add a wait to + // ensure future tests don't get tripped up by this. + casper.wait(2000); +}); CasperTest.begin('Can login to Ghost', 5, function suite(test) { + CasperTest.Routines.signout.run(test); + casper.thenOpenAndWaitForPageLoad('signin', function testTitleAndUrl() { test.assertTitle('Ghost Admin', 'Ghost admin has no title'); test.assertUrlMatch(/ghost\/signin\/$/, 'Landed on the correct URL'); @@ -144,9 +120,11 @@ CasperTest.begin('Can login to Ghost', 5, function suite(test) { }, function onTimeOut() { test.fail('Failed to signin'); }); -}, true); +}); CasperTest.begin('Authenticated user is redirected', 8, function suite(test) { + CasperTest.Routines.signout.run(test); + casper.thenOpenAndWaitForPageLoad('signin', function testTitleAndUrl() { test.assertTitle('Ghost Admin', 'Ghost admin has no title'); test.assertUrlMatch(/ghost\/signin\/$/, 'Landed on the correct URL'); @@ -173,29 +151,31 @@ CasperTest.begin('Authenticated user is redirected', 8, function suite(test) { }, function onTimeOut() { test.fail('Failed to redirect'); }); -}, true); +}); -// TODO: please uncomment when the validation problem is fixed (https://github.com/TryGhost/Ghost/issues/3120) -// CasperTest.begin('Ensure email field form validation', 3, function suite(test) { -// casper.thenOpenAndWaitForPageLoad('signin', function testTitleAndUrl() { -// test.assertTitle('Ghost Admin', 'Ghost admin has no title'); -// test.assertUrlMatch(/ghost\/signin\/$/, 'Landed on the correct URL'); -// }); -// casper.waitForOpaque('.js-login-box', -// function then() { -// this.fillAndSave('form.login-form', { -// 'email': 'notanemail' -// }); -// }, -// function onTimeout() { -// test.fail('Login form didn\'t fade in.'); -// }); +CasperTest.begin('Ensure email field form validation', 3, function suite(test) { + CasperTest.Routines.signout.run(test); -// casper.waitForSelectorTextChange('.notification-error', function onSuccess() { -// test.assertSelectorHasText('.notification-error', 'Invalid Email'); -// }, function onTimeout() { -// test.fail('Email validation error did not appear'); -// }, 2000); + casper.thenOpenAndWaitForPageLoad('signin', function testTitleAndUrl() { + test.assertTitle('Ghost Admin', 'Ghost admin has no title'); + test.assertUrlMatch(/ghost\/signin\/$/, 'Landed on the correct URL'); + }); -// }, true); + casper.waitForOpaque('.js-login-box', + function then() { + this.fillAndSave('form.login-form', { + 'identification': 'notanemail' + }); + }, + function onTimeout() { + test.fail('Login form didn\'t fade in.'); + }); + + casper.waitForSelectorTextChange('.notification-error', function onSuccess() { + test.assertSelectorHasText('.notification-error', 'Invalid Email'); + }, function onTimeout() { + test.fail('Email validation error did not appear'); + }, 2000); + +}); diff --git a/core/test/functional/routes/api/db_test.js b/core/test/functional/routes/api/db_test.js index 99cdc7de37..a98263af93 100644 --- a/core/test/functional/routes/api/db_test.js +++ b/core/test/functional/routes/api/db_test.js @@ -16,6 +16,7 @@ describe('DB API', function () { before(function (done) { var app = express(); + app.set('disableLoginLimiter', true); ghost({app: app}).then(function (_httpServer) { httpServer = _httpServer; diff --git a/core/test/functional/routes/api/error_test.js b/core/test/functional/routes/api/error_test.js index a084454255..332965f843 100644 --- a/core/test/functional/routes/api/error_test.js +++ b/core/test/functional/routes/api/error_test.js @@ -41,7 +41,7 @@ describe('Unauthorized', function () { }); - describe('Unauthorized', function () { + describe('Unauthorized API', function () { it('can\'t retrieve posts', function (done) { request.get(testUtils.API.getApiQuery('posts/')) .expect(401) diff --git a/core/test/functional/routes/api/notifications_test.js b/core/test/functional/routes/api/notifications_test.js index 14f426bb2c..812bd1403f 100644 --- a/core/test/functional/routes/api/notifications_test.js +++ b/core/test/functional/routes/api/notifications_test.js @@ -16,6 +16,7 @@ describe('Notifications API', function () { before(function (done) { var app = express(); + app.set('disableLoginLimiter', true); ghost({app: app}).then(function (_httpServer) { httpServer = _httpServer; diff --git a/core/test/functional/routes/api/posts_test.js b/core/test/functional/routes/api/posts_test.js index c3996406c9..904e68a46a 100644 --- a/core/test/functional/routes/api/posts_test.js +++ b/core/test/functional/routes/api/posts_test.js @@ -18,6 +18,7 @@ describe('Post API', function () { before(function (done) { var app = express(); + app.set('disableLoginLimiter', true); ghost({app: app}).then(function (_httpServer) { httpServer = _httpServer; diff --git a/core/test/functional/routes/api/settings_test.js b/core/test/functional/routes/api/settings_test.js index 7a77c1102a..fa7c6ac1cd 100644 --- a/core/test/functional/routes/api/settings_test.js +++ b/core/test/functional/routes/api/settings_test.js @@ -18,6 +18,7 @@ describe('Settings API', function () { before(function (done) { var app = express(); + app.set('disableLoginLimiter', true); ghost({app: app}).then(function (_httpServer) { httpServer = _httpServer; diff --git a/core/test/functional/routes/api/slugs_test.js b/core/test/functional/routes/api/slugs_test.js index 7e0915d10f..919b39fb5c 100644 --- a/core/test/functional/routes/api/slugs_test.js +++ b/core/test/functional/routes/api/slugs_test.js @@ -18,6 +18,7 @@ describe('Slug API', function () { before(function (done) { var app = express(); + app.set('disableLoginLimiter', true); ghost({ app: app }).then(function (_httpServer) { httpServer = _httpServer; diff --git a/core/test/functional/routes/api/tags_test.js b/core/test/functional/routes/api/tags_test.js index b629d1df56..72d784823d 100644 --- a/core/test/functional/routes/api/tags_test.js +++ b/core/test/functional/routes/api/tags_test.js @@ -18,6 +18,7 @@ describe('Tag API', function () { before(function (done) { var app = express(); + app.set('disableLoginLimiter', true); ghost({app: app}).then(function (_httpServer) { httpServer = _httpServer; diff --git a/core/test/functional/routes/api/users_test.js b/core/test/functional/routes/api/users_test.js index 5e65d570c9..922fd5ba7e 100644 --- a/core/test/functional/routes/api/users_test.js +++ b/core/test/functional/routes/api/users_test.js @@ -16,6 +16,7 @@ describe('User API', function () { before(function (done) { var app = express(); + app.set('disableLoginLimiter', true); ghost({app: app}).then(function (_httpServer) { httpServer = _httpServer;