Skip to content

Commit 39047a2

Browse files
LZoogdschom
authored andcommitted
feat(recovery-phone): Display phone number in desired format (masking + Twilio's nationalFormat)
Because: * We want to show the national format provided by Twilio to better display phone numbers for users This commit: * Adds a Twilio lookup call at recovery phone number add for nationalFormat, reads nationalFormat from DB when querying recovery phone data * Updates our server response to return the last 4 digits of a phone number instead of the mask, displays accordingly on the client including new copy * Updates account resolver recovery phone auth-server call to ensure only the last 4 digits are returned if a user's session is not verified closes FXA-11044
1 parent 13c2f51 commit 39047a2

File tree

41 files changed

+478
-184
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+478
-184
lines changed

libs/accounts/recovery-phone/README.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ Run `nx build recovery-phone` to build the library.
88

99
## Running unit tests
1010

11-
Run `nx test-unit recovery-phone` to execute the unit tests via [Jest](https://jestjs.io).
11+
Run `nx test-unit accounts-recovery-phone` to execute the unit tests via [Jest](https://jestjs.io).
1212

1313
## Running integration tests
1414

15-
Run `nx test-integration recovery-phone` to execute the integration tests via [Jest](https://jestjs.io).
15+
Run `nx test-integration accounts-recovery-phone` to execute the integration tests via [Jest](https://jestjs.io).

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

+5-3
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,11 @@ export class RecoveryPhoneManager {
8282
*
8383
* @param uid
8484
*/
85-
async getConfirmedPhoneNumber(
86-
uid: string
87-
): Promise<{ uid: Buffer; phoneNumber: string }> {
85+
async getConfirmedPhoneNumber(uid: string): Promise<{
86+
uid: Buffer;
87+
phoneNumber: string;
88+
nationalFormat?: string;
89+
}> {
8890
const uidBuffer = Buffer.from(uid, 'hex');
8991
const result = await getConfirmedPhoneNumber(this.db, uidBuffer);
9092
if (!result) {

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

+18-3
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,27 @@ import { RecoveryPhone } from './recovery-phone.types';
77
export async function getConfirmedPhoneNumber(
88
db: AccountDatabase,
99
uid: Buffer
10-
): Promise<{ uid: Buffer; phoneNumber: string } | undefined> {
11-
return db
10+
): Promise<
11+
{ uid: Buffer; phoneNumber: string; nationalFormat?: string } | undefined
12+
> {
13+
const row = await db
1214
.selectFrom('recoveryPhones')
1315
.where('uid', '=', uid)
14-
.select(['uid', 'phoneNumber'])
16+
.select(['uid', 'phoneNumber', 'lookupData'])
1517
.executeTakeFirst();
18+
19+
if (!row) {
20+
return undefined;
21+
}
22+
const { uid: userId, phoneNumber, lookupData } = row;
23+
const nationalFormat = (lookupData as { nationalFormat?: string })
24+
?.nationalFormat;
25+
26+
return {
27+
uid: userId,
28+
phoneNumber,
29+
nationalFormat,
30+
};
1631
}
1732

1833
export async function registerPhoneNumber(

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

+37-10
Original file line numberDiff line numberDiff line change
@@ -208,14 +208,14 @@ describe('RecoveryPhoneService', () => {
208208
).toHaveBeenCalledWith(uid);
209209
});
210210

211-
it('can return masked phone number', async () => {
211+
it('can return stripped phone number', async () => {
212212
mockRecoveryPhoneManager.getConfirmedPhoneNumber.mockReturnValueOnce({
213213
phoneNumber,
214214
});
215215

216216
const result = await service.hasConfirmed(uid, 4);
217217

218-
expect(result.phoneNumber).toEqual('+•••••••1234');
218+
expect(result.phoneNumber).toEqual('1234');
219219
expect(
220220
mockRecoveryPhoneManager.getConfirmedPhoneNumber
221221
).toHaveBeenCalledWith(uid);
@@ -547,7 +547,7 @@ describe('RecoveryPhoneService', () => {
547547
expect(service.hasConfirmed(uid)).rejects.toEqual(
548548
new RecoveryPhoneNotEnabled()
549549
);
550-
expect(() => service.maskPhoneNumber('+15550005555')).toThrow(
550+
expect(() => service.stripPhoneNumber('+15550005555')).toThrow(
551551
new RecoveryPhoneNotEnabled()
552552
);
553553
expect(service.removePhoneNumber(uid)).rejects.toEqual(
@@ -562,14 +562,41 @@ describe('RecoveryPhoneService', () => {
562562
});
563563
});
564564

565-
describe('mask phone number', () => {
566-
it('can mask number', () => {
565+
describe('strip phone number', () => {
566+
it('can strip number', () => {
567567
const phoneNumber = '+123456789';
568-
expect(service.maskPhoneNumber(phoneNumber, -1)).toEqual('+•••••••••');
569-
expect(service.maskPhoneNumber(phoneNumber, 0)).toEqual('+•••••••••');
570-
expect(service.maskPhoneNumber(phoneNumber, 4)).toEqual('+•••••6789');
571-
expect(service.maskPhoneNumber(phoneNumber, 9)).toEqual('+123456789');
572-
expect(service.maskPhoneNumber(phoneNumber, 12)).toEqual('+123456789');
568+
569+
expect(service.stripPhoneNumber(phoneNumber)).toEqual(phoneNumber);
570+
expect(service.stripPhoneNumber(phoneNumber, -1)).toEqual('');
571+
expect(service.stripPhoneNumber(phoneNumber, 0)).toEqual('');
572+
expect(service.stripPhoneNumber(phoneNumber, 2)).toEqual('89');
573+
expect(service.stripPhoneNumber(phoneNumber, 4)).toEqual('6789');
574+
expect(service.stripPhoneNumber(phoneNumber, 9)).toEqual('123456789');
575+
expect(service.stripPhoneNumber(phoneNumber, 12)).toEqual('123456789');
576+
});
577+
it('can strip NANP national_format number and should not display format', () => {
578+
const phoneNumber = '(123) 456-7890';
579+
580+
expect(service.stripPhoneNumber(phoneNumber)).toEqual(phoneNumber);
581+
expect(service.stripPhoneNumber(phoneNumber, -1)).toEqual('');
582+
expect(service.stripPhoneNumber(phoneNumber, 0)).toEqual('');
583+
expect(service.stripPhoneNumber(phoneNumber, 4)).toEqual('7890');
584+
expect(service.stripPhoneNumber(phoneNumber, 10)).toEqual('1234567890');
585+
expect(service.stripPhoneNumber(phoneNumber, 12)).toEqual('1234567890');
586+
});
587+
it('can strip non-NANP national_format number and should not display format', () => {
588+
const phoneNumber = '+33 9 87 65 43 21';
589+
590+
expect(service.stripPhoneNumber(phoneNumber)).toEqual(phoneNumber);
591+
expect(service.stripPhoneNumber(phoneNumber, -1)).toEqual('');
592+
expect(service.stripPhoneNumber(phoneNumber, 0)).toEqual('');
593+
expect(service.stripPhoneNumber(phoneNumber, 4)).toEqual('4321');
594+
expect(service.stripPhoneNumber(phoneNumber, 9)).toEqual('987654321');
595+
expect(service.stripPhoneNumber(phoneNumber, 12)).toEqual('33987654321');
596+
});
597+
it('can handle being passed an empty string', () => {
598+
const phoneNumber = '';
599+
expect(service.stripPhoneNumber(phoneNumber, 4)).toEqual('');
573600
});
574601
});
575602
});

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

+33-28
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,18 @@ export class RecoveryPhoneService {
117117
return this.isSuccessfulSmsSend(msg);
118118
}
119119

120+
public async getNationalFormat(phoneNumber: string) {
121+
// When the user _confirms_ their OTP code we also call the lookup endpoint to
122+
// store the full data returned in our DB, but we need the national format on the
123+
// OTP confirm page before then. "Basic lookups" from Twilio are free, so don't
124+
// bother persisting in redis.
125+
// https://www.twilio.com/en-us/user-authentication-identity/pricing/lookup
126+
const { nationalFormat } = await this.smsManager.phoneNumberLookup(
127+
phoneNumber
128+
);
129+
return nationalFormat;
130+
}
131+
120132
/**
121133
* Confirms a UID code. This will also and finalizes the phone number setup if the code provided was
122134
* intended for phone number setup.
@@ -261,29 +273,34 @@ export class RecoveryPhoneService {
261273
*/
262274
public async hasConfirmed(
263275
uid: string,
264-
phoneNumberMask?: number
276+
phoneNumberStrip?: number
265277
): Promise<{
266278
exists: boolean;
267279
phoneNumber?: string;
280+
nationalFormat?: string;
268281
}> {
269282
if (!this.config.enabled) {
270283
throw new RecoveryPhoneNotEnabled();
271284
}
272285

273286
try {
274-
const { phoneNumber } =
287+
const { phoneNumber, nationalFormat } =
275288
await this.recoveryPhoneManager.getConfirmedPhoneNumber(uid);
276289

277290
return {
278291
exists: true,
279-
phoneNumber: this.maskPhoneNumber(phoneNumber, phoneNumberMask),
292+
phoneNumber: this.stripPhoneNumber(phoneNumber, phoneNumberStrip),
293+
nationalFormat: nationalFormat
294+
? this.stripPhoneNumber(nationalFormat, phoneNumberStrip)
295+
: undefined,
280296
};
281297
} catch (err) {
282298
if (err instanceof RecoveryNumberNotExistsError) {
283299
// no-op - we handle the error, and just return false;
284300
return {
285301
exists: false,
286302
phoneNumber: undefined,
303+
nationalFormat: undefined,
287304
};
288305
}
289306
// Something unexpected happened...
@@ -296,38 +313,26 @@ export class RecoveryPhoneService {
296313
*
297314
* @param phoneNumber The actual phone number
298315
* @param lastN The last N number of digits to show
299-
* @returns A masked number
300-
*
301-
* @remarks This will not mask a + symbol, since this technically isn't part of the
302-
* number. e.g. +15005551234 would be masked as +*******1234 if lastN was 4.
316+
* @returns The last N number of digits of the phone number
303317
*/
304-
public maskPhoneNumber(phoneNumber: string, lastN?: number) {
318+
public stripPhoneNumber(phoneNumber: string, lastN?: number) {
305319
if (!this.config.enabled) {
306320
throw new RecoveryPhoneNotEnabled();
307321
}
308-
309-
// The + notation can be confusing in a masked number. Don't count it
310-
// as a digit.
311-
let prefix = '';
312-
if (phoneNumber.startsWith('+')) {
313-
prefix = '+';
314-
phoneNumber = phoneNumber.substring(1);
315-
}
316-
317-
// Clamp lastN between 0 and phoneNumber.length
322+
// No stripping needed, session is verified
318323
if (lastN === undefined) {
319-
lastN = phoneNumber.length;
320-
} else if (lastN > phoneNumber.length) {
321-
lastN = phoneNumber.length;
322-
} else if (lastN < 0) {
323-
lastN = 0;
324+
return phoneNumber;
325+
}
326+
if (lastN <= 0) {
327+
return '';
324328
}
325329

326-
// Create mask
327-
const maskedPhoneNumber = phoneNumber
328-
.substring(phoneNumber.length - lastN)
329-
.padStart(phoneNumber.length, '•');
330-
return `${prefix}${maskedPhoneNumber}`;
330+
const digits = phoneNumber.replace(/\D/g, '');
331+
// Clamp lastN between 0 and digits.length
332+
if (lastN > digits.length) {
333+
lastN = digits.length;
334+
}
335+
return digits.slice(-lastN);
331336
}
332337

333338
/**

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

+1
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export class SmsManager {
7272
const from = this.rotateFromNumber();
7373

7474
try {
75+
// Validate the `to` phone number and send the SMS
7576
const msg = await this.client.messages.create({
7677
to,
7778
from,

packages/fxa-auth-client/lib/client.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -2171,7 +2171,7 @@ export default class AuthClient {
21712171
sessionToken: string,
21722172
phoneNumber: string,
21732173
headers?: Headers
2174-
) {
2174+
): Promise<{ nationalFormat?: string; success: boolean }> {
21752175
return this.sessionPost(
21762176
'/recovery_phone/create',
21772177
sessionToken,
@@ -2205,7 +2205,7 @@ export default class AuthClient {
22052205
sessionToken: string,
22062206
code: string,
22072207
headers?: Headers
2208-
) {
2208+
): Promise<{ nationalFormat?: string }> {
22092209
return this.sessionPost(
22102210
'/recovery_phone/confirm',
22112211
sessionToken,

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

+29-16
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,18 @@ class RecoveryPhoneHandler {
175175
uid,
176176
});
177177
await this.glean.twoStepAuthPhoneCode.sent(request);
178-
return { status: RecoveryPhoneStatus.SUCCESS };
178+
179+
let nationalFormat: string | null = null;
180+
try {
181+
nationalFormat = await this.recoveryPhoneService.getNationalFormat(
182+
phoneNumber
183+
);
184+
} catch (e) {
185+
// This should not fail since the number was already validated with Twilio so
186+
// if it does it's a network problem - just return a null value and don't error out.
187+
}
188+
189+
return { status: RecoveryPhoneStatus.SUCCESS, nationalFormat };
179190
}
180191
await this.glean.twoStepAuthPhoneCode.sendError(request);
181192
return { status: RecoveryPhoneStatus.FAILURE };
@@ -277,17 +288,17 @@ class RecoveryPhoneHandler {
277288
this.log.info('account.recoveryPhone.phoneAdded.success', { uid });
278289

279290
try {
280-
const { phoneNumber } = await this.recoveryPhoneService.hasConfirmed(
281-
uid,
282-
4
283-
);
284-
291+
const { phoneNumber, nationalFormat } =
292+
await this.recoveryPhoneService.hasConfirmed(uid, 4);
285293
await this.mailer.sendPostAddRecoveryPhoneEmail(
286294
account.emails,
287295
account,
288296
{
289297
acceptLanguage,
290-
maskedLastFourPhoneNumber: phoneNumber?.slice(1),
298+
maskedLastFourPhoneNumber: `••••••${this.recoveryPhoneService.stripPhoneNumber(
299+
phoneNumber || '',
300+
4
301+
)}`,
291302
timeZone: geo.timeZone,
292303
uaBrowser: ua.browser,
293304
uaBrowserVersion: ua.browserVersion,
@@ -297,6 +308,11 @@ class RecoveryPhoneHandler {
297308
uid,
298309
}
299310
);
311+
return {
312+
phoneNumber,
313+
nationalFormat,
314+
status: RecoveryPhoneStatus.SUCCESS,
315+
};
300316
} catch (error) {
301317
// log email send error but don't throw
302318
// user should be allowed to proceed
@@ -448,21 +464,18 @@ class RecoveryPhoneHandler {
448464
async exists(request: AuthRequest) {
449465
const { uid, emailVerified, mustVerify, tokenVerified } = request.auth
450466
.credentials as SessionTokenAuthCredential;
451-
const payload = request.payload as unknown as { phoneNumberMask: number };
452-
let phoneNumberMask = payload?.phoneNumberMask;
453467

454468
// To ensure no data is leaked, we will never expose the full phone number, if
455469
// the session is not verified. e.g. The user has entered the correct password,
456470
// but failed to provide 2FA.
457-
if (
458-
phoneNumberMask === undefined &&
459-
(!emailVerified || (mustVerify && !tokenVerified))
460-
) {
461-
phoneNumberMask = 4;
462-
}
471+
const phoneNumberStrip =
472+
!emailVerified || (mustVerify && !tokenVerified) ? 4 : undefined;
463473

464474
try {
465-
return await this.recoveryPhoneService.hasConfirmed(uid, phoneNumberMask);
475+
return await this.recoveryPhoneService.hasConfirmed(
476+
uid,
477+
phoneNumberStrip
478+
);
466479
} catch (error) {
467480
if (error instanceof RecoveryPhoneNotEnabled) {
468481
throw AppError.featureNotEnabled();

packages/fxa-auth-server/lib/senders/emails/templates/postAddRecoveryPhone/en.ftl

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ postAddRecoveryPhone-preview = Account protected by two-step authentication
33
postAddRecoveryPhone-title = You created a recovery phone number
44
# Variables:
55
# $maskedLastFourPhoneNumber (String) - A bullet point mask with the last four digits of the user's phone number, e.g. ••••••1234
6-
postAddRecoveryPhone-description = You added { $maskedLastFourPhoneNumber } as your recovery phone
6+
postAddRecoveryPhone-description-v2 = You added { $maskedLastFourPhoneNumber } as your recovery phone number
77
# Links out to a support article about two factor authentication
88
postAddRecoveryPhone-how-protect = How this protects your account
99
postAddRecoveryPhone-how-protect-plaintext = How this protects your account:

packages/fxa-auth-server/lib/senders/emails/templates/postAddRecoveryPhone/index.mjml

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
</mj-text>
1010
1111
<mj-text css-class="text-body-no-margin">
12-
<span dir="ltr" data-l10n-id="postAddRecoveryPhone-description" data-l10n-args="<%= JSON.stringify({ maskedLastFourPhoneNumber }) %>">You added <%- maskedLastFourPhoneNumber %> as your recovery phone</span>
12+
<span dir="ltr" data-l10n-id="postAddRecoveryPhone-description-v2" data-l10n-args="<%= JSON.stringify({ maskedLastFourPhoneNumber }) %>">You added <%- maskedLastFourPhoneNumber %> as your recovery phone number</span>
1313
</mj-text>
1414
<mj-text css-class="text-body">
1515
<a class="link-blue" href="<%- twoFactorSupportLink %>">

packages/fxa-auth-server/lib/senders/emails/templates/postAddRecoveryPhone/index.txt

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
postAddRecoveryPhone-title = "You created a recovery phone number"
22

3-
postAddRecoveryPhone-description = "You added <%- maskedLastFourPhoneNumber %> as your recovery phone"
3+
postAddRecoveryPhone-description-v2 = "You added <%- maskedLastFourPhoneNumber %> as your recovery phone number"
44

55
postAddRecoveryPhone-how-protect-plaintext = "How this protects your account:"
66
<%- twoFactorSupportLink %>

0 commit comments

Comments
 (0)