Skip to content

Commit d5315aa

Browse files
committed
feat(recovery phone): Limit number of accounts that can use the same recovery phone number
Because: * We want to limit the number of accounts that can use the same phone number This commit: * Update recovery phone libs to check for number of accounts with the same registered phone number before setting up a recovery phone or confirming recovery phone setup * Adds a new error type for this scenario Closes #FXA-11085
1 parent a7b1e90 commit d5315aa

16 files changed

Lines changed: 185 additions & 10 deletions

File tree

libs/accounts/recovery-phone/src/lib/recovery-phone.errors.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,13 @@ export class RecoveryPhoneNotEnabled extends RecoveryPhoneError {
7474
super('Recovery phone not enabled.', {}, cause);
7575
}
7676
}
77+
78+
export class RecoveryPhoneRegistrationLimitReached extends RecoveryPhoneError {
79+
constructor(phoneNumber: string, cause?: Error) {
80+
super(
81+
'Maximum registrations reached for this phone number.',
82+
{ phoneNumber },
83+
cause
84+
);
85+
}
86+
}

libs/accounts/recovery-phone/src/lib/recovery-phone.manager.in.spec.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ describe('RecoveryPhoneManager', () => {
127127
it('should throw if recovery phone already exists', async () => {
128128
const mockPhone = RecoveryPhoneFactory();
129129
const { uid, phoneNumber } = mockPhone;
130+
130131
await recoveryPhoneManager.registerPhoneNumber(
131132
uid.toString('hex'),
132133
phoneNumber,
@@ -264,4 +265,37 @@ describe('RecoveryPhoneManager', () => {
264265

265266
expect(result).toBe(true);
266267
});
268+
269+
it('should return number of accounts associated with a phone number', async () => {
270+
const mockPhone = RecoveryPhoneFactory();
271+
const mockPhone2 = RecoveryPhoneFactory();
272+
const { uid, phoneNumber } = mockPhone;
273+
const { uid: uid2 } = mockPhone2;
274+
275+
await db
276+
.insertInto('recoveryPhones')
277+
.values({
278+
uid: uid,
279+
phoneNumber: phoneNumber,
280+
createdAt: Date.now(),
281+
lastConfirmed: Date.now(),
282+
})
283+
.execute();
284+
285+
await db
286+
.insertInto('recoveryPhones')
287+
.values({
288+
uid: uid2,
289+
phoneNumber: phoneNumber,
290+
createdAt: Date.now(),
291+
lastConfirmed: Date.now(),
292+
})
293+
.execute();
294+
295+
const result = await recoveryPhoneManager.getCountByPhoneNumber(
296+
phoneNumber
297+
);
298+
299+
expect(result).toBe(2);
300+
});
267301
});

libs/accounts/recovery-phone/src/lib/recovery-phone.manager.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
import { Inject, Injectable } from '@nestjs/common';
1010
import {
1111
getConfirmedPhoneNumber,
12+
getCountByPhoneNumber,
1213
hasRecoveryCodes,
1314
registerPhoneNumber,
1415
removePhoneNumber,
@@ -200,4 +201,8 @@ export class RecoveryPhoneManager {
200201
async hasRecoveryCodes(uid: string): Promise<boolean> {
201202
return hasRecoveryCodes(this.db, Buffer.from(uid, 'hex'));
202203
}
204+
205+
async getCountByPhoneNumber(phoneNumber: string) {
206+
return getCountByPhoneNumber(this.db, phoneNumber);
207+
}
203208
}

libs/accounts/recovery-phone/src/lib/recovery-phone.repository.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,16 @@ export async function hasRecoveryCodes(
6363

6464
return result.length > 0;
6565
}
66+
67+
export async function getCountByPhoneNumber(
68+
db: AccountDatabase,
69+
phoneNumber: string
70+
): Promise<number> {
71+
const result = await db
72+
.selectFrom('recoveryPhones')
73+
.where('phoneNumber', '=', phoneNumber)
74+
.select('uid')
75+
.execute();
76+
77+
return result.length;
78+
}

libs/accounts/recovery-phone/src/lib/recovery-phone.service.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ export class RecoveryPhoneConfig {
1919
@IsArray()
2020
public allowedRegions?: Array<string>;
2121

22+
@IsNumber()
23+
public maxRegistrationsPerNumber?: number;
24+
2225
@IsObject()
2326
public sms?: RecoveryPhoneServiceConfig;
2427
}

libs/accounts/recovery-phone/src/lib/recovery-phone.service.spec.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
RecoveryNumberNotSupportedError,
1414
RecoveryPhoneNotEnabled,
1515
RecoveryNumberRemoveMissingBackupCodes,
16+
RecoveryPhoneRegistrationLimitReached,
1617
} from './recovery-phone.errors';
1718
import { LOGGER_PROVIDER } from '@fxa/shared/log';
1819
import { StatsDService } from '@fxa/shared/metrics/statsd';
@@ -49,6 +50,7 @@ describe('RecoveryPhoneService', () => {
4950
getConfirmedPhoneNumber: jest.fn(),
5051
hasRecoveryCodes: jest.fn(),
5152
removeCode: jest.fn(),
53+
getCountByPhoneNumber: jest.fn(),
5254
};
5355

5456
const mockOtpManager = {
@@ -58,6 +60,7 @@ describe('RecoveryPhoneService', () => {
5860
const mockRecoveryPhoneConfig: RecoveryPhoneConfig = {
5961
enabled: true,
6062
allowedRegions: ['US'],
63+
maxRegistrationsPerNumber: 5,
6164
sms: {
6265
validNumberPrefixes: ['+1500'],
6366
smsPumpingRiskThreshold: 75,
@@ -116,7 +119,7 @@ describe('RecoveryPhoneService', () => {
116119
expect(service).toBeInstanceOf(RecoveryPhoneService);
117120
});
118121

119-
it('Should setup a phone number', async () => {
122+
it('Should set up a phone number', async () => {
120123
mockOtpManager.generateCode.mockReturnValue(code);
121124

122125
const result = await service.setupPhoneNumber(uid, phoneNumber);
@@ -134,10 +137,9 @@ describe('RecoveryPhoneService', () => {
134137
true
135138
);
136139
expect(mockRecoveryPhoneManager.getAllUnconfirmed).toBeCalledWith(uid);
137-
expect(result).toBeTruthy();
138140
});
139141

140-
it('Should send new code for setup phone number', async () => {
142+
it('Should send new code to set up a phone number', async () => {
141143
mockOtpManager.generateCode.mockReturnValue(code);
142144
mockRecoveryPhoneManager.getAllUnconfirmed.mockResolvedValue([
143145
'this:is:the:code123',
@@ -163,7 +165,7 @@ describe('RecoveryPhoneService', () => {
163165
expect(mockRecoveryPhoneManager.getAllUnconfirmed).toBeCalledWith(uid);
164166
});
165167

166-
it('handles message template when provided to setup phone number', async () => {
168+
it('handles message template when provided to set up phone number', async () => {
167169
mockOtpManager.generateCode.mockReturnValue(code);
168170
const getFormattedMessage = jest.fn().mockResolvedValue('message');
169171

@@ -195,6 +197,14 @@ describe('RecoveryPhoneService', () => {
195197
);
196198
});
197199

200+
it('Will reject a phone number if it has been used for too many accounts', async () => {
201+
mockRecoveryPhoneManager.getCountByPhoneNumber.mockReturnValue(5);
202+
203+
await expect(service.setupPhoneNumber(uid, phoneNumber)).rejects.toEqual(
204+
new RecoveryPhoneRegistrationLimitReached(phoneNumber)
205+
);
206+
});
207+
198208
it('Throws error during send sms', async () => {
199209
mockSmsManager.sendSMS.mockRejectedValueOnce(mockError);
200210
await expect(service.setupPhoneNumber(uid, phoneNumber)).rejects.toEqual(
@@ -311,6 +321,19 @@ describe('RecoveryPhoneService', () => {
311321
);
312322
});
313323

324+
it('rejects phone number that has been used for too many accounts', async () => {
325+
const phoneNumber = '+15005550000';
326+
mockRecoveryPhoneManager.getUnconfirmed.mockReturnValue({
327+
isSetup: true,
328+
phoneNumber,
329+
});
330+
mockRecoveryPhoneManager.getCountByPhoneNumber.mockResolvedValue(5);
331+
332+
await expect(service.confirmSetupCode(uid, code)).rejects.toThrow(
333+
new RecoveryPhoneRegistrationLimitReached(phoneNumber)
334+
);
335+
});
336+
314337
it('can handle failed lookup by throwing PhoneNumberNotSupported error', async () => {
315338
mockRecoveryPhoneManager.getUnconfirmed.mockReturnValue({
316339
isSetup: true,

libs/accounts/recovery-phone/src/lib/recovery-phone.service.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
RecoveryNumberNotSupportedError,
1616
RecoveryPhoneNotEnabled,
1717
RecoveryNumberRemoveMissingBackupCodes,
18+
RecoveryPhoneRegistrationLimitReached,
1819
} from './recovery-phone.errors';
1920
import { MessageInstance } from 'twilio/lib/rest/api/v2010/account/message';
2021
import { LOGGER_PROVIDER } from '@fxa/shared/log';
@@ -97,6 +98,17 @@ export class RecoveryPhoneService {
9798
}
9899
}
99100

101+
// Rejects the phone number if it has been registered for too many accounts
102+
const countByPhoneNumber =
103+
await this.recoveryPhoneManager.getCountByPhoneNumber(phoneNumber);
104+
105+
if (
106+
this.config.maxRegistrationsPerNumber &&
107+
countByPhoneNumber >= this.config.maxRegistrationsPerNumber
108+
) {
109+
throw new RecoveryPhoneRegistrationLimitReached(phoneNumber);
110+
}
111+
100112
const code = await this.otpCode.generateCode();
101113

102114
await this.recoveryPhoneManager.storeUnconfirmed(
@@ -156,6 +168,17 @@ export class RecoveryPhoneService {
156168
return false;
157169
}
158170

171+
// Rejects the phone number if it has been registered for too many accounts
172+
const countByPhoneNumber =
173+
await this.recoveryPhoneManager.getCountByPhoneNumber(data.phoneNumber);
174+
175+
if (
176+
this.config.maxRegistrationsPerNumber &&
177+
countByPhoneNumber >= this.config.maxRegistrationsPerNumber
178+
) {
179+
throw new RecoveryPhoneRegistrationLimitReached(data.phoneNumber);
180+
}
181+
159182
// If this was for a setup operation. Register the phone number to the uid.
160183
const lookupData: PhoneNumberLookupData = await (async () => {
161184
try {

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2168,7 +2168,7 @@ const convictConf = convict({
21682168
format: Boolean,
21692169
},
21702170
allowedRegions: {
2171-
default: ['US'],
2171+
default: ['CA', 'US'],
21722172
doc: 'Allowed regions for recovery phone',
21732173
env: 'RECOVERY_PHONE__ALLOWED_REGIONS',
21742174
format: Array,
@@ -2187,6 +2187,12 @@ const convictConf = convict({
21872187
format: Number,
21882188
},
21892189
},
2190+
maxRegistrationsPerNumber: {
2191+
default: 5,
2192+
doc: 'Max number of uids that can be associated to the same phone number.',
2193+
env: 'RECOVERY_PHONE__MAX_UID_PER_NUMBER',
2194+
format: Number,
2195+
},
21902196
redis: {},
21912197
sms: {
21922198
from: {

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,16 @@ AppError.smsSendRateLimitExceeded = () => {
613613
});
614614
};
615615

616+
AppError.recoveryPhoneRegistrationLimitReached = () => {
617+
return new AppError({
618+
code: 400,
619+
error: 'Bad Request',
620+
errno: ERRNO.RECOVERY_PHONE_REGISTRATION_LIMIT_REACHED,
621+
message:
622+
'Limit reached for number off accounts that can be associated with phone number.',
623+
});
624+
};
625+
616626
AppError.invalidRegion = (region) => {
617627
return new AppError(
618628
{

packages/fxa-auth-server/lib/routes/recovery-phone.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
RecoveryNumberNotExistsError,
1515
SmsSendRateLimitExceededError,
1616
RecoveryNumberRemoveMissingBackupCodes,
17+
RecoveryPhoneRegistrationLimitReached,
1718
TwilioMessageStatus,
1819
} from '@fxa/accounts/recovery-phone';
1920
import {
@@ -230,6 +231,10 @@ class RecoveryPhoneHandler {
230231
throw AppError.smsSendRateLimitExceeded();
231232
}
232233

234+
if (error instanceof RecoveryPhoneRegistrationLimitReached) {
235+
throw AppError.recoveryPhoneRegistrationLimitReached();
236+
}
237+
233238
throw AppError.backendServiceFailure(
234239
'RecoveryPhoneService',
235240
'setupPhoneNumber',
@@ -291,6 +296,10 @@ class RecoveryPhoneHandler {
291296
throw AppError.recoveryPhoneNumberAlreadyExists();
292297
}
293298

299+
if (error instanceof RecoveryPhoneRegistrationLimitReached) {
300+
throw AppError.recoveryPhoneRegistrationLimitReached();
301+
}
302+
294303
throw AppError.backendServiceFailure(
295304
'RecoveryPhoneService',
296305
'confirmCode',

0 commit comments

Comments
 (0)