packages/fxa-customs-server/lib/email_record.js (338 lines of code) (raw):
/* 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/. */
var actions = require('./actions');
// Keep track of events tied to just email addresses
module.exports = function (limits, now) {
now = now || Date.now;
function EmailRecord() {
// Code verification
this.vc = [];
// Email Sent
this.xs = [];
// SMS send
this.sms = [];
// Twilio Request
this.twilio = [];
// Unblock Codes
this.ub = [];
// Failed Logins
this.lf = [];
}
EmailRecord.parse = function (object) {
var rec = new EmailRecord();
object = object || {};
rec.bk = object.bk; // timestamp when the account was blocked
rec.su = object.su; // timestamp when the account was suspected
rec.di = object.di; // timestamp when the account was disabled
rec.rl = object.rl; // timestamp when the account was rate-limited
rec.vc = object.vc || rec.vc; // timestamps when code verifications happened
rec.xs = object.xs || rec.xs; // timestamps when emails were sent
rec.sms = object.sms || rec.sms; // timestamps when sms were sent
rec.twilio = object.twilio || rec.twilio; // timestamp when twilio requests were made
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
rec.ov = object.ov || []; // timpstamp of password reset OTP verification request
return rec;
};
EmailRecord.prototype.getMinLifetimeMS = function () {
return Math.max(limits.rateLimitIntervalMs, limits.blockIntervalMs);
};
EmailRecord.prototype.isOverEmailLimit = function () {
this.trimHits(now());
return this.xs.length > limits.maxEmails;
};
EmailRecord.prototype.trimHits = function (now) {
if (this.xs.length === 0) {
return;
}
// xs is naturally ordered from oldest to newest
// and we only need to keep up to limits.maxEmails + 1
var i = this.xs.length - 1;
var n = 0;
var hit = this.xs[i];
while (hit > now - limits.rateLimitIntervalMs && n <= limits.maxEmails) {
hit = this.xs[--i];
n++;
}
this.xs = this.xs.slice(i + 1);
};
EmailRecord.prototype.addHit = function () {
this.xs.push(now());
};
EmailRecord.prototype.isOverVerifyCodes = function () {
this.trimVerifyCodes(now());
return this.vc.length > limits.maxVerifyCodes;
};
EmailRecord.prototype.trimVerifyCodes = function (now) {
if (this.vc.length === 0) {
return;
}
// vc is naturally ordered from oldest to newest
// and we only need to keep up to limits.maxVerifyCodes + 1
var i = this.vc.length - 1;
var n = 0;
var hit = this.vc[i];
while (
hit > now - limits.rateLimitIntervalMs &&
n <= limits.maxVerifyCodes
) {
hit = this.vc[--i];
n++;
}
this.vc = this.vc.slice(i + 1);
};
EmailRecord.prototype.addVerifyCode = function () {
this.vc.push(now());
};
EmailRecord.prototype.isOverSmsLimit = function () {
this.trimSmsRequests(now());
return this.sms.length > limits.maxSms;
};
EmailRecord.prototype.trimSmsRequests = function (now) {
if (this.sms.length === 0) {
return;
}
// sms is naturally ordered from oldest to newest
// and we only need to keep up to limits.maxSms + 1
var i = this.sms.length - 1;
var n = 0;
var hit = this.sms[i];
while (hit > now - limits.rateLimitIntervalMs && n <= limits.maxSms) {
hit = this.sms[--i];
n++;
}
this.sms = this.sms.slice(i + 1);
};
EmailRecord.prototype.addSmsRequest = function () {
this.sms.push(now());
};
EmailRecord.prototype.isOverTwilioLimit = function () {
this.trimTwilioRequests(now());
return this.twilio.length > limits.maxTwilioRequests;
};
EmailRecord.prototype.trimTwilioRequests = function (now) {
if (this.twilio.length === 0) {
return;
}
// twilio is naturally ordered from oldest to newest
// and we only need to keep up to limits.maxTwilioRequests + 1
var i = this.twilio.length - 1;
var n = 0;
var hit = this.twilio[i];
while (
hit > now - limits.rateLimitIntervalMs &&
n <= limits.maxTwilioRequests
) {
hit = this.twilio[--i];
n++;
}
this.twilio = this.twilio.slice(i + 1);
};
EmailRecord.prototype.addTwilioRequest = function () {
this.twilio.push(now());
};
EmailRecord.prototype.addUnblock = function () {
this.ub.push(now());
};
EmailRecord.prototype.canUnblock = function () {
this.trimUnblocks(now());
return this.ub.length <= limits.maxUnblockAttempts;
};
EmailRecord.prototype.trimUnblocks = function (now) {
if (this.ub.length === 0) {
return;
}
// ub is naturally ordered from oldest to newest
// and we only need to keep up to limits.maxUnblockAttempts + 1
var i = this.ub.length - 1;
var n = 0;
var ub = this.ub[i];
while (
ub > now - limits.rateLimitIntervalMs &&
n <= limits.maxUnblockAttempts
) {
ub = this.ub[--i];
n++;
}
this.ub = this.ub.slice(i + 1);
};
EmailRecord.prototype.shouldBlock = function () {
return this.isRateLimited() || this.isBlocked() || this.isDisabled();
};
EmailRecord.prototype.isRateLimited = function () {
return !!(this.rl && now() - this.rl < limits.rateLimitIntervalMs);
};
EmailRecord.prototype.isBlocked = function () {
return !!(this.bk && now() - this.bk < limits.blockIntervalMs);
};
EmailRecord.prototype.isSuspected = function () {
return !!(this.su && now() - this.su < limits.suspectIntervalMs);
};
EmailRecord.prototype.isDisabled = function () {
return !!(this.di && now() - this.di < limits.disableIntervalMs);
};
EmailRecord.prototype.block = function () {
this.bk = now();
};
EmailRecord.prototype.suspect = function () {
this.su = now();
};
EmailRecord.prototype.disable = function () {
this.di = now();
};
EmailRecord.prototype.rateLimit = function () {
this.rl = now();
this.xs = [];
this.sms = [];
this.twilio = [];
};
EmailRecord.prototype.passwordReset = function () {
this.pr = now();
// reset the OTP timestamps so the user can start another password reset
// flow if they wish
this.os = [];
this.ov = [];
};
EmailRecord.prototype.retryAfter = function () {
var rateLimitAfter = Math.ceil(
((this.rl || 0) + limits.rateLimitIntervalMs - now()) / 1000
);
var banAfter = Math.ceil(
((this.bk || 0) + limits.blockIntervalMs - now()) / 1000
);
return Math.max(0, rateLimitAfter, banAfter);
};
EmailRecord.prototype.isOverBadLogins = function () {
this.trimBadLogins(now());
return this.lf.length > limits.maxBadLoginsPerEmail;
};
EmailRecord.prototype.addBadLogin = function () {
this.trimBadLogins(now());
this.lf.push(now());
};
EmailRecord.prototype.trimBadLogins = function (now) {
if (this.lf.length === 0) {
return;
}
// lf is naturally ordered from oldest to newest
// and we only need to keep up to limits.maxBadLoginsPerEmail + 1
var i = this.lf.length - 1;
var n = 0;
var login = this.lf[i];
while (
login > now - limits.rateLimitIntervalMs &&
n <= limits.maxBadLoginsPerEmail
) {
login = this.lf[--i];
n++;
}
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.addPasswordResetOtpVerification = function () {
this.ov.push(now());
};
EmailRecord.prototype.trimPasswordResetOtpVerifications = function (now) {
this.ov = this.ov.filter(
(otpVerificationTime) =>
otpVerificationTime >
now - limits.passwordResetOtpVerificationBlockWindowMs
);
};
EmailRecord.prototype.isOverPasswordResetOtpVerificationRateLimit =
function () {
const thisIsNow = now();
return (
this.ov.filter(
(otpVerificationTime) =>
otpVerificationTime >
thisIsNow - limits.passwordResetOtpVerificationRateLimitWindowMs
).length >= limits.maxPasswordResetOtpVerificationRateLimit
);
};
EmailRecord.prototype.isOverPasswordResetOtpVerificationBlockLimit =
function () {
this.trimPasswordResetOtpVerifications(now());
return this.ov.length >= limits.maxPasswordResetOtpVerificationBlockLimit;
};
EmailRecord.prototype.update = function (action, unblock) {
// Reject immediately if they've been explicitly blocked.
if (this.isBlocked()) {
return this.retryAfter();
}
if (unblock) {
this.addUnblock();
}
// For code-checking actions, we may need to rate-limit.
if (actions.isCodeVerifyingAction(action)) {
// If they're already being blocked then don't count any more hits,
// and tell them to retry.
if (this.shouldBlock()) {
return this.retryAfter();
}
this.addVerifyCode();
if (this.isOverVerifyCodes()) {
// They're now over the limit, rate-limit and tell them to retry.
this.rateLimit();
return this.retryAfter();
}
}
// For email-sending actions, we may need to rate-limit.
if (actions.isEmailSendingAction(action)) {
// If they're already being blocked then don't count any more hits,
// and tell them to retry.
if (this.shouldBlock()) {
return this.retryAfter();
}
this.addHit();
if (this.isOverEmailLimit()) {
// They're now over the limit, rate-limit and tell them to retry.
this.rateLimit();
return this.retryAfter();
}
}
// For sms-sending actions, we may need to rate-limit.
if (actions.isSmsSendingAction(action)) {
// If they're already being blocked then don't count any more hits,
// and tell them to retry.
if (this.shouldBlock()) {
return this.retryAfter();
}
this.addSmsRequest();
if (this.isOverSmsLimit()) {
// They're now over the limit, rate-limit and tell them to retry.
this.rateLimit();
return this.retryAfter();
}
}
if (actions.isTwilioAction(action)) {
// If they're already being blocked then don't count any more hits,
// and tell them to retry.
if (this.shouldBlock()) {
return this.retryAfter();
}
this.addTwilioRequest();
if (this.isOverTwilioLimit()) {
this.rateLimit();
return this.retryAfter();
}
}
// if over the bad logins, rate limit them and return the block
if (this.isOverBadLogins()) {
this.rateLimit();
return this.retryAfter();
}
if (actions.isResetPasswordOtpSendingAction(action)) {
const shouldRateLimit = this.isOverPasswordResetOtpLimit();
if (shouldRateLimit) {
const latest = this.os[this.os.length - 1];
return Math.ceil(
(latest + limits.passwordResetOtpEmailRateLimitIntervalMs - now()) /
1000
);
}
this.addPasswordResetOtp();
return 0;
}
if (actions.isResetPasswordOtpVerificationAction(action)) {
const shouldBlock = this.isOverPasswordResetOtpVerificationBlockLimit();
const shouldRateLimit =
this.isOverPasswordResetOtpVerificationRateLimit();
if (shouldBlock || shouldRateLimit) {
const latest = this.ov[this.ov.length - 1];
if (shouldBlock) {
return Math.ceil(
(latest +
limits.passwordResetOtpVerificationBlockWindowMs -
now()) /
1000
);
}
if (shouldRateLimit) {
return Math.ceil(
(latest +
limits.passwordResetOtpVerificationRateLimitWindowMs -
now()) /
1000
);
}
}
this.addPasswordResetOtpVerification();
return 0;
}
// Everything else is allowed through.
return 0;
};
return EmailRecord;
};