зеркало из https://github.com/mozilla/fxa.git
Merge pull request #16883 from mozilla/fxa-9485-otp-customs
feat(customs): add rate limiting for password reset OTPs
This commit is contained in:
Коммит
7408010048
|
@ -531,8 +531,7 @@ module.exports = function (
|
|||
};
|
||||
const email = payload.email;
|
||||
|
||||
// TODO FXA-9485
|
||||
// await customs.check(request, email, 'passwordForgotSendOtp');
|
||||
await customs.check(request, email, 'passwordForgotSendOtp');
|
||||
|
||||
request.validateMetricsContext();
|
||||
|
||||
|
|
|
@ -64,6 +64,8 @@ const SMS_SENDING_ACTION = {
|
|||
// are not associated with an email address or uid.
|
||||
const ACCOUNT_ACCESS_ACTION = new Set(['consumeSigninCode']);
|
||||
|
||||
const RESET_PASSWORD_OTP_SENDING_ACTION = { passwordForgotSendOtp: true };
|
||||
|
||||
module.exports = {
|
||||
isPasswordCheckingAction: function (action) {
|
||||
return PASSWORD_CHECKING_ACTION[action];
|
||||
|
@ -88,4 +90,8 @@ module.exports = {
|
|||
isAccountAccessAction(action) {
|
||||
return ACCOUNT_ACCESS_ACTION.has(action);
|
||||
},
|
||||
|
||||
isResetPasswordOtpSendingAction(action) {
|
||||
return RESET_PASSWORD_OTP_SENDING_ACTION[action];
|
||||
},
|
||||
};
|
||||
|
|
|
@ -173,6 +173,26 @@ module.exports = function (fs, path, url, convict) {
|
|||
format: 'nat',
|
||||
env: 'MAX_ACCOUNT_ACCESS',
|
||||
},
|
||||
passwordResetOtpLimits: {
|
||||
maxPasswordResetOtpEmails: {
|
||||
doc: 'Number of OTP email for an account email or from an IP can request before rate limiting',
|
||||
default: 5,
|
||||
format: 'nat',
|
||||
env: 'PASSWORD_RESET_OTP_EMAIL_LIMIT',
|
||||
},
|
||||
passwordResetOtpEmailRequestWindowSeconds: {
|
||||
doc: 'Number of seconds when the max number of OTP email requests is allowed',
|
||||
default: 600,
|
||||
format: 'nat',
|
||||
env: 'PASSWORD_RESET_OTP_EMAIL_REQUEST_WINDOW_SECONDS',
|
||||
},
|
||||
passwordResetOtpRateLimitIntervalSeconds: {
|
||||
doc: 'Number of seconds to wait until password reset OTP requests are allowed again',
|
||||
default: 1800,
|
||||
format: 'nat',
|
||||
env: 'PASSWORD_RESET_OTP_EMAIL_RATE_LIMIT_SECONDS',
|
||||
},
|
||||
},
|
||||
},
|
||||
cache: {
|
||||
recordLifetimeSeconds: {
|
||||
|
|
|
@ -29,6 +29,7 @@ module.exports = function (limits, now) {
|
|||
rec.lf = object.lf || rec.lf; // timestamps of when login failed
|
||||
rec.pr = object.pr; // timestamp of the last password reset
|
||||
rec.ub = object.ub || rec.ub;
|
||||
rec.os = object.os || []; // timpstamp of password reset OTP email request
|
||||
return rec;
|
||||
};
|
||||
|
||||
|
@ -189,9 +190,11 @@ module.exports = function (limits, now) {
|
|||
this.pr = now();
|
||||
};
|
||||
|
||||
EmailRecord.prototype.retryAfter = function () {
|
||||
EmailRecord.prototype.retryAfter = function (rlIntervalMs) {
|
||||
const intervalMs = rlIntervalMs || limits.rateLimitIntervalMs;
|
||||
|
||||
var rateLimitAfter = Math.ceil(
|
||||
((this.rl || 0) + limits.rateLimitIntervalMs - now()) / 1000
|
||||
((this.rl || 0) + intervalMs - now()) / 1000
|
||||
);
|
||||
var banAfter = Math.ceil(
|
||||
((this.bk || 0) + limits.blockIntervalMs - now()) / 1000
|
||||
|
@ -229,6 +232,22 @@ module.exports = function (limits, now) {
|
|||
this.lf = this.lf.slice(i + 1);
|
||||
};
|
||||
|
||||
EmailRecord.prototype.addPasswordResetOtp = function () {
|
||||
this.os.push(now());
|
||||
};
|
||||
|
||||
EmailRecord.prototype.trimPasswordResetOtps = function (now) {
|
||||
this.os = this.os.filter(
|
||||
(otpReqTime) =>
|
||||
otpReqTime > now - limits.passwordResetOtpEmailRequestWindowMs
|
||||
);
|
||||
};
|
||||
|
||||
EmailRecord.prototype.isOverPasswordResetOtpLimit = function () {
|
||||
this.trimPasswordResetOtps(now());
|
||||
return this.os.length > limits.maxPasswordResetOtpEmails;
|
||||
};
|
||||
|
||||
EmailRecord.prototype.update = function (action, unblock) {
|
||||
// Reject immediately if they've been explicitly blocked.
|
||||
if (this.isBlocked()) {
|
||||
|
@ -290,6 +309,22 @@ module.exports = function (limits, now) {
|
|||
return this.retryAfter();
|
||||
}
|
||||
|
||||
if (actions.isResetPasswordOtpSendingAction(action)) {
|
||||
const otpEmailRateLimited = !!(
|
||||
this.rl &&
|
||||
now() - this.rl < limits.passwordResetOtpEmailRateLimitIntervalMs
|
||||
);
|
||||
if (otpEmailRateLimited || this.shouldBlock()) {
|
||||
return this.retryAfter(limits.passwordResetOtpEmailRateLimitIntervalMs);
|
||||
}
|
||||
this.addPasswordResetOtp();
|
||||
if (this.isOverPasswordResetOtpLimit()) {
|
||||
this.rateLimit();
|
||||
this.os = [];
|
||||
return this.retryAfter(limits.passwordResetOtpEmailRateLimitIntervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
// Everything else is allowed through.
|
||||
return 0;
|
||||
};
|
||||
|
|
|
@ -28,6 +28,7 @@ module.exports = function (limits, now) {
|
|||
rec.sms = object.sms || []; // timestamp+sms when sms sent
|
||||
rec.aa = object.aa || []; // timestamp when account access was attempted
|
||||
rec.rl = object.rl; // timestamp when the IP address was rate-limited
|
||||
rec.os = object.os || []; // timpstamp of password reset OTP email request
|
||||
return rec;
|
||||
};
|
||||
|
||||
|
@ -74,6 +75,22 @@ module.exports = function (limits, now) {
|
|||
this.lf = this._trim(now, this.lf, limits.maxBadLoginsPerIp);
|
||||
};
|
||||
|
||||
IpRecord.prototype.addPasswordResetOtp = function () {
|
||||
this.os.push(now());
|
||||
};
|
||||
|
||||
IpRecord.prototype.trimPasswordResetOtps = function (now) {
|
||||
this.os = this.os.filter(
|
||||
(otpReqTime) =>
|
||||
otpReqTime > now - limits.passwordResetOtpEmailRequestWindowMs
|
||||
);
|
||||
};
|
||||
|
||||
IpRecord.prototype.isOverPasswordResetOtpLimit = function () {
|
||||
this.trimPasswordResetOtps(now());
|
||||
return this.os.length > limits.maxPasswordResetOtpEmails;
|
||||
};
|
||||
|
||||
IpRecord.prototype.isOverVerifyCodes = function () {
|
||||
this.trimVerifyCodes(now());
|
||||
// Limit based on number of unique emails accessed by this IP.
|
||||
|
@ -224,9 +241,11 @@ module.exports = function (limits, now) {
|
|||
this.aa = [];
|
||||
};
|
||||
|
||||
IpRecord.prototype.retryAfter = function () {
|
||||
IpRecord.prototype.retryAfter = function (rlIntervalMs) {
|
||||
const intervalMs = rlIntervalMs || limits.ipRateLimitBanDurationMs;
|
||||
|
||||
var rateLimitAfter = Math.ceil(
|
||||
((this.rl || 0) + limits.ipRateLimitBanDurationMs - now()) / 1000
|
||||
((this.rl || 0) + intervalMs - now()) / 1000
|
||||
);
|
||||
var banAfter = Math.ceil(
|
||||
((this.bk || 0) + limits.blockIntervalMs - now()) / 1000
|
||||
|
@ -291,6 +310,22 @@ module.exports = function (limits, now) {
|
|||
}
|
||||
}
|
||||
|
||||
if (actions.isResetPasswordOtpSendingAction(action)) {
|
||||
const otpEmailRateLimited = !!(
|
||||
this.rl &&
|
||||
now() - this.rl < limits.passwordResetOtpEmailRateLimitIntervalMs
|
||||
);
|
||||
if (otpEmailRateLimited || this.shouldBlock()) {
|
||||
return this.retryAfter(limits.passwordResetOtpEmailRateLimitIntervalMs);
|
||||
}
|
||||
this.addPasswordResetOtp();
|
||||
if (this.isOverPasswordResetOtpLimit()) {
|
||||
this.rateLimit();
|
||||
this.os = [];
|
||||
return this.retryAfter(limits.passwordResetOtpEmailRateLimitIntervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
return this.retryAfter();
|
||||
};
|
||||
|
||||
|
|
|
@ -46,6 +46,15 @@ module.exports = (config, Settings, log) => {
|
|||
this.smsRateLimitIntervalSeconds = this.smsRateLimit.limitIntervalSeconds;
|
||||
this.smsRateLimitIntervalMs = this.smsRateLimitIntervalSeconds * 1000;
|
||||
this.maxAccountAccess = settings.maxAccountAccess;
|
||||
this.passwordResetOtpLimits = settings.passwordResetOtpLimits || {};
|
||||
this.maxPasswordResetOtpEmails =
|
||||
settings.passwordResetOtpLimits.maxPasswordResetOtpEmails;
|
||||
this.passwordResetOtpEmailRequestWindowMs =
|
||||
settings.passwordResetOtpLimits
|
||||
.passwordResetOtpEmailRequestWindowSeconds * 1000;
|
||||
this.passwordResetOtpEmailRateLimitIntervalMs =
|
||||
settings.passwordResetOtpLimits
|
||||
.passwordResetOtpRateLimitIntervalSeconds * 1000;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
|
|
@ -41,6 +41,15 @@ var config = {
|
|||
Number(process.env.UID_RATE_LIMIT_BAN_DURATION_SECONDS) || 60 * 15,
|
||||
maxChecks: Number(process.env.UID_RATE_LIMIT) || 3,
|
||||
},
|
||||
passwordResetOtpLimits: {
|
||||
maxPasswordResetOtpEmails:
|
||||
Number(process.env.PASSWORD_RESET_OTP_EMAIL_LIMIT) || 5,
|
||||
passwordResetOtpEmailRequestWindowSeconds:
|
||||
Number(process.env.PASSWORD_RESET_OTP_EMAIL_REQUEST_WINDOW_SECONDS) ||
|
||||
600,
|
||||
passwordResetOtpRateLimitIntervalSeconds:
|
||||
Number(process.env.PASSWORD_RESET_OTP_EMAIL_RATE_LIMIT_SECONDS) || 1800,
|
||||
},
|
||||
},
|
||||
requestChecks: {
|
||||
treatEveryoneWithSuspicion: false,
|
||||
|
|
|
@ -5,8 +5,9 @@
|
|||
var test = require('tap').test;
|
||||
var emailRecord = require('../../lib/email_record');
|
||||
|
||||
let nowTimestamp;
|
||||
function now() {
|
||||
return 1000; // old school
|
||||
return nowTimestamp || 1000; // old school
|
||||
}
|
||||
|
||||
function simpleEmailRecord() {
|
||||
|
@ -18,6 +19,9 @@ function simpleEmailRecord() {
|
|||
maxEmails: 2,
|
||||
maxUnblockAttempts: 2,
|
||||
maxBadLoginsPerEmail: 2,
|
||||
maxPasswordResetOtpEmails: 2,
|
||||
passwordResetOtpEmailRequestWindowMs: 2000,
|
||||
passwordResetOtpEmailRateLimitIntervalMs: 5000,
|
||||
};
|
||||
return new (emailRecord(limits, now))();
|
||||
}
|
||||
|
@ -174,6 +178,7 @@ test('retryAfter works', function (t) {
|
|||
t.equal(er.retryAfter(), 0, 'just expired blocks can be retried immediately');
|
||||
er.rl = 6000;
|
||||
t.equal(er.retryAfter(), 1, 'unexpired blocks can be retried in a bit');
|
||||
t.equal(er.retryAfter(10000), 6, 'optional rate limit interval');
|
||||
|
||||
delete er.rl;
|
||||
t.equal(er.retryAfter(), 0, 'unblocked records can be retried now');
|
||||
|
@ -265,6 +270,18 @@ test('update works', function (t) {
|
|||
t.equal(er.update('accountCreate'), 2, 'email action is blocked');
|
||||
t.equal(er.update('accountLogin'), 2, 'non-email action is blocked');
|
||||
|
||||
er = simpleEmailRecord();
|
||||
er.os = [];
|
||||
nowTimestamp = 1001;
|
||||
er.update('passwordForgotSendOtp');
|
||||
nowTimestamp = 1003;
|
||||
let res = er.update('passwordForgotSendOtp');
|
||||
t.equal(res === 0, true, 'account is not rate limited');
|
||||
nowTimestamp = 2001;
|
||||
res = er.update('passwordForgotSendOtp');
|
||||
t.equal(res > 0, true, 'account is rate limited');
|
||||
nowTimestamp = null;
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
|
|
|
@ -18,7 +18,10 @@ function simpleIpRecord() {
|
|||
ipRateLimitBanDurationMs: 1000,
|
||||
maxBadLoginsPerIp: 3,
|
||||
maxAccountStatusCheck: 1,
|
||||
badLoginErrnoWeights: { '102': 2 },
|
||||
badLoginErrnoWeights: { 102: 2 },
|
||||
maxPasswordResetOtpEmails: 2,
|
||||
passwordResetOtpEmailRequestWindowMs: 2000,
|
||||
passwordResetOtpEmailRateLimitIntervalMs: 5000,
|
||||
};
|
||||
return new (ipRecord(limits, now))();
|
||||
}
|
||||
|
@ -99,6 +102,23 @@ test('retryAfter block works', function (t) {
|
|||
t.end();
|
||||
});
|
||||
|
||||
test('retryAfter rate limiting with optional interval', function (t) {
|
||||
const ir = simpleIpRecord();
|
||||
t.equal(ir.retryAfter(), 0, 'unblocked records can be retried now');
|
||||
ir.rl = 240 * 1000;
|
||||
t.equal(
|
||||
ir.retryAfter(),
|
||||
1,
|
||||
'unblocked records can be retried after default interval'
|
||||
);
|
||||
t.equal(
|
||||
ir.retryAfter(5000),
|
||||
5,
|
||||
'unblocked records can be retried after given interval'
|
||||
);
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('parse works', function (t) {
|
||||
var ir = simpleIpRecord();
|
||||
t.equal(ir.shouldBlock(), false, 'original object is not blocked');
|
||||
|
@ -139,6 +159,19 @@ test('action accountStatusCheck rate-limit works', function (t) {
|
|||
t.end();
|
||||
});
|
||||
|
||||
test('action passwordForgotSendOtp rate-limit works', function (t) {
|
||||
const ir = simpleIpRecord();
|
||||
ir.os = [];
|
||||
|
||||
let res = ir.update('passwordForgotSendOtp', 'test1@example.com');
|
||||
t.equal(res, 0, 'rate-limit not exceeded');
|
||||
res = ir.update('passwordForgotSendOtp', 'test1@example.com');
|
||||
t.equal(res, 0, 'rate-limit not exceeded using same email');
|
||||
res = ir.update('passwordForgotSendOtp', 'test2@example.com');
|
||||
t.equal(res, 5, 'rate-limit exceeded using different email');
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('getMinLifetimeMS works', function (t) {
|
||||
var limits = {
|
||||
blockIntervalMs: 10,
|
||||
|
|
|
@ -14,6 +14,11 @@ const config = {
|
|||
limitIntervalSeconds: 1800,
|
||||
maxSms: 5,
|
||||
},
|
||||
passwordResetOtpLimits: {
|
||||
maxPasswordResetOtpEmails: 5,
|
||||
passwordResetOtpEmailRequestWindowSeconds: 600,
|
||||
passwordResetOtpRateLimitIntervalSeconds: 1800,
|
||||
},
|
||||
},
|
||||
requestChecks: {
|
||||
treatEveryoneWithSuspicion: true,
|
||||
|
|
|
@ -0,0 +1,105 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
const test = require('tap').test;
|
||||
const TestServer = require('../test_server');
|
||||
const Promise = require('bluebird');
|
||||
const restifyClients = Promise.promisifyAll(require('restify-clients'));
|
||||
const mcHelper = require('../cache-helper');
|
||||
const testUtils = require('../utils');
|
||||
|
||||
const config = require('../../lib/config').getProperties();
|
||||
config.limits.rateLimitIntervalSeconds = 1;
|
||||
config.limits.ipRateLimitIntervalSeconds = 1;
|
||||
config.limits.passwordResetOtpLimits.maxPasswordResetOtpEmails = 2;
|
||||
config.limits.passwordResetOtpLimits.passwordResetOtpRateLimitIntervalSeconds = 2;
|
||||
|
||||
const testServer = new TestServer(config);
|
||||
|
||||
const client = restifyClients.createJsonClient({
|
||||
url: 'http://localhost:' + config.listen.port,
|
||||
});
|
||||
|
||||
const action = 'passwordForgotSendOtp';
|
||||
|
||||
Promise.promisifyAll(client, { multiArgs: true });
|
||||
|
||||
test('startup', async function (t) {
|
||||
await testServer.start();
|
||||
t.type(testServer.server, 'object', 'test server was started');
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('clear everything', function (t) {
|
||||
mcHelper.clearEverything(function (err) {
|
||||
t.notOk(err, 'no errors were returned');
|
||||
t.end();
|
||||
});
|
||||
});
|
||||
|
||||
test('/check passwordForgotSendOtp by email', async (t) => {
|
||||
const email = testUtils.randomEmail();
|
||||
let ip = testUtils.randomIp();
|
||||
|
||||
let response = await client.postAsync('/check', { ip, email, action });
|
||||
let [_, res, obj] = response;
|
||||
t.equal(res.statusCode, 200, 'returns a 200');
|
||||
t.equal(obj.block, false, 'not rate limited');
|
||||
|
||||
// same email different ip
|
||||
ip = testUtils.randomIp();
|
||||
|
||||
response = await client.postAsync('/check', { ip, email, action });
|
||||
[_, res, obj] = response;
|
||||
t.equal(res.statusCode, 200, 'returns a 200');
|
||||
t.equal(obj.block, false, 'not rate limited');
|
||||
|
||||
ip = testUtils.randomIp();
|
||||
|
||||
response = await client.postAsync('/check', { ip, email, action });
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
[_, res, obj] = response;
|
||||
t.equal(res.statusCode, 200, 'returns a 200');
|
||||
t.equal(obj.block, true, 'rate limited');
|
||||
t.equal(obj.retryAfter, 2, 'rate limit retry amount');
|
||||
});
|
||||
|
||||
test('clear everything', function (t) {
|
||||
mcHelper.clearEverything(function (err) {
|
||||
t.notOk(err, 'no errors were returned');
|
||||
t.end();
|
||||
});
|
||||
});
|
||||
|
||||
test('/check passwordForgotSendOtp by ip address', async (t) => {
|
||||
const ip = testUtils.randomIp();
|
||||
let email = testUtils.randomEmail();
|
||||
|
||||
let response = await client.postAsync('/check', { ip, email, action });
|
||||
let [_, res, obj] = response;
|
||||
t.equal(res.statusCode, 200, 'returns a 200');
|
||||
t.equal(obj.block, false, 'not rate limited');
|
||||
|
||||
// same ip different email
|
||||
email = testUtils.randomEmail();
|
||||
|
||||
response = await client.postAsync('/check', { ip, email, action });
|
||||
[_, res, obj] = response;
|
||||
t.equal(res.statusCode, 200, 'returns a 200');
|
||||
t.equal(obj.block, false, 'not rate limited');
|
||||
|
||||
email = testUtils.randomEmail();
|
||||
|
||||
response = await client.postAsync('/check', { ip, email, action });
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
[_, res, obj] = response;
|
||||
t.equal(res.statusCode, 200, 'returns a 200');
|
||||
t.equal(obj.block, true, 'rate limited');
|
||||
t.equal(obj.retryAfter, 2, 'rate limit retry amount');
|
||||
});
|
||||
|
||||
test('teardown', async function (t) {
|
||||
await testServer.stop();
|
||||
t.end();
|
||||
});
|
Загрузка…
Ссылка в новой задаче