Skip to content

Commit 09300fb

Browse files
authored
Merge pull request #18844 from mozilla/FXA-11607
task(auth-server): Replace accountStatusCheck customs rule with new rate limit library call
2 parents a64690a + fdc44f3 commit 09300fb

10 files changed

Lines changed: 198 additions & 11 deletions

File tree

.circleci/config.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ executors:
177177
CUSTOMS_SERVER_URL: none
178178
HUSKY_SKIP_INSTALL: 1
179179
AUTH_CLOUDTASKS_USE_LOCAL_EMULATOR: true
180+
RATE_LIMIT__RULES: ""
180181

181182
# Contains a pre-installed fxa stack and browsers for doing ui test
182183
# automation. Perfect for running smoke tests against remote targets.

libs/accounts/rate-limit/src/lib/rate-limit.spec.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,17 @@ describe('rate-limit', () => {
3333
expect(rateLimit).toBeDefined();
3434
});
3535

36+
it('can determine if action is supported', () => {
37+
rateLimit = new RateLimit(
38+
parseConfigRules(['test:ip:1:1s:1s']),
39+
redis,
40+
statsd
41+
);
42+
43+
expect(rateLimit.supportsAction('test')).toBeTruthy();
44+
expect(rateLimit.supportsAction('foo')).toBeFalsy();
45+
});
46+
3647
it('throws if no action can be found', async () => {
3748
rateLimit = new RateLimit({}, redis, statsd);
3849
expect(rateLimit.check('foo', {})).rejects.toThrow(

libs/accounts/rate-limit/src/lib/rate-limit.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,15 @@ export class RateLimit {
5252
}
5353
}
5454

55+
/**
56+
* Checks to see if there are rules for a given action.
57+
* @param action - Name of action
58+
* @returns - True if action has rules, otherwise false.
59+
*/
60+
supportsAction(action:string) {
61+
return this.rules[action] != null;
62+
}
63+
5564
/**
5665
* Checks to see if a rate limit has been exceeded.
5766
* @param action The action being conducted. Important, if the action is not defined, a runtime error will occur!
@@ -73,7 +82,7 @@ export class RateLimit {
7382
throw new ActionNotFound(action);
7483
}
7584

76-
const openBlocks = [];
85+
const openBlocks = new Array<BlockStatus>();
7786

7887
// Important! Set timestamp of check upfront.
7988
// This reduces small variance because of wait on IO operations.
@@ -159,9 +168,7 @@ export class RateLimit {
159168
// - Open question, do we also want to no blocks with shorter bans? Or just the block with largest
160169
// ban (ie the biggest retryAfter value).
161170

162-
return {
163-
...block,
164-
};
171+
return block;
165172
}
166173

167174
// Made it through the gauntlet of rules. No blocks found!

packages/fxa-auth-server/bin/key_server.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ const {
5151
RecoveryPhoneService,
5252
TwilioFactory,
5353
} = require('@fxa/accounts/recovery-phone');
54+
const { parseConfigRules, RateLimit } = require('@fxa/accounts/rate-limit');
5455
const { AccountManager } = require('@fxa/shared/account/account');
5556
const { setupAccountDatabase } = require('@fxa/shared/db/mysql/account');
5657
const { EmailCloudTaskManager } = require('../lib/email-cloud-tasks');
@@ -220,6 +221,16 @@ async function run(config) {
220221
Container.get(AppleIAP);
221222
}
222223

224+
// Create rate limiter (aka customs v2)
225+
const rules = parseConfigRules(config.rateLimit.rules);
226+
const rateLimitRedis = new Redis({
227+
...config.redis,
228+
...config.redis.customs,
229+
});
230+
const rateLimit = new RateLimit(rules, rateLimitRedis, statsd);
231+
Container.set(RateLimit, rateLimit);
232+
233+
// Create Recovery Phone Service
223234
const twilio = TwilioFactory.useFactory(config.twilio);
224235
const recoveryPhoneRedis = new Redis({
225236
...config.redis,
@@ -308,7 +319,7 @@ async function run(config) {
308319
config.domain
309320
);
310321
const Password = require('../lib/crypto/password')(log, config);
311-
const customs = new Customs(config.customsUrl, log, error, statsd);
322+
const customs = new Customs(config.customsUrl, log, error, statsd, rateLimit);
312323
const zendeskClient = require('../lib/zendesk-client').createZendeskClient(
313324
config
314325
);

packages/fxa-auth-server/config/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2160,6 +2160,18 @@ const convictConf = convict({
21602160
},
21612161
},
21622162
},
2163+
rateLimit: {
2164+
rules: {
2165+
default: [
2166+
'unblockEmail : email : 10 : 24 hours : 24 hours ',
2167+
'accountStatusCheck : ip : 20 : 15 minutes : 15 minutes ',
2168+
'accountStatusCheck : email : 20 : 15 minutes : 15 minutes ',
2169+
],
2170+
doc: 'Rules for rate limiting user actions. These are essentially customs v2 rules.',
2171+
env: 'RATE_LIMIT__RULES',
2172+
format: Array,
2173+
},
2174+
},
21632175
recoveryPhone: {
21642176
enabled: {
21652177
default: false,

packages/fxa-auth-server/lib/customs.js

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,12 @@ const localizeTimestamp =
1717
const serviceName = 'customs';
1818

1919
class CustomsClient {
20-
constructor(url, log, error, statsd) {
20+
constructor(url, log, error, statsd, rateLimit) {
2121
this.log = log;
2222
this.error = error;
2323
this.statsd = statsd;
24+
this.rateLimit = rateLimit;
25+
2426
const customsHttpAgentConfig = config.get('customsHttpAgent');
2527

2628
if (url !== 'none') {
@@ -87,6 +89,14 @@ class CustomsClient {
8789
}
8890

8991
async check(request, email, action) {
92+
const checked = await this.checkV2(request, 'check', action, {
93+
ip: request?.app?.clientAddress,
94+
email,
95+
});
96+
if (checked) {
97+
return;
98+
}
99+
90100
const result = await this.makeRequest('/check', {
91101
...this.sanitizePayload({
92102
ip: request.app.clientAddress,
@@ -108,6 +118,14 @@ class CustomsClient {
108118
}
109119

110120
async checkAuthenticated(request, uid, action) {
121+
const checked = await this.checkV2(request, 'checkAuthenticated', action, {
122+
ip: request?.app?.clientAddress,
123+
uid,
124+
});
125+
if (checked) {
126+
return;
127+
}
128+
111129
const result = await this.makeRequest('/checkAuthenticated', {
112130
...this.sanitizePayload({
113131
action,
@@ -121,6 +139,13 @@ class CustomsClient {
121139
}
122140

123141
async checkIpOnly(request, action) {
142+
const checked = await this.checkV2(request, 'checkIpOnly', action, {
143+
ip: request?.app?.clientAddress,
144+
});
145+
if (checked) {
146+
return;
147+
}
148+
124149
const result = await this.makeRequest('/checkIpOnly', {
125150
...this.sanitizePayload({
126151
action,
@@ -257,6 +282,76 @@ class CustomsClient {
257282
});
258283
}
259284
}
285+
286+
// #region Customs V2
287+
288+
/**
289+
* Version 2 Customs Approach
290+
* =======================================================================================
291+
* This uses a library provided by libs and works directly with Redis to make rate limiting
292+
* decisions. The previous customs check to see if there is 'new' configuration for the
293+
* customs action being checked. If there is, we will call into this code instead of calling
294+
* the legacy customs service.
295+
*/
296+
async checkV2(request, type, action, opts) {
297+
// Short circuit if rate limit wasn't provided.
298+
if (this.rateLimit == null) {
299+
return false;
300+
}
301+
302+
// Fallback to the legacy customs service approach, if v2 action isn't configured
303+
const actionConfigured = this.rateLimit.supportsAction(action);
304+
if (!actionConfigured) {
305+
this.statsd?.increment(`${serviceName}.check.v1`, [`action:${action}`]);
306+
return false;
307+
}
308+
309+
// Otherwise, call the new nx lib instead of the legacy service
310+
this.statsd?.increment(`${serviceName}.check.v2`, [`action:${action}`]);
311+
const result = await this.rateLimit.check(action, opts);
312+
313+
// If statsd was provided, record metrics
314+
this.statsd?.increment(`${serviceName}.request.v2.${type}`, {
315+
action,
316+
block: result != null,
317+
blockReason: result?.reason || '',
318+
});
319+
320+
321+
// If no result, we exit. Check essentially passes.
322+
if (result == null) {
323+
return true;
324+
}
325+
326+
// We use the rate limiter to allow X number unblock attempts per day. Once
327+
// unblock attempts have been exhausted, the user cannot request an unblock
328+
// code and must wait until the unblockEmail ban duration has expired. Similar
329+
// logic existed in the old customs server, but these sorts of decisions are
330+
// actually domain of the service using customs and not customs itself, so
331+
// this is the revised approach.
332+
let canUnblock = false;
333+
const { email } = opts;
334+
if (email) {
335+
const unblockResult = await this.rateLimit.check('unblockEmail', {
336+
email,
337+
});
338+
canUnblock = unblockResult == null;
339+
}
340+
341+
request.emitMetricsEvent('customs.blocked');
342+
const retryAfterLocalized = localizeTimestamp.format(
343+
Date.now() + result.retryAfter * 1000,
344+
request.headers['accept-language']
345+
);
346+
347+
throw this.error.tooManyRequests(
348+
result.retryAfter,
349+
retryAfterLocalized,
350+
canUnblock
351+
);
352+
353+
}
354+
// #endregion
260355
}
261356

262357
module.exports = CustomsClient;

packages/fxa-auth-server/lib/error.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -432,7 +432,7 @@ AppError.tooManyRequests = function (
432432
extraData.verificationReason = 'login';
433433
}
434434

435-
return new AppError(
435+
const error = new AppError(
436436
{
437437
code: 429,
438438
error: 'Too Many Requests',
@@ -444,6 +444,8 @@ AppError.tooManyRequests = function (
444444
'retry-after': retryAfter,
445445
}
446446
);
447+
448+
return error;
447449
};
448450

449451
AppError.requestBlocked = function (canUnblock) {

packages/fxa-auth-server/test/local/customs.js

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,11 @@
44

55
'use strict';
66

7-
const ROOT_DIR = '../..';
87

98
const sinon = require('sinon');
109
const assert = { ...sinon.assert, ...require('chai').assert };
1110
const mocks = require('../mocks');
12-
const error = require(`${ROOT_DIR}/lib/error.js`);
11+
const error = require(`../../lib/error.js`);
1312
const nock = require('nock');
1413

1514
const CUSTOMS_URL_REAL = 'http://localhost:7000';
@@ -18,7 +17,7 @@ const CUSTOMS_URL_MISSING = 'http://localhost:7001';
1817
const customsServer = nock(CUSTOMS_URL_REAL).defaultReplyHeaders({
1918
'Content-Type': 'application/json',
2019
});
21-
const Customs = require(`${ROOT_DIR}/lib/customs.js`);
20+
const Customs = require(`../../lib/customs.js`);
2221

2322
describe('Customs', () => {
2423
let customsNoUrl;
@@ -720,6 +719,52 @@ describe('Customs', () => {
720719
});
721720
});
722721

722+
describe('customs v2', () => {
723+
const mockRateLimit = {
724+
supportsAction: sinon.spy(() => true),
725+
check: sinon.spy(),
726+
};
727+
const customs = new Customs(
728+
CUSTOMS_URL_REAL,
729+
log,
730+
error,
731+
statsd,
732+
mockRateLimit
733+
);
734+
735+
it('can allow checkAccountStatus with rate-limit lib', async () => {
736+
mockRateLimit.check = sandbox.spy(async () => {
737+
return await Promise.resolve(null);
738+
});
739+
// Should no throw
740+
await customs.check(request, email, 'accountStatusCheck');
741+
});
742+
743+
it('can block checkAccountStatus with rate-limit lib', async () => {
744+
mockRateLimit.check = sandbox.spy(async () => {
745+
return await Promise.resolve({
746+
retryAfter: 1000,
747+
reason: 'too-many-attempts',
748+
});
749+
});
750+
751+
let customsError = undefined;
752+
try {
753+
await customs.check(request, email, 'accountStatusCheck');
754+
} catch (err) {
755+
customsError = err;
756+
}
757+
758+
assert.isDefined(error);
759+
assert.equal(customsError.errno, 114);
760+
assert.equal(customsError.output.payload.error, 'Too Many Requests');
761+
assert.equal(
762+
customsError.output.payload.message,
763+
'Client has sent too many requests'
764+
);
765+
});
766+
});
767+
723768
describe('statsd metrics', () => {
724769
const tags = {
725770
block: true,
@@ -748,7 +793,7 @@ describe('Customs', () => {
748793
await customsWithUrl.check(request, email, action);
749794
assert.fail('should have failed');
750795
} catch (err) {
751-
assert.isTrue(
796+
assert.isTrue(
752797
statsd.increment.calledWithExactly('customs.request.check', {
753798
action,
754799
...validTags,

packages/fxa-auth-server/test/local/routes/account.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1544,13 +1544,15 @@ describe('/account/status', () => {
15441544
);
15451545
const mockMailer = mocks.mockMailer();
15461546
const mockPush = mocks.mockPush();
1547+
const mockCustoms = mocks.mockCustoms();
15471548
const verificationReminders = mocks.mockVerificationReminders();
15481549
const subscriptionAccountReminders = mocks.mockVerificationReminders();
15491550
const accountRoutes = makeRoutes({
15501551
config,
15511552
db: mockDB,
15521553
log: mockLog,
15531554
mailer: mockMailer,
1555+
customs: mockCustoms,
15541556
Password: function () {
15551557
return {
15561558
unwrap: function () {

packages/fxa-auth-server/test/mocks.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,7 @@ function mockCustoms(errors) {
353353
return mockObject(CUSTOMS_METHOD_NAMES)({
354354
checkAuthenticated: optionallyThrow(errors, 'checkAuthenticated'),
355355
checkIpOnly: optionallyThrow(errors, 'checkIpOnly'),
356+
checkV2: sinon.spy(() => false),
356357
});
357358
}
358359

0 commit comments

Comments
 (0)