Replace memory spam prevention with brute-express (#7579)

no issue

- removes count from user checks model
- uses brute express brute with brute-knex adaptor to store persisted data on spam prevention
- implement brute force protection for password/token exchange, password resets and private blogging
This commit is contained in:
David Wolfe 2016-11-08 11:33:19 +00:00 committed by Katharina Irrgang
parent b3f09347e4
commit 68af2145a1
20 changed files with 472 additions and 464 deletions

View File

@ -11,7 +11,7 @@ var debug = require('debug')('ghost:api'),
// API specific
auth = require('../auth'),
cors = require('../middleware/api/cors'), // routes only?!
spamPrevention = require('../middleware/api/spam-prevention'), // routes only
brute = require('../middleware/brute'), // routes only
versionMatch = require('../middleware/api/version-match'), // global
// Handling uploads & imports
@ -170,17 +170,21 @@ function apiRoutes() {
// ## Authentication
apiRouter.post('/authentication/passwordreset',
spamPrevention.forgotten,
// Prevent more than 5 password resets from an ip in an hour for any email address
brute.globalReset,
// Prevent more than 5 password resets in an hour for an email+IP pair
brute.userReset,
api.http(api.authentication.generateResetToken)
);
apiRouter.put('/authentication/passwordreset', api.http(api.authentication.resetPassword));
apiRouter.put('/authentication/passwordreset', brute.globalBlock, api.http(api.authentication.resetPassword));
apiRouter.post('/authentication/invitation', api.http(api.authentication.acceptInvitation));
apiRouter.get('/authentication/invitation', api.http(api.authentication.isInvitation));
apiRouter.post('/authentication/setup', api.http(api.authentication.setup));
apiRouter.put('/authentication/setup', authenticatePrivate, api.http(api.authentication.updateSetup));
apiRouter.get('/authentication/setup', api.http(api.authentication.isSetup));
apiRouter.post('/authentication/token',
spamPrevention.signin,
brute.globalBlock,
brute.userLogin,
auth.authenticate.authenticateClient,
auth.oauth.generateAccessToken
);

View File

@ -13,6 +13,7 @@ var _ = require('lodash'),
events = require('../events'),
config = require('../config'),
i18n = require('../i18n'),
spamPrevention = require('../middleware/api/spam-prevention'),
authentication,
tokenSecurity = {};
@ -342,6 +343,8 @@ authentication = {
}));
}
spamPrevention.userLogin.reset(null, options.data.connection + tokenParts.email + 'login');
return models.User.changePassword({
oldPassword: oldPassword,
newPassword: newPassword,

View File

@ -7,11 +7,9 @@ var _ = require('lodash'),
config = require('../../../config'),
api = require('../../../api'),
errors = require('../../../errors'),
logging = require('../../../logging'),
utils = require('../../../utils'),
i18n = require('../../../i18n'),
privateRoute = '/' + config.get('routeKeywords').private + '/',
protectedSecurity = [],
privateBlogging;
function verifySessionHash(salt, hash) {
@ -136,50 +134,6 @@ privateBlogging = {
return next();
}
});
},
spamPrevention: function spamPrevention(req, res, next) {
var currentTime = process.hrtime()[0],
remoteAddress = req.connection.remoteAddress,
rateProtectedPeriod = config.rateProtectedPeriod || 3600,
rateProtectedAttempts = config.rateProtectedAttempts || 10,
ipCount = '',
message = i18n.t('errors.middleware.spamprevention.tooManyAttempts'),
deniedRateLimit = '',
password = req.body.password;
if (password) {
protectedSecurity.push({ip: remoteAddress, time: currentTime});
} else {
res.error = {
message: i18n.t('errors.middleware.spamprevention.noPassword')
};
return next();
}
// filter entries that are older than rateProtectedPeriod
protectedSecurity = _.filter(protectedSecurity, function filter(logTime) {
return (logTime.time + rateProtectedPeriod > currentTime);
});
ipCount = _.chain(protectedSecurity).countBy('ip').value();
deniedRateLimit = (ipCount[remoteAddress] > rateProtectedAttempts);
if (deniedRateLimit) {
logging.error(new errors.GhostError({
message: i18n.t('errors.middleware.spamprevention.forgottenPasswordIp.error', {rfa: rateProtectedAttempts, rfp: rateProtectedPeriod}),
context: i18n.t('errors.middleware.spamprevention.forgottenPasswordIp.context')
}));
message += rateProtectedPeriod === 3600 ? i18n.t('errors.middleware.spamprevention.waitOneHour') : i18n.t('errors.middleware.spamprevention.tryAgainLater');
// @TODO: why?
res.error = {
message: message
};
}
return next();
}
};

View File

@ -4,6 +4,7 @@ var path = require('path'),
bodyParser = require('body-parser'),
templates = require('../../../controllers/frontend/templates'),
setResponseContext = require('../../../controllers/frontend/context'),
brute = require('../../../middleware/brute'),
privateRouter = express.Router();
function controller(req, res) {
@ -32,7 +33,7 @@ privateRouter.route('/')
.post(
bodyParser.urlencoded({extended: true}),
middleware.isPrivateSessionAuth,
middleware.spamPrevention,
brute.privateBlog,
middleware.authenticateProtection,
controller
);

View File

@ -1,4 +1,4 @@
/*globals describe, beforeEach, afterEach, before, it*/
/*globals describe, beforeEach, afterEach, it*/
var crypto = require('crypto'),
should = require('should'),
sinon = require('sinon'),
@ -288,77 +288,4 @@ describe('Private Blogging', function () {
});
});
});
describe('spamPrevention', function () {
var error = null,
res, req, spyNext;
before(function () {
spyNext = sinon.spy(function (param) {
error = param;
});
});
beforeEach(function () {
res = sinon.spy();
req = {
connection: {
remoteAddress: '10.0.0.0'
},
body: {
password: 'password'
}
};
});
it ('sets an error when there is no password', function (done) {
req.body = {};
privateBlogging.spamPrevention(req, res, spyNext);
res.error.message.should.equal('No password entered');
spyNext.calledOnce.should.be.true();
done();
});
it ('sets and error message after 10 tries', function (done) {
var ndx;
for (ndx = 0; ndx < 10; ndx = ndx + 1) {
privateBlogging.spamPrevention(req, res, spyNext);
}
should.not.exist(res.error);
privateBlogging.spamPrevention(req, res, spyNext);
should.exist(res.error);
should.exist(res.error.message);
done();
});
it ('allows more tries after an hour', function (done) {
var ndx,
stub = sinon.stub(process, 'hrtime', function () {
return [10, 10];
});
for (ndx = 0; ndx < 11; ndx = ndx + 1) {
privateBlogging.spamPrevention(req, res, spyNext);
}
should.exist(res.error);
process.hrtime.restore();
stub = sinon.stub(process, 'hrtime', function () {
return [3610000, 10];
});
res = sinon.spy();
privateBlogging.spamPrevention(req, res, spyNext);
should.not.exist(res.error);
process.hrtime.restore();
done();
});
});
});

View File

@ -39,8 +39,8 @@ function exchangeRefreshToken(client, refreshToken, scope, done) {
}
});
}
function exchangePassword(client, username, password, scope, done) {
// We are required to pass in authInfo in order to reset spam counter for user login
function exchangePassword(client, username, password, scope, body, authInfo, done) {
// Validate the client
models.Client.findOne({slug: client.slug})
.then(function then(client) {
@ -54,7 +54,8 @@ function exchangePassword(client, username, password, scope, done) {
return authenticationAPI.createTokens({}, {context: {client_id: client.id, user: user.id}});
})
.then(function then(response) {
spamPrevention.resetCounter(username);
// Reset spam count for username and IP pair
spamPrevention.userLogin.reset(null, authInfo.ip + username + 'login');
return done(null, response.access_token, response.refresh_token, {expires_in: response.expires_in});
});
})

View File

@ -35,5 +35,36 @@
"enabled": false
},
"transports": ["stdout"]
},
"spam": {
"user_login": {
"minWait": 600000,
"maxWait": 604800000,
"freeRetries": 4
},
"user_reset": {
"minWait": 3600000,
"maxWait": 3600000,
"lifetime": 3600,
"freeRetries": 4
},
"global_reset": {
"minWait": 3600000,
"maxWait": 3600000,
"lifetime": 3600,
"freeRetries":4
},
"global_block": {
"minWait": 3600000,
"maxWait": 3600000,
"lifetime": 3600,
"freeRetries":99
},
"private_block": {
"minWait": 3600000,
"maxWait": 3600000,
"lifetime": 3600,
"freeRetries":99
}
}
}

View File

@ -17,5 +17,36 @@
},
"logging": {
"level": "fatal"
},
"spam": {
"user_login": {
"minWait": 600000,
"maxWait": 604800000,
"freeRetries": 3
},
"user_reset": {
"minWait": 3600000,
"maxWait": 3600000,
"lifetime": 3600,
"freeRetries": 4
},
"global_reset": {
"minWait": 3600000,
"maxWait": 3600000,
"lifetime": 3600,
"freeRetries":4
},
"global_block": {
"minWait": 3600000,
"maxWait": 3600000,
"lifetime": 3600,
"freeRetries":4
},
"private_block": {
"minWait": 3600000,
"maxWait": 3600000,
"lifetime": 3600,
"freeRetries":99
}
}
}

View File

@ -14,5 +14,36 @@
},
"logging": {
"level": "fatal"
},
"spam": {
"user_login": {
"minWait": 600000,
"maxWait": 604800000,
"freeRetries": 3
},
"user_reset": {
"minWait": 3600000,
"maxWait": 3600000,
"lifetime": 3600,
"freeRetries": 4
},
"global_reset": {
"minWait": 3600000,
"maxWait": 3600000,
"lifetime": 3600,
"freeRetries":4
},
"global_block": {
"minWait": 3600000,
"maxWait": 3600000,
"lifetime": 3600,
"freeRetries":4
},
"private_block": {
"minWait": 3600000,
"maxWait": 3600000,
"lifetime": 3600,
"freeRetries":99
}
}
}

View File

@ -232,5 +232,12 @@ module.exports = {
id: {type: 'increments', nullable: false, primary: true},
role_id: {type: 'integer', nullable: false},
invite_id: {type: 'integer', nullable: false}
},
brute: {
key: {type: 'string'},
firstRequest: {type: 'timestamp'},
lastRequest: {type: 'timestamp'},
lifetime: {type: 'bigInteger'},
count: {type: 'integer'}
}
};

View File

@ -1,129 +1,128 @@
// # SpamPrevention Middleware
// Usage: spamPrevention
// After:
// Before:
// App: Admin|Blog|API
//
// Helpers to handle spam detection on signin, forgot password, and protected pages.
var ExpressBrute = require('express-brute'),
BruteKnex = require('brute-knex'),
knexInstance = require('../../data/db/connection'),
store = new BruteKnex({tablename: 'brute', createTable:false, knex: knexInstance}),
moment = require('moment'),
errors = require('../../errors'),
config = require('../../config'),
spam = config.get('spam') || {},
_ = require('lodash'),
spamPrivateBlog = spam.private_blog || {},
spamGlobalBlock = spam.global_block || {},
spamGlobalReset = spam.global_reset || {},
spamUserReset = spam.user_reset || {},
spamUserLogin = spam.user_login || {},
var _ = require('lodash'),
errors = require('../../errors'),
config = require('../../config'),
i18n = require('../../i18n'),
loginSecurity = [],
forgottenSecurity = [],
spamPrevention;
i18n = require('../../i18n'),
handleStoreError,
globalBlock,
globalReset,
privateBlog,
userLogin,
userReset,
logging = require('../../logging'),
spamConfigKeys = ['freeRetries', 'minWait', 'maxWait', 'lifetime'];
spamPrevention = {
/*jslint unparam:true*/
// limit signin requests to ten failed requests per IP per hour
signin: function signin(req, res, next) {
var currentTime = process.hrtime()[0],
remoteAddress = req.connection.remoteAddress,
deniedRateLimit = '',
ipCount = '',
rateSigninPeriod = config.rateSigninPeriod || 3600,
rateSigninAttempts = config.rateSigninAttempts || 10;
if (req.body.username && req.body.grant_type === 'password') {
loginSecurity.push({ip: remoteAddress, time: currentTime, email: req.body.username});
} else if (req.body.grant_type === 'refresh_token' || req.body.grant_type === 'authorization_code') {
return next();
} else {
return next(new errors.BadRequestError({message: i18n.t('errors.middleware.spamprevention.noUsername')}));
}
// filter entries that are older than rateSigninPeriod
loginSecurity = _.filter(loginSecurity, function filter(logTime) {
return (logTime.time + rateSigninPeriod > currentTime);
});
// check number of tries per IP address
ipCount = _.chain(loginSecurity).countBy('ip').value();
deniedRateLimit = (ipCount[remoteAddress] > rateSigninAttempts);
if (deniedRateLimit) {
return next(new errors.TooManyRequestsError({
message: i18n.t('errors.middleware.spamprevention.tooManyAttempts') + rateSigninPeriod === 3600 ? i18n.t('errors.middleware.spamprevention.waitOneHour') : i18n.t('errors.middleware.spamprevention.tryAgainLater'),
context: i18n.t('errors.middleware.spamprevention.tooManySigninAttempts.error', {rateSigninAttempts: rateSigninAttempts, rateSigninPeriod: rateSigninPeriod}),
help: i18n.t('errors.middleware.spamprevention.tooManySigninAttempts.context')
}));
}
next();
},
// limit forgotten password requests to five requests per IP per hour for different email addresses
// limit forgotten password requests to five requests per email address
// @TODO: add validation check to validation middleware
forgotten: function forgotten(req, res, next) {
if (!req.body.passwordreset) {
return next(new errors.BadRequestError({
message: i18n.t('errors.api.utils.noRootKeyProvided', {docName: 'passwordreset'})
}));
}
var currentTime = process.hrtime()[0],
remoteAddress = req.connection.remoteAddress,
rateForgottenPeriod = config.rateForgottenPeriod || 3600,
rateForgottenAttempts = config.rateForgottenAttempts || 5,
email = req.body.passwordreset[0].email,
ipCount = '',
deniedRateLimit = '',
deniedEmailRateLimit = '',
index = _.findIndex(forgottenSecurity, function findIndex(logTime) {
return (logTime.ip === remoteAddress && logTime.email === email);
});
if (email) {
if (index !== -1) {
forgottenSecurity[index].count = forgottenSecurity[index].count + 1;
} else {
forgottenSecurity.push({ip: remoteAddress, time: currentTime, email: email, count: 0});
}
} else {
return next(new errors.BadRequestError({message: i18n.t('errors.middleware.spamprevention.noEmail')}));
}
// filter entries that are older than rateForgottenPeriod
forgottenSecurity = _.filter(forgottenSecurity, function filter(logTime) {
return (logTime.time + rateForgottenPeriod > currentTime);
});
// check number of tries with different email addresses per IP
ipCount = _.chain(forgottenSecurity).countBy('ip').value();
deniedRateLimit = (ipCount[remoteAddress] > rateForgottenAttempts);
if (index !== -1) {
deniedEmailRateLimit = (forgottenSecurity[index].count > rateForgottenAttempts);
}
if (deniedEmailRateLimit) {
return next(new errors.TooManyRequestsError({
message: i18n.t('errors.middleware.spamprevention.tooManyAttempts') + rateForgottenPeriod === 3600 ? i18n.t('errors.middleware.spamprevention.waitOneHour') : i18n.t('errors.middleware.spamprevention.tryAgainLater'),
context: i18n.t('errors.middleware.spamprevention.forgottenPasswordEmail.error', {
rfa: rateForgottenAttempts,
rfp: rateForgottenPeriod
}),
help: i18n.t('errors.middleware.spamprevention.forgottenPasswordEmail.context')
}));
}
if (deniedRateLimit) {
return next(new errors.TooManyRequestsError({
message: i18n.t('errors.middleware.spamprevention.tooManyAttempts') + rateForgottenPeriod === 3600 ? i18n.t('errors.middleware.spamprevention.waitOneHour') : i18n.t('errors.middleware.spamprevention.tryAgainLater'),
context: i18n.t('errors.middleware.spamprevention.forgottenPasswordIp.error', {rfa: rateForgottenAttempts, rfp: rateForgottenPeriod}),
help: i18n.t('errors.middleware.spamprevention.forgottenPasswordIp.context')
}));
}
next();
},
resetCounter: function resetCounter(email) {
loginSecurity = _.filter(loginSecurity, function filter(logTime) {
return (logTime.email !== email);
});
}
handleStoreError = function handleStoreError(err) {
return new errors.NoPermissionError({message: 'DB error', err: err});
};
module.exports = spamPrevention;
// This is a global endpoint protection mechanism that will lock an endpoint if there are so many
// requests from a single IP
// We allow for a generous number of requests here to prevent communites on the same IP bing barred on account of a single suer
// Defaults to 50 attempts per hour and locks the endpoint for an hour
globalBlock = new ExpressBrute(store,
_.extend({
attachResetToRequest: false,
failCallback: function (req, res, next, nextValidRequestDate) {
return next(new errors.TooManyRequestsError({
message: 'Too many attempts try again in ' + moment(nextValidRequestDate).fromNow(true),
context: i18n.t('errors.middleware.spamprevention.forgottenPasswordIp.error',
{rfa: spamGlobalBlock.freeRetries + 1 || 5, rfp: spamGlobalBlock.lifetime || 60 * 60}),
help: i18n.t('errors.middleware.spamprevention.forgottenPasswordIp.context')
}));
},
handleStoreError: handleStoreError
}, _.pick(spamGlobalBlock, spamConfigKeys))
);
globalReset = new ExpressBrute(store,
_.extend({
attachResetToRequest: false,
failCallback: function (req, res, next, nextValidRequestDate) {
// TODO use i18n again
return next(new errors.TooManyRequestsError({
message: 'Too many attempts try again in ' + moment(nextValidRequestDate).fromNow(true),
context: i18n.t('errors.middleware.spamprevention.forgottenPasswordIp.error',
{rfa: spamGlobalReset.freeRetries + 1 || 5, rfp: spamGlobalReset.lifetime || 60 * 60}),
help: i18n.t('errors.middleware.spamprevention.forgottenPasswordIp.context')
}));
},
handleStoreError: handleStoreError
}, _.pick(spamGlobalBlock, spamConfigKeys))
);
// Stops login attempts for a user+IP pair with an increasing time period starting from 10 minutes
// and rising to a week in a fibonnaci sequence
// The user+IP count is reset when on successful login
// Default value of 5 attempts per user+IP pair
userLogin = new ExpressBrute(store,
_.extend({
attachResetToRequest: true,
failCallback: function (req, res, next, nextValidRequestDate) {
return next(new errors.TooManyRequestsError({
message: 'Too many attempts try again in ' + moment(nextValidRequestDate).fromNow(true),
context: i18n.t('errors.middleware.spamprevention.forgottenPasswordIp.error',
{rfa: spamUserLogin.freeRetries + 1 || 5, rfp: spamUserLogin.lifetime || 60 * 60}),
help: i18n.t('errors.middleware.spamprevention.forgottenPasswordIp.context')
}));
},
handleStoreError: handleStoreError
}, _.pick(spamUserLogin, spamConfigKeys))
);
// Stop password reset requests when there are (freeRetries + 1) requests per lifetime per email
// Defaults here are 5 attempts per hour for a user+IP pair
// The endpoint is then locked for an hour
userReset = new ExpressBrute(store,
_.extend({
attachResetToRequest: true,
failCallback: function (req, res, next, nextValidRequestDate) {
return next(new errors.TooManyRequestsError({
message: 'Too many attempts try again in ' + moment(nextValidRequestDate).fromNow(true),
context: i18n.t('errors.middleware.spamprevention.forgottenPasswordIp.error',
{rfa: spamUserReset.freeRetries + 1 || 5, rfp: spamUserReset.lifetime || 60 * 60}),
help: i18n.t('errors.middleware.spamprevention.forgottenPasswordIp.context')
}));
},
handleStoreError: handleStoreError
}, _.pick(spamUserReset, spamConfigKeys))
);
// This protects a private blog from spam attacks. The defaults here allow 10 attempts per IP per hour
// The endpoint is then locked for an hour
privateBlog = new ExpressBrute(store,
_.extend({
attachResetToRequest: false,
failCallback: function (req, res, next, nextValidRequestDate) {
logging.error(new errors.GhostError({
message: i18n.t('errors.middleware.spamprevention.forgottenPasswordIp.error',
{rfa: spamPrivateBlog.freeRetries + 1 || 5, rfp: spamPrivateBlog.lifetime || 60 * 60}),
context: i18n.t('errors.middleware.spamprevention.forgottenPasswordIp.context')
}));
return next(new errors.GhostError({
message: 'Too many attempts try again in ' + moment(nextValidRequestDate).fromNow(true)
}));
},
handleStoreError: handleStoreError
}, _.pick(spamPrivateBlog, spamConfigKeys))
);
module.exports = {
globalBlock: globalBlock,
globalReset: globalReset,
userLogin: userLogin,
userReset: userReset,
privateBlog: privateBlog
};

View File

@ -0,0 +1,50 @@
var spamPrevention = require('./api/spam-prevention');
module.exports = {
globalBlock: spamPrevention.globalBlock.getMiddleware({
// We want to ignore req.ip and instead use req.connection.remoteAddress
ignoreIP: true,
key: function (req, res, next) {
req.authInfo = req.authInfo || {};
req.authInfo.ip = req.connection.remoteAddress;
req.body.connection = req.connection.remoteAddress;
next(req.authInfo.ip);
}
}),
globalReset: spamPrevention.globalReset.getMiddleware({
ignoreIP: true,
key: function (req, res, next) {
req.authInfo = req.authInfo || {};
req.authInfo.ip = req.connection.remoteAddress;
// prevent too many attempts for the same email address but keep separate to login brute force prevention
next(req.authInfo.ip);
}
}),
userLogin: spamPrevention.userLogin.getMiddleware({
ignoreIP: true,
key: function (req, res, next) {
req.authInfo = req.authInfo || {};
req.authInfo.ip = req.connection.remoteAddress;
// prevent too many attempts for the same username
next(req.authInfo.ip + req.body.username + 'login');
}
}),
userReset: spamPrevention.userReset.getMiddleware({
ignoreIP: true,
key: function (req, res, next) {
req.authInfo = req.authInfo || {};
req.authInfo.ip = req.connection.remoteAddress;
// prevent too many attempts for the same email address but keep separate to login brute force prevention
next(req.authInfo.ip + req.body.username + 'reset');
}
}),
privateBlog: spamPrevention.privateBlog.getMiddleware({
ignoreIP: true,
key: function (req, res, next) {
req.authInfo = req.authInfo || {};
req.authInfo.ip = req.connection.remoteAddress;
// prevent too many attempts for the same email address but keep separate to login brute force prevention
next(req.authInfo.ip + 'private');
}
})
};

View File

@ -582,8 +582,7 @@ User = ghostBookshelf.Model.extend({
// Finds the user by email, and checks the password
// @TODO: shorten this function and rename...
check: function check(object) {
var self = this,
s;
var self = this;
return this.getByEmail(object.email).then(function then(user) {
if (!user) {
@ -607,32 +606,11 @@ User = ghostBookshelf.Model.extend({
});
})
.catch(function onError(err) {
if (err.code !== 'PASSWORD_INCORRECT') {
return Promise.reject(err);
}
return Promise.resolve(self.setWarning(user, {validate: false}))
.then(function then(remaining) {
if (remaining === 0) {
// If remaining attempts = 0, the account has been locked, so show a locked account message
return Promise.reject(new errors.NoPermissionError({
message: i18n.t('errors.models.user.accountLocked')
}));
}
s = (remaining > 1) ? 's' : '';
return Promise.reject(new errors.UnauthorizedError({
message: i18n.t('errors.models.user.incorrectPasswordAttempts', {remaining: remaining, s: s})
}));
}, function handleError(err) {
// ^ Use comma structure, not .catch, because we don't want to catch incorrect passwords
return Promise.reject(new errors.UnauthorizedError({
err: err,
context: i18n.t('errors.models.user.incorrectPassword'),
help: i18n.t('errors.models.user.userUpdateError.help')
}));
});
return Promise.reject(new errors.UnauthorizedError({
err: err,
context: i18n.t('errors.models.user.incorrectPassword'),
help: i18n.t('errors.models.user.userUpdateError.help')
}));
});
}

View File

@ -22,6 +22,12 @@ describe('Authentication API', function () {
}).catch(done);
});
afterEach(function (done) {
testUtils.clearBruteData().then(function () {
done();
});
});
after(function (done) {
testUtils.clearData().then(function () {
done();

View File

@ -0,0 +1,153 @@
var supertest = require('supertest'),
should = require('should'),
testUtils = require('../../../utils'),
user1 = testUtils.DataGenerator.forModel.users[0],
user2 = testUtils.DataGenerator.forModel.users[1],
config = require('../../../../../core/server/config'),
ghostSetup = testUtils.setup('settings', 'users:roles', 'perms:setting', 'perms:notification', 'perms:init'),
ghost = testUtils.startGhost,
failedLoginAttempt,
count,
tooManyFailedLoginAttempts,
request;
describe('Spam Prevention API', function () {
before(function (done) {
ghostSetup()
.then(ghost)
.then(function (ghostServer) {
request = supertest.agent(ghostServer.rootApp);
}).then(function () {
done();
}).then(testUtils.clearBruteData)
.catch(done);
});
after(function (done) {
testUtils.clearData().then(function () {
done();
}).catch(done);
});
afterEach(function (done) {
testUtils.clearBruteData().then(function () {
done();
}).catch(done);
});
it('Too many failed login attempts for a user results in 429 TooManyRequestsError', function (done) {
count = 0;
tooManyFailedLoginAttempts = function tooManyFailedLoginAttempts(email) {
request.post(testUtils.API.getApiQuery('authentication/token'))
.set('Origin', config.get('url'))
.send({
grant_type: 'password',
username: email,
password: 'wrong-password',
client_id: 'ghost-admin',
client_secret: 'not_available'
}).expect('Content-Type', /json/)
.expect(429)
.end(function (err, res) {
if (err) {
return done(err);
}
var error = res.body.errors[0];
should.exist(error.errorType);
error.errorType.should.eql('TooManyRequestsError');
error.message.should.eql('Too many attempts try again in 10 minutes');
done();
});
};
failedLoginAttempt = function failedLoginAttempt(email) {
count += 1;
request.post(testUtils.API.getApiQuery('authentication/token'))
.set('Origin', config.get('url'))
.send({
grant_type: 'password',
username: email,
password: 'wrong-password',
client_id: 'ghost-admin',
client_secret: 'not_available'
}).expect('Content-Type', /json/)
.expect(401)
.end(function (err) {
if (err) {
return done(err);
}
if (count < config.get('spam:user_login:freeRetries') + 1) {
return failedLoginAttempt(email);
}
tooManyFailedLoginAttempts(email);
});
};
failedLoginAttempt(user1.email);
});
it('Too many failed login attempts for multiple users results in 429 TooManyRequestsError', function (done) {
count = 0;
// We make some unsuccessful login attempts for user1 but not enough to block them. We then make some
// failed login attempts for user2 to trigger a global block rather than user specific block
tooManyFailedLoginAttempts = function tooManyFailedLoginAttempts(email) {
request.post(testUtils.API.getApiQuery('authentication/token'))
.set('Origin', config.get('url'))
.send({
grant_type: 'password',
username: email,
password: 'wrong-password',
client_id: 'ghost-admin',
client_secret: 'not_available'
}).expect('Content-Type', /json/)
.expect(429)
.end(function (err, res) {
if (err) {
return done(err);
}
var error = res.body.errors[0];
should.exist(error.errorType);
error.errorType.should.eql('TooManyRequestsError');
error.message.should.eql('Too many attempts try again in an hour');
done();
});
};
failedLoginAttempt = function failedLoginAttempt(email) {
count += 1;
request.post(testUtils.API.getApiQuery('authentication/token'))
.set('Origin', config.get('url'))
.send({
grant_type: 'password',
username: email,
password: 'wrong-password',
client_id: 'ghost-admin',
client_secret: 'not_available'
}).expect('Content-Type', /json/)
.expect(401)
.end(function (err) {
if (err) {
return done(err);
}
if (count < config.get('spam:user_login:freeRetries') + 1) {
return failedLoginAttempt(user1.email);
}
if (count < config.get('spam:global_block:freeRetries') + 1) {
return failedLoginAttempt(user2.email);
}
tooManyFailedLoginAttempts(user2.email);
});
};
failedLoginAttempt(user1.email);
});
});

View File

@ -623,34 +623,11 @@ describe('User Model', function run() {
throw new Error('User should not have been logged in.');
}
function checkAttemptsError(number) {
return function (error) {
should.exist(error);
error.errorType.should.equal('UnauthorizedError');
error.message.should.match(new RegExp(number + ' attempt'));
return UserModel.check(object);
};
}
function checkLockedError(error) {
should.exist(error);
error.errorType.should.equal('NoPermissionError');
error.message.should.match(/^Your account is locked/);
}
return UserModel.check(object).then(userWasLoggedIn)
.catch(checkAttemptsError(4))
.then(userWasLoggedIn)
.catch(checkAttemptsError(3))
.then(userWasLoggedIn)
.catch(checkAttemptsError(2))
.then(userWasLoggedIn)
.catch(checkAttemptsError(1))
.then(userWasLoggedIn)
.catch(checkLockedError);
.catch(function checkError(error) {
should.exist(error);
error.errorType.should.equal('UnauthorizedError');
});
});
});
});

View File

@ -41,6 +41,8 @@ describe('OAuth', function () {
req.client = {
slug: 'test'
};
req.authInfo = {};
req.authInfo.ip = '127.0.0.1';
req.body.grant_type = 'password';
req.body.username = 'username';

View File

@ -1,155 +0,0 @@
var should = require('should'),
sinon = require('sinon'),
rewire = require('rewire'),
spamPrevention = require('../../../../server/middleware/api/spam-prevention');
describe('Middleware: spamPrevention', function () {
var sandbox,
req,
next,
error,
spyNext;
beforeEach(function () {
sandbox = sinon.sandbox.create();
error = null;
next = sinon.spy();
spyNext = sinon.spy(function (param) {
error = param;
});
spamPrevention = rewire('../../../../server/middleware/api/spam-prevention');
});
afterEach(function () {
sandbox.restore();
});
describe('signin', function () {
beforeEach(function () {
req = {
connection: {
remoteAddress: '10.0.0.0'
},
body: {
username: 'tester',
grant_type: 'password'
}
};
});
it('calls next if refreshing the token', function (done) {
req.body.grant_type = 'refresh_token';
spamPrevention.signin(req, null, next);
next.calledOnce.should.be.true();
done();
});
it ('creates a BadRequestError when there\'s no username', function (done) {
req.body = {};
spamPrevention.signin(req, null, spyNext);
should.exist(error);
error.errorType.should.eql('BadRequestError');
done();
});
it ('rate limits after 10 attempts', function (done) {
for (var ndx = 0; ndx < 10; ndx = ndx + 1) {
spamPrevention.signin(req, null, spyNext);
}
spamPrevention.signin(req, null, spyNext);
should.exist(error);
error.errorType.should.eql('TooManyRequestsError');
done();
});
it ('allows more attempts after an hour', function (done) {
var ndx,
stub = sinon.stub(process, 'hrtime', function () {
return [10, 10];
});
for (ndx = 0; ndx < 10; ndx = ndx + 1) {
spamPrevention.signin(req, null, spyNext);
}
spamPrevention.signin(req, null, spyNext);
error.errorType.should.eql('TooManyRequestsError');
error = null;
// fast forward 1 hour
process.hrtime.restore();
stub = sinon.stub(process, 'hrtime', function () {
return [3610, 10];
});
spamPrevention.signin(req, null, spyNext);
should(error).equal(undefined);
spyNext.called.should.be.true();
process.hrtime.restore();
done();
});
});
describe('forgotten', function () {
beforeEach(function () {
req = {
connection: {
remoteAddress: '10.0.0.0'
},
body: {
passwordreset: [
{email:'test@ghost.org'}
]
}
};
});
it ('send a bad request if no email is specified', function (done) {
req.body = {
passwordreset: [{}]
};
spamPrevention.forgotten(req, null, spyNext);
error.errorType.should.eql('BadRequestError');
done();
});
it ('creates an unauthorized error after 5 attempts with same email', function (done) {
for (var ndx = 0; ndx < 6; ndx = ndx + 1) {
spamPrevention.forgotten(req, null, spyNext);
}
spamPrevention.forgotten(req, null, spyNext);
error.errorType.should.eql('TooManyRequestsError');
done();
});
it ('creates an unauthorized error after 5 attempts from the same ip', function (done) {
var ndx, email;
for (ndx = 0; ndx < 6; ndx = ndx + 1) {
email = 'test' + String(ndx) + '@ghost.org';
req.body.passwordreset = [
{email: email}
];
spamPrevention.forgotten(req, null, spyNext);
}
spamPrevention.forgotten(req, null, spyNext);
error.errorType.should.eql('TooManyRequestsError');
done();
});
});
});

View File

@ -37,7 +37,8 @@ var Promise = require('bluebird'),
initFixtures,
initData,
clearData;
clearData,
clearBruteData;
// Require additional assertions which help us keep our tests small and clear
require('./assertions');
@ -405,6 +406,10 @@ initData = function initData() {
return knexMigrator.init();
};
clearBruteData = function clearBruteData() {
return db.knex('brute').truncate();
};
// we must always try to delete all tables
clearData = function clearData() {
debug('Database reset');
@ -681,6 +686,7 @@ module.exports = {
initFixtures: initFixtures,
initData: initData,
clearData: clearData,
clearBruteData: clearBruteData,
mocks: mocks,

View File

@ -31,6 +31,7 @@
"bluebird": "3.4.6",
"body-parser": "1.15.2",
"bookshelf": "0.10.2",
"brute-knex": "git://github.com/cobbspur/brute-knex.git#0985c50",
"bunyan": "1.8.1",
"chalk": "1.1.3",
"cheerio": "0.22.0",
@ -43,6 +44,7 @@
"debug": "2.2.0",
"downsize": "0.0.8",
"express": "4.14.0",
"express-brute": "1.0.1",
"express-hbs": "1.0.3",
"extract-zip-fork": "1.5.1",
"fs-extra": "0.30.0",