mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-02-17 23:44:39 -05:00
🎨 optimisations for brute (#7867)
closes #7766, refs #7579 - ensure we are using the correct brute keys - ensure we are using req.ip as Ghost is configured with trust proxy option - tidy up a little
This commit is contained in:
parent
4dad5ae742
commit
a2edc09762
9 changed files with 164 additions and 45 deletions
|
@ -166,9 +166,7 @@ function apiRoutes() {
|
||||||
|
|
||||||
// ## Authentication
|
// ## Authentication
|
||||||
apiRouter.post('/authentication/passwordreset',
|
apiRouter.post('/authentication/passwordreset',
|
||||||
// Prevent more than 5 password resets from an ip in an hour for any email address
|
|
||||||
brute.globalReset,
|
brute.globalReset,
|
||||||
// Prevent more than 5 password resets in an hour for an email+IP pair
|
|
||||||
brute.userReset,
|
brute.userReset,
|
||||||
api.http(api.authentication.generateResetToken)
|
api.http(api.authentication.generateResetToken)
|
||||||
);
|
);
|
||||||
|
|
|
@ -266,7 +266,7 @@ authentication = {
|
||||||
* @param {Object} object
|
* @param {Object} object
|
||||||
* @returns {Promise<Object>} message
|
* @returns {Promise<Object>} message
|
||||||
*/
|
*/
|
||||||
resetPassword: function resetPassword(object) {
|
resetPassword: function resetPassword(object, opts) {
|
||||||
var tasks, tokenIsCorrect, dbHash, options = {context: {internal: true}}, tokenParts;
|
var tasks, tokenIsCorrect, dbHash, options = {context: {internal: true}}, tokenParts;
|
||||||
|
|
||||||
function validateRequest() {
|
function validateRequest() {
|
||||||
|
@ -341,7 +341,7 @@ authentication = {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
spamPrevention.userLogin.reset(null, options.data.connection + tokenParts.email + 'login');
|
spamPrevention.userLogin.reset(opts.ip, tokenParts.email + 'login');
|
||||||
|
|
||||||
return models.User.changePassword({
|
return models.User.changePassword({
|
||||||
oldPassword: oldPassword,
|
oldPassword: oldPassword,
|
||||||
|
|
|
@ -220,7 +220,7 @@ http = function http(apiMethod) {
|
||||||
return function apiHandler(req, res, next) {
|
return function apiHandler(req, res, next) {
|
||||||
// We define 2 properties for using as arguments in API calls:
|
// We define 2 properties for using as arguments in API calls:
|
||||||
var object = req.body,
|
var object = req.body,
|
||||||
options = _.extend({}, req.file, req.query, req.params, {
|
options = _.extend({}, req.file, {ip: req.ip}, req.query, req.params, {
|
||||||
context: {
|
context: {
|
||||||
// @TODO: forward the client and user obj in 1.0 (options.context.user.id)
|
// @TODO: forward the client and user obj in 1.0 (options.context.user.id)
|
||||||
user: ((req.user && req.user.id) || (req.user && models.User.isExternalUser(req.user.id))) ? req.user.id : null,
|
user: ((req.user && req.user.id) || (req.user && models.User.isExternalUser(req.user.id))) ? req.user.id : null,
|
||||||
|
|
|
@ -21,7 +21,8 @@ function exchangeRefreshToken(client, refreshToken, scope, body, authInfo, done)
|
||||||
refreshExpires = Date.now() + utils.ONE_WEEK_MS;
|
refreshExpires = Date.now() + utils.ONE_WEEK_MS;
|
||||||
|
|
||||||
if (token.expires > Date.now()) {
|
if (token.expires > Date.now()) {
|
||||||
spamPrevention.userLogin.reset(null, authInfo.ip + body.refresh_token + 'login');
|
spamPrevention.userLogin.reset(authInfo.ip, body.refresh_token + 'login');
|
||||||
|
|
||||||
models.Accesstoken.add({
|
models.Accesstoken.add({
|
||||||
token: accessToken,
|
token: accessToken,
|
||||||
user_id: token.user_id,
|
user_id: token.user_id,
|
||||||
|
@ -42,11 +43,12 @@ function exchangeRefreshToken(client, refreshToken, scope, body, authInfo, done)
|
||||||
}
|
}
|
||||||
// We are required to pass in authInfo in order to reset spam counter for user login
|
// 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) {
|
function exchangePassword(client, username, password, scope, body, authInfo, done) {
|
||||||
// Validate the client
|
|
||||||
models.Client.findOne({slug: client.slug})
|
models.Client.findOne({slug: client.slug})
|
||||||
.then(function then(client) {
|
.then(function then(client) {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
return done(new errors.NoPermissionError({message: i18n.t('errors.middleware.oauth.invalidClient')}), false);
|
return done(new errors.NoPermissionError({
|
||||||
|
message: i18n.t('errors.middleware.oauth.invalidClient')
|
||||||
|
}), false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the user
|
// Validate the user
|
||||||
|
@ -55,8 +57,7 @@ function exchangePassword(client, username, password, scope, body, authInfo, don
|
||||||
return authenticationAPI.createTokens({}, {context: {client_id: client.id, user: user.id}});
|
return authenticationAPI.createTokens({}, {context: {client_id: client.id, user: user.id}});
|
||||||
})
|
})
|
||||||
.then(function then(response) {
|
.then(function then(response) {
|
||||||
// Reset spam count for username and IP pair
|
spamPrevention.userLogin.reset(authInfo.ip, username + 'login');
|
||||||
spamPrevention.userLogin.reset(null, authInfo.ip + username + 'login');
|
|
||||||
return done(null, response.access_token, response.refresh_token, {expires_in: response.expires_in});
|
return done(null, response.access_token, response.refresh_token, {expires_in: response.expires_in});
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
@ -86,7 +87,7 @@ function exchangeAuthorizationCode(req, res, next) {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
spamPrevention.userLogin.reset(null, req.authInfo.ip + req.body.authorizationCode + 'login');
|
spamPrevention.userLogin.reset(req.authInfo.ip, req.body.authorizationCode + 'login');
|
||||||
|
|
||||||
authenticationAPI.createTokens({}, {context: {client_id: req.client.id, user: user.id}})
|
authenticationAPI.createTokens({}, {context: {client_id: req.client.id, user: user.id}})
|
||||||
.then(function then(response) {
|
.then(function then(response) {
|
||||||
|
@ -147,6 +148,17 @@ oauth = {
|
||||||
// ### Generate access token Middleware
|
// ### Generate access token Middleware
|
||||||
// register the oauth2orize middleware for password and refresh token grants
|
// register the oauth2orize middleware for password and refresh token grants
|
||||||
generateAccessToken: function generateAccessToken(req, res, next) {
|
generateAccessToken: function generateAccessToken(req, res, next) {
|
||||||
|
/**
|
||||||
|
* TODO:
|
||||||
|
* https://github.com/jaredhanson/oauth2orize/issues/182
|
||||||
|
* oauth2orize only offers the option to forward request information via authInfo object
|
||||||
|
*
|
||||||
|
* Important: only used for resetting the brute count (access to req.ip)
|
||||||
|
*/
|
||||||
|
req.authInfo = {
|
||||||
|
ip: req.ip
|
||||||
|
};
|
||||||
|
|
||||||
return oauthServer.token()(req, res, next);
|
return oauthServer.token()(req, res, next);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -74,7 +74,7 @@ globalReset = new ExpressBrute(store,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
handleStoreError: handleStoreError
|
handleStoreError: handleStoreError
|
||||||
}, _.pick(spamGlobalBlock, spamConfigKeys))
|
}, _.pick(spamGlobalReset, spamConfigKeys))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Stops login attempts for a user+IP pair with an increasing time period starting from 10 minutes
|
// Stops login attempts for a user+IP pair with an increasing time period starting from 10 minutes
|
||||||
|
|
|
@ -1,63 +1,64 @@
|
||||||
var spamPrevention = require('./api/spam-prevention');
|
var url = require('url'),
|
||||||
|
spamPrevention = require('./api/spam-prevention');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We set ignoreIP to false, because we tell brute-knex to use `req.ip`.
|
||||||
|
* We can use `req.ip`, because express trust proxy option is enabled.
|
||||||
|
*/
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
/**
|
||||||
|
* block per route per ip
|
||||||
|
*/
|
||||||
globalBlock: spamPrevention.globalBlock.getMiddleware({
|
globalBlock: spamPrevention.globalBlock.getMiddleware({
|
||||||
// We want to ignore req.ip and instead use req.connection.remoteAddress
|
ignoreIP: false,
|
||||||
ignoreIP: true,
|
|
||||||
key: function (req, res, next) {
|
key: function (req, res, next) {
|
||||||
req.authInfo = req.authInfo || {};
|
next(url.parse(req.url).pathname);
|
||||||
req.authInfo.ip = req.connection.remoteAddress;
|
|
||||||
req.body.connection = req.connection.remoteAddress;
|
|
||||||
|
|
||||||
next(req.authInfo.ip);
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
/**
|
||||||
|
* block per route per ip
|
||||||
|
*/
|
||||||
globalReset: spamPrevention.globalReset.getMiddleware({
|
globalReset: spamPrevention.globalReset.getMiddleware({
|
||||||
ignoreIP: true,
|
ignoreIP: false,
|
||||||
key: function (req, res, next) {
|
key: function (req, res, next) {
|
||||||
req.authInfo = req.authInfo || {};
|
next(url.parse(req.url).pathname);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
/**
|
||||||
|
* block per user
|
||||||
|
* username === email!
|
||||||
|
*/
|
||||||
userLogin: spamPrevention.userLogin.getMiddleware({
|
userLogin: spamPrevention.userLogin.getMiddleware({
|
||||||
ignoreIP: true,
|
ignoreIP: false,
|
||||||
key: function (req, res, next) {
|
key: function (req, res, next) {
|
||||||
req.authInfo = req.authInfo || {};
|
|
||||||
req.authInfo.ip = req.connection.remoteAddress || req.ip;
|
|
||||||
// prevent too many attempts for the same username
|
|
||||||
if (req.body.username) {
|
if (req.body.username) {
|
||||||
return next(req.authInfo.ip + req.body.username + 'login');
|
return next(req.body.username + 'login');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.body.authorizationCode) {
|
if (req.body.authorizationCode) {
|
||||||
return next(req.authInfo.ip + req.body.authorizationCode + 'login');
|
return next(req.body.authorizationCode + 'login');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.body.refresh_token) {
|
if (req.body.refresh_token) {
|
||||||
return next(req.authInfo.ip + req.body.refresh_token + 'login');
|
return next(req.body.refresh_token + 'login');
|
||||||
}
|
}
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
/**
|
||||||
|
* block per user
|
||||||
|
*/
|
||||||
userReset: spamPrevention.userReset.getMiddleware({
|
userReset: spamPrevention.userReset.getMiddleware({
|
||||||
ignoreIP: true,
|
ignoreIP: false,
|
||||||
key: function (req, res, next) {
|
key: function (req, res, next) {
|
||||||
req.authInfo = req.authInfo || {};
|
next(req.body.username + 'reset');
|
||||||
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({
|
privateBlog: spamPrevention.privateBlog.getMiddleware({
|
||||||
ignoreIP: true,
|
ignoreIP: false,
|
||||||
key: function (req, res, next) {
|
key: function (req, res, next) {
|
||||||
req.authInfo = req.authInfo || {};
|
next('privateblog');
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
|
@ -2,7 +2,10 @@ var supertest = require('supertest'),
|
||||||
should = require('should'),
|
should = require('should'),
|
||||||
testUtils = require('../../../utils'),
|
testUtils = require('../../../utils'),
|
||||||
user = testUtils.DataGenerator.forModel.users[0],
|
user = testUtils.DataGenerator.forModel.users[0],
|
||||||
|
userForKnex = testUtils.DataGenerator.forKnex.users[0],
|
||||||
|
models = require('../../../../../core/server/models'),
|
||||||
config = require('../../../../../core/server/config'),
|
config = require('../../../../../core/server/config'),
|
||||||
|
utils = require('../../../../../core/server/utils'),
|
||||||
ghost = testUtils.startGhost,
|
ghost = testUtils.startGhost,
|
||||||
request;
|
request;
|
||||||
|
|
||||||
|
@ -127,7 +130,9 @@ describe('Authentication API', function () {
|
||||||
if (err) {
|
if (err) {
|
||||||
return done(err);
|
return done(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
var refreshToken = res.body.refresh_token;
|
var refreshToken = res.body.refresh_token;
|
||||||
|
|
||||||
request.post(testUtils.API.getApiQuery('authentication/token'))
|
request.post(testUtils.API.getApiQuery('authentication/token'))
|
||||||
.set('Origin', config.get('url'))
|
.set('Origin', config.get('url'))
|
||||||
.send({
|
.send({
|
||||||
|
@ -173,4 +178,39 @@ describe('Authentication API', function () {
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reset password', function (done) {
|
||||||
|
models.Settings
|
||||||
|
.findOne({key: 'dbHash'})
|
||||||
|
.then(function (response) {
|
||||||
|
var token = utils.tokens.resetToken.generateHash({
|
||||||
|
expires: Date.now() + (1000 * 60),
|
||||||
|
email: user.email,
|
||||||
|
dbHash: response.attributes.value,
|
||||||
|
password: userForKnex.password
|
||||||
|
});
|
||||||
|
|
||||||
|
request.put(testUtils.API.getApiQuery('authentication/passwordreset'))
|
||||||
|
.set('Origin', config.get('url'))
|
||||||
|
.set('Accept', 'application/json')
|
||||||
|
.send({
|
||||||
|
passwordreset: [{
|
||||||
|
token: token,
|
||||||
|
newPassword: 'abcdefgh',
|
||||||
|
ne2Password: 'abcdefgh'
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect('Cache-Control', testUtils.cacheRules.private)
|
||||||
|
.expect(200)
|
||||||
|
.end(function (err) {
|
||||||
|
if (err) {
|
||||||
|
return done(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(done);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,11 +1,14 @@
|
||||||
var supertest = require('supertest'),
|
var supertest = require('supertest'),
|
||||||
should = require('should'),
|
should = require('should'),
|
||||||
testUtils = require('../../../utils'),
|
testUtils = require('../../../utils'),
|
||||||
|
db = require('../../../../../core/server/data/db'),
|
||||||
config = require('../../../../../core/server/config'),
|
config = require('../../../../../core/server/config'),
|
||||||
ghost = testUtils.startGhost,
|
ghost = testUtils.startGhost,
|
||||||
failedLoginAttempt,
|
failedLoginAttempt,
|
||||||
count,
|
count,
|
||||||
|
checkBruteTable,
|
||||||
tooManyFailedLoginAttempts,
|
tooManyFailedLoginAttempts,
|
||||||
|
successLoginAttempt,
|
||||||
request;
|
request;
|
||||||
|
|
||||||
describe('Spam Prevention API', function () {
|
describe('Spam Prevention API', function () {
|
||||||
|
@ -57,7 +60,8 @@ describe('Spam Prevention API', function () {
|
||||||
password: 'wrong-password',
|
password: 'wrong-password',
|
||||||
client_id: 'ghost-admin',
|
client_id: 'ghost-admin',
|
||||||
client_secret: 'not_available'
|
client_secret: 'not_available'
|
||||||
}).expect('Content-Type', /json/)
|
})
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
.expect(429)
|
.expect(429)
|
||||||
.end(function (err, res) {
|
.end(function (err, res) {
|
||||||
if (err) {
|
if (err) {
|
||||||
|
@ -84,12 +88,14 @@ describe('Spam Prevention API', function () {
|
||||||
password: 'wrong-password',
|
password: 'wrong-password',
|
||||||
client_id: 'ghost-admin',
|
client_id: 'ghost-admin',
|
||||||
client_secret: 'not_available'
|
client_secret: 'not_available'
|
||||||
}).expect('Content-Type', /json/)
|
})
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
.expect(401)
|
.expect(401)
|
||||||
.end(function (err) {
|
.end(function (err) {
|
||||||
if (err) {
|
if (err) {
|
||||||
return done(err);
|
return done(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (count < config.get('spam:user_login:freeRetries') + 1) {
|
if (count < config.get('spam:user_login:freeRetries') + 1) {
|
||||||
return failedLoginAttempt(email);
|
return failedLoginAttempt(email);
|
||||||
}
|
}
|
||||||
|
@ -160,4 +166,66 @@ describe('Spam Prevention API', function () {
|
||||||
|
|
||||||
failedLoginAttempt(owner.email);
|
failedLoginAttempt(owner.email);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Ensure reset works: password grant type', function (done) {
|
||||||
|
count = 0;
|
||||||
|
|
||||||
|
checkBruteTable = function checkBruteTable() {
|
||||||
|
return db.knex('brute').select();
|
||||||
|
};
|
||||||
|
|
||||||
|
successLoginAttempt = function successLoginAttempt(email) {
|
||||||
|
request.post(testUtils.API.getApiQuery('authentication/token'))
|
||||||
|
.set('Origin', config.get('url'))
|
||||||
|
.send({
|
||||||
|
grant_type: 'password',
|
||||||
|
username: email,
|
||||||
|
password: 'Sl1m3rson',
|
||||||
|
client_id: 'ghost-admin',
|
||||||
|
client_secret: 'not_available'
|
||||||
|
}).expect('Content-Type', /json/)
|
||||||
|
.expect(200)
|
||||||
|
.end(function (err) {
|
||||||
|
if (err) {
|
||||||
|
return done(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
checkBruteTable()
|
||||||
|
.then(function (rows) {
|
||||||
|
// if reset works, the key is deleted and only one key remains in the database
|
||||||
|
// the one key is the key for global block
|
||||||
|
rows.length.should.eql(1);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
successLoginAttempt(email);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
failedLoginAttempt(owner.email);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -348,7 +348,7 @@ describe('Authentication API', function () {
|
||||||
}).catch(done);
|
}).catch(done);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should allow a password reset', function (done) {
|
it('should not allow a password reset', function (done) {
|
||||||
AuthAPI.resetPassword(testReset).then(function () {
|
AuthAPI.resetPassword(testReset).then(function () {
|
||||||
done(new Error('password reset did not fail on token validation'));
|
done(new Error('password reset did not fail on token validation'));
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
|
|
Loading…
Add table
Reference in a new issue