Skip to content

Commit 0a311ef

Browse files
authored
Merge pull request #18264 from mozilla/add-recovery-enabled-config-check-to-all-recovery-phone-endpoints
task(auth): Check recovery-phone enabled config for all endpoints
2 parents 2bea7c5 + 0a7d1dd commit 0a311ef

5 files changed

Lines changed: 127 additions & 14 deletions

File tree

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,9 @@ export class SmsSendRateLimitExceededError extends RecoveryPhoneError {
5858
);
5959
}
6060
}
61+
62+
export class RecoveryPhoneNotEnabled extends RecoveryPhoneError {
63+
constructor(cause?: Error) {
64+
super('Recovery phone not enabled.', {}, cause);
65+
}
66+
}

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

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { RecoveryPhoneManager } from './recovery-phone.manager';
1111
import {
1212
RecoveryNumberNotExistsError,
1313
RecoveryNumberNotSupportedError,
14+
RecoveryPhoneNotEnabled,
1415
} from './recovery-phone.errors';
1516

1617
describe('RecoveryPhoneService', () => {
@@ -335,12 +336,6 @@ describe('RecoveryPhoneService', () => {
335336
mockRecoveryPhoneConfig.allowedRegions = ['US'];
336337
});
337338

338-
it('should return false if config is not enabled', async () => {
339-
mockRecoveryPhoneConfig.enabled = false;
340-
const result = await service.available(uid, region);
341-
expect(result).toBe(false);
342-
});
343-
344339
it('should return false if region is not in allowedRegions', async () => {
345340
mockRecoveryPhoneConfig.allowedRegions = ['USA'];
346341
const result = await service.available(uid, region);
@@ -368,6 +363,41 @@ describe('RecoveryPhoneService', () => {
368363
});
369364
});
370365

366+
describe('can disabled service', () => {
367+
beforeAll(() => {
368+
mockRecoveryPhoneConfig.enabled = false;
369+
});
370+
afterAll(() => {
371+
mockRecoveryPhoneConfig.enabled = true;
372+
});
373+
it('can disable', () => {
374+
expect(service.available(uid, 'US')).rejects.toEqual(
375+
new RecoveryPhoneNotEnabled()
376+
);
377+
expect(service.confirmSetupCode(uid, '000000')).rejects.toEqual(
378+
new RecoveryPhoneNotEnabled()
379+
);
380+
expect(service.confirmSigninCode(uid, '000000')).rejects.toEqual(
381+
new RecoveryPhoneNotEnabled()
382+
);
383+
expect(service.hasConfirmed(uid)).rejects.toEqual(
384+
new RecoveryPhoneNotEnabled()
385+
);
386+
expect(() => service.maskPhoneNumber('+15550005555')).toThrow(
387+
new RecoveryPhoneNotEnabled()
388+
);
389+
expect(service.removePhoneNumber(uid)).rejects.toEqual(
390+
new RecoveryPhoneNotEnabled()
391+
);
392+
expect(service.sendCode(uid)).rejects.toEqual(
393+
new RecoveryPhoneNotEnabled()
394+
);
395+
expect(service.setupPhoneNumber(uid, '+15550005555')).rejects.toEqual(
396+
new RecoveryPhoneNotEnabled()
397+
);
398+
});
399+
});
400+
371401
describe('mask phone number', () => {
372402
it('can mask number', () => {
373403
const phoneNumber = '+123456789';

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

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { RecoveryPhoneManager } from './recovery-phone.manager';
1010
import {
1111
RecoveryNumberNotExistsError,
1212
RecoveryNumberNotSupportedError,
13+
RecoveryPhoneNotEnabled,
1314
} from './recovery-phone.errors';
1415
import { MessageInstance } from 'twilio/lib/rest/api/v2010/account/message';
1516

@@ -31,7 +32,7 @@ export class RecoveryPhoneService {
3132
*/
3233
public async available(uid: string, region: string): Promise<boolean> {
3334
if (!this.config.enabled) {
34-
return false;
35+
throw new RecoveryPhoneNotEnabled();
3536
}
3637

3738
if (!this.config.allowedRegions?.includes(region)) {
@@ -55,6 +56,10 @@ export class RecoveryPhoneService {
5556
* @returns True if code was sent and stored
5657
*/
5758
public async setupPhoneNumber(uid: string, phoneNumber: string) {
59+
if (!this.config.enabled) {
60+
throw new RecoveryPhoneNotEnabled();
61+
}
62+
5863
if (this.config.sms && this.config.sms.validNumberPrefixes) {
5964
const allowed = this.config.sms.validNumberPrefixes.some((check) => {
6065
return phoneNumber.startsWith(check);
@@ -92,6 +97,10 @@ export class RecoveryPhoneService {
9297
* @returns True if successful
9398
*/
9499
public async confirmSetupCode(uid: string, code: string) {
100+
if (!this.config.enabled) {
101+
throw new RecoveryPhoneNotEnabled();
102+
}
103+
95104
const data = await this.recoveryPhoneManager.getUnconfirmed(uid, code);
96105

97106
// If there is no data, it means there's no record of this code being sent to the uid provided
@@ -121,7 +130,17 @@ export class RecoveryPhoneService {
121130
return true;
122131
}
123132

133+
/**
134+
* Confirms a signin code.
135+
* @param uid An account id
136+
* @param code A otp code
137+
* @returns True if successful
138+
*/
124139
public async confirmSigninCode(uid: string, code: string) {
140+
if (!this.config.enabled) {
141+
throw new RecoveryPhoneNotEnabled();
142+
}
143+
125144
const data = await this.recoveryPhoneManager.getUnconfirmed(uid, code);
126145

127146
// If there is no data, it means there's no record of this code being sent to the uid provided
@@ -145,8 +164,13 @@ export class RecoveryPhoneService {
145164
* phone number.
146165
*
147166
* @param uid An account id
167+
* @returns True if successful
148168
*/
149169
public async removePhoneNumber(uid: string) {
170+
if (!this.config.enabled) {
171+
throw new RecoveryPhoneNotEnabled();
172+
}
173+
150174
return await this.recoveryPhoneManager.removePhoneNumber(uid);
151175
}
152176

@@ -166,6 +190,10 @@ export class RecoveryPhoneService {
166190
exists: boolean;
167191
phoneNumber?: string;
168192
}> {
193+
if (!this.config.enabled) {
194+
throw new RecoveryPhoneNotEnabled();
195+
}
196+
169197
try {
170198
const { phoneNumber } =
171199
await this.recoveryPhoneManager.getConfirmedPhoneNumber(uid);
@@ -198,6 +226,10 @@ export class RecoveryPhoneService {
198226
* number. e.g. +15005551234 would be masked as +*******1234 if lastN was 4.
199227
*/
200228
public maskPhoneNumber(phoneNumber: string, lastN?: number) {
229+
if (!this.config.enabled) {
230+
throw new RecoveryPhoneNotEnabled();
231+
}
232+
201233
// The + notation can be confusing in a masked number. Don't count it
202234
// as a digit.
203235
let prefix = '';
@@ -228,6 +260,10 @@ export class RecoveryPhoneService {
228260
* @returns True if message didn't fail to send.
229261
*/
230262
public async sendCode(uid: string) {
263+
if (!this.config.enabled) {
264+
throw new RecoveryPhoneNotEnabled();
265+
}
266+
231267
const { phoneNumber } =
232268
await this.recoveryPhoneManager.getConfirmedPhoneNumber(uid);
233269
const code = await this.otpCode.generateCode();

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

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import {
66
RecoveryPhoneService,
7+
RecoveryPhoneNotEnabled,
78
RecoveryNumberNotSupportedError,
89
RecoveryNumberInvalidFormatError,
910
RecoveryNumberAlreadyExistsError,
@@ -66,6 +67,10 @@ class RecoveryPhoneHandler {
6667
throw AppError.smsSendRateLimitExceeded();
6768
}
6869

70+
if (error instanceof RecoveryPhoneNotEnabled) {
71+
throw AppError.featureNotEnabled();
72+
}
73+
6974
throw AppError.backendServiceFailure(
7075
'RecoveryPhoneService',
7176
'sendCode',
@@ -108,6 +113,10 @@ class RecoveryPhoneHandler {
108113
await this.glean.twoStepAuthPhoneCode.sendError(request);
109114
return { status: RecoveryPhoneStatus.FAILURE };
110115
} catch (error) {
116+
if (error instanceof RecoveryPhoneNotEnabled) {
117+
throw AppError.featureNotEnabled();
118+
}
119+
111120
await this.glean.twoStepAuthPhoneCode.sendError(request);
112121

113122
if (
@@ -171,6 +180,10 @@ class RecoveryPhoneHandler {
171180
}
172181
}
173182
} catch (error) {
183+
if (error instanceof RecoveryPhoneNotEnabled) {
184+
throw AppError.featureNotEnabled();
185+
}
186+
174187
if (error instanceof RecoveryNumberAlreadyExistsError) {
175188
throw AppError.recoveryPhoneNumberAlreadyExists();
176189
}
@@ -198,6 +211,10 @@ class RecoveryPhoneHandler {
198211
try {
199212
success = await this.recoveryPhoneService.removePhoneNumber(uid);
200213
} catch (error) {
214+
if (error instanceof RecoveryPhoneNotEnabled) {
215+
throw AppError.featureNotEnabled();
216+
}
217+
201218
throw AppError.backendServiceFailure(
202219
'RecoveryPhoneService',
203220
'destroy',
@@ -237,14 +254,33 @@ class RecoveryPhoneHandler {
237254
};
238255
}
239256

240-
const available = await this.recoveryPhoneService.available(
241-
uid,
242-
location.countryCode
243-
);
257+
try {
258+
const available = await this.recoveryPhoneService.available(
259+
uid,
260+
location.countryCode
261+
);
244262

245-
return {
246-
available,
247-
};
263+
return {
264+
available,
265+
};
266+
} catch (error) {
267+
if (error instanceof RecoveryPhoneNotEnabled) {
268+
// In this case we won't throw an AppError. Unlike other endpoints,
269+
// this drives whether or not the feature shows up in the UI, so
270+
// if the recovery phone services isn't enabled, we can simply
271+
// return available false.
272+
return {
273+
available: false,
274+
};
275+
}
276+
277+
throw AppError.backendServiceFailure(
278+
'RecoveryPhoneService',
279+
'destroy',
280+
{ uid },
281+
error
282+
);
283+
}
248284
}
249285

250286
async exists(request: AuthRequest) {
@@ -266,6 +302,10 @@ class RecoveryPhoneHandler {
266302
try {
267303
return await this.recoveryPhoneService.hasConfirmed(uid, phoneNumberMask);
268304
} catch (error) {
305+
if (error instanceof RecoveryPhoneNotEnabled) {
306+
throw AppError.featureNotEnabled();
307+
}
308+
269309
throw AppError.backendServiceFailure(
270310
'RecoveryPhoneService',
271311
'destroy',

packages/fxa-auth-server/test/remote/recovery_phone_tests.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ describe(`#integration - recovery phone`, function () {
6767
const password = 'password';
6868

6969
before(async function () {
70+
config.recoveryPhone.enabled = true;
7071
config.securityHistory.ipProfiling.allowedRecency = 0;
7172
config.signinConfirmation.skipForNewAccounts.enabled = false;
7273
server = await TestServer.start(config);

0 commit comments

Comments
 (0)