mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-02-10 23:36:14 -05:00
Reset magic link rate limiting upon successful login (#15345)
refs https://github.com/TryGhost/Team/issues/1771 We don't have access to `req.brute.reset` due to the way the flow works, we have one endpoint which sends an email with a magic link, and another route which handles the login. We don't want to apply brute force protection to both because our rate limiting is designed for API requests not web page visits (which is how login is handled). Because of this we require access to the underlying ExpressBrute instance exposed by the spam-protection module, so that we can perform the reset.
This commit is contained in:
parent
4f8526fd77
commit
e4cbb3d24d
5 changed files with 109 additions and 5 deletions
|
@ -3,6 +3,7 @@ const logging = require('@tryghost/logging');
|
||||||
const membersService = require('./service');
|
const membersService = require('./service');
|
||||||
const models = require('../../models');
|
const models = require('../../models');
|
||||||
const urlUtils = require('../../../shared/url-utils');
|
const urlUtils = require('../../../shared/url-utils');
|
||||||
|
const spamPrevention = require('../../web/shared/middleware/api/spam-prevention');
|
||||||
const {formattedMemberResponse} = require('./utils');
|
const {formattedMemberResponse} = require('./utils');
|
||||||
|
|
||||||
// @TODO: This piece of middleware actually belongs to the frontend, not to the member app
|
// @TODO: This piece of middleware actually belongs to the frontend, not to the member app
|
||||||
|
@ -155,6 +156,7 @@ const createSessionFromMagicLink = async function (req, res, next) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const member = await membersService.ssr.exchangeTokenForSession(req, res);
|
const member = await membersService.ssr.exchangeTokenForSession(req, res);
|
||||||
|
spamPrevention.membersAuth().reset(req.ip, `${member.email}login`);
|
||||||
const subscriptions = member && member.subscriptions || [];
|
const subscriptions = member && member.subscriptions || [];
|
||||||
|
|
||||||
const action = req.query.action;
|
const action = req.query.action;
|
||||||
|
|
|
@ -36,6 +36,7 @@ let privateBlogInstance;
|
||||||
let globalResetInstance;
|
let globalResetInstance;
|
||||||
let globalBlockInstance;
|
let globalBlockInstance;
|
||||||
let userLoginInstance;
|
let userLoginInstance;
|
||||||
|
let membersAuthInstance;
|
||||||
let userResetInstance;
|
let userResetInstance;
|
||||||
let contentApiKeyInstance;
|
let contentApiKeyInstance;
|
||||||
|
|
||||||
|
@ -121,6 +122,36 @@ const globalReset = () => {
|
||||||
return globalResetInstance;
|
return globalResetInstance;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const membersAuth = () => {
|
||||||
|
const ExpressBrute = require('express-brute');
|
||||||
|
const BruteKnex = require('brute-knex');
|
||||||
|
const db = require('../../../../data/db');
|
||||||
|
|
||||||
|
store = store || new BruteKnex({
|
||||||
|
tablename: 'brute',
|
||||||
|
createTable: false,
|
||||||
|
knex: db.knex
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!membersAuthInstance) {
|
||||||
|
membersAuthInstance = new ExpressBrute(store,
|
||||||
|
extend({
|
||||||
|
attachResetToRequest: true,
|
||||||
|
failCallback(req, res, next, nextValidRequestDate) {
|
||||||
|
return next(new errors.TooManyRequestsError({
|
||||||
|
message: `Too many sign-in attempts try again in ${moment(nextValidRequestDate).fromNow(true)}`,
|
||||||
|
context: tpl(messages.tooManySigninAttempts.context),
|
||||||
|
help: tpl(messages.tooManySigninAttempts.context)
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
handleStoreError: handleStoreError
|
||||||
|
}, pick(spamUserLogin, spamConfigKeys))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return membersAuthInstance;
|
||||||
|
};
|
||||||
|
|
||||||
// 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
|
||||||
// and rising to a week in a fibonnaci sequence
|
// and rising to a week in a fibonnaci sequence
|
||||||
// The user+IP count is reset when on successful login
|
// The user+IP count is reset when on successful login
|
||||||
|
@ -249,6 +280,7 @@ module.exports = {
|
||||||
globalBlock: globalBlock,
|
globalBlock: globalBlock,
|
||||||
globalReset: globalReset,
|
globalReset: globalReset,
|
||||||
userLogin: userLogin,
|
userLogin: userLogin,
|
||||||
|
membersAuth: membersAuth,
|
||||||
userReset: userReset,
|
userReset: userReset,
|
||||||
privateBlog: privateBlog,
|
privateBlog: privateBlog,
|
||||||
contentApiKey: contentApiKey
|
contentApiKey: contentApiKey
|
||||||
|
|
|
@ -86,7 +86,7 @@ module.exports = {
|
||||||
/**
|
/**
|
||||||
*/
|
*/
|
||||||
membersAuth(req, res, next) {
|
membersAuth(req, res, next) {
|
||||||
return spamPrevention.userLogin().getMiddleware({
|
return spamPrevention.membersAuth().getMiddleware({
|
||||||
ignoreIP: false,
|
ignoreIP: false,
|
||||||
key(_req, _res, _next) {
|
key(_req, _res, _next) {
|
||||||
if (_req.body.email) {
|
if (_req.body.email) {
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
const {agentProvider, mockManager, fixtureManager} = require('../../utils/e2e-framework');
|
const {agentProvider, mockManager, fixtureManager, dbUtils, configUtils} = require('../../utils/e2e-framework');
|
||||||
const models = require('../../../core/server/models');
|
const models = require('../../../core/server/models');
|
||||||
const assert = require('assert');
|
const assert = require('assert');
|
||||||
require('should');
|
require('should');
|
||||||
|
@ -33,6 +33,7 @@ describe('Members Signin', function () {
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
|
mockManager.mockMail();
|
||||||
mockManager.mockStripe();
|
mockManager.mockStripe();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -119,6 +120,75 @@ describe('Members Signin', function () {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Rate limiting', function () {
|
||||||
|
it('Will clear rate limits for members auth', async function () {
|
||||||
|
await dbUtils.truncate('brute');
|
||||||
|
// +1 because this is a retry count, so we have one request + the retries, then blocked
|
||||||
|
const userLoginRateLimit = configUtils.config.get('spam').user_login.freeRetries + 1;
|
||||||
|
|
||||||
|
for (let i = 0; i < userLoginRateLimit; i++) {
|
||||||
|
await membersAgent.post('/api/send-magic-link')
|
||||||
|
.body({
|
||||||
|
email: 'rate-limiting-test-1@test.com',
|
||||||
|
emailType: 'signup'
|
||||||
|
});
|
||||||
|
|
||||||
|
await membersAgent.post('/api/send-magic-link')
|
||||||
|
.body({
|
||||||
|
email: 'rate-limiting-test-2@test.com',
|
||||||
|
emailType: 'signup'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now we've been rate limited
|
||||||
|
await membersAgent.post('/api/send-magic-link')
|
||||||
|
.body({
|
||||||
|
email: 'rate-limiting-test-1@test.com',
|
||||||
|
emailType: 'signup'
|
||||||
|
})
|
||||||
|
.expectStatus(429);
|
||||||
|
|
||||||
|
// Now we've been rate limited
|
||||||
|
await membersAgent.post('/api/send-magic-link')
|
||||||
|
.body({
|
||||||
|
email: 'rate-limiting-test-2@test.com',
|
||||||
|
emailType: 'signup'
|
||||||
|
})
|
||||||
|
.expectStatus(429);
|
||||||
|
|
||||||
|
// Get one of the magic link emails
|
||||||
|
const mail = mockManager.assert.sentEmail({
|
||||||
|
to: 'rate-limiting-test-1@test.com',
|
||||||
|
subject: /Complete your sign up to Ghost!/
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get link from email
|
||||||
|
const [url] = mail.text.match(/https?:\/\/[^\s]+/);
|
||||||
|
|
||||||
|
const magicLink = new URL(url);
|
||||||
|
|
||||||
|
// Login
|
||||||
|
await membersAgent.get(magicLink.pathname + magicLink.search);
|
||||||
|
|
||||||
|
// The first member has been un ratelimited
|
||||||
|
await membersAgent.post('/api/send-magic-link')
|
||||||
|
.body({
|
||||||
|
email: 'rate-limiting-test-1@test.com',
|
||||||
|
emailType: 'signup'
|
||||||
|
})
|
||||||
|
.expectEmptyBody()
|
||||||
|
.expectStatus(201);
|
||||||
|
|
||||||
|
// The second is still rate limited
|
||||||
|
await membersAgent.post('/api/send-magic-link')
|
||||||
|
.body({
|
||||||
|
email: 'rate-limiting-test-2@test.com',
|
||||||
|
emailType: 'signup'
|
||||||
|
})
|
||||||
|
.expectStatus(429);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Member attribution', function () {
|
describe('Member attribution', function () {
|
||||||
it('Will create a member attribution if magic link contains an attribution source', async function () {
|
it('Will create a member attribution if magic link contains an attribution source', async function () {
|
||||||
const email = 'non-existent-member@test.com';
|
const email = 'non-existent-member@test.com';
|
||||||
|
|
|
@ -107,7 +107,7 @@ describe('Members Service Middleware', function () {
|
||||||
req.query = {token: 'test', action: 'signup'};
|
req.query = {token: 'test', action: 'signup'};
|
||||||
|
|
||||||
// Fake token handling failure
|
// Fake token handling failure
|
||||||
membersService.ssr.exchangeTokenForSession.resolves();
|
membersService.ssr.exchangeTokenForSession.resolves({});
|
||||||
|
|
||||||
// Fake welcome page for free tier
|
// Fake welcome page for free tier
|
||||||
models.Product.findOne.resolves({
|
models.Product.findOne.resolves({
|
||||||
|
@ -153,7 +153,7 @@ describe('Members Service Middleware', function () {
|
||||||
req.query = {token: 'test', action: 'signin', r: 'https://site.com/blah/my-post/'};
|
req.query = {token: 'test', action: 'signin', r: 'https://site.com/blah/my-post/'};
|
||||||
|
|
||||||
// Fake token handling failure
|
// Fake token handling failure
|
||||||
membersService.ssr.exchangeTokenForSession.resolves();
|
membersService.ssr.exchangeTokenForSession.resolves({});
|
||||||
|
|
||||||
// Call the middleware
|
// Call the middleware
|
||||||
await membersMiddleware.createSessionFromMagicLink(req, res, next);
|
await membersMiddleware.createSessionFromMagicLink(req, res, next);
|
||||||
|
@ -169,7 +169,7 @@ describe('Members Service Middleware', function () {
|
||||||
req.query = {token: 'test', action: 'signin', r: 'https://external.com/whatever/'};
|
req.query = {token: 'test', action: 'signin', r: 'https://external.com/whatever/'};
|
||||||
|
|
||||||
// Fake token handling failure
|
// Fake token handling failure
|
||||||
membersService.ssr.exchangeTokenForSession.resolves();
|
membersService.ssr.exchangeTokenForSession.resolves({});
|
||||||
|
|
||||||
// Call the middleware
|
// Call the middleware
|
||||||
await membersMiddleware.createSessionFromMagicLink(req, res, next);
|
await membersMiddleware.createSessionFromMagicLink(req, res, next);
|
||||||
|
|
Loading…
Add table
Reference in a new issue