Skip to content

Commit fde38f6

Browse files
authored
Merge pull request #18211 from mozilla/FXA-10947
task(auth): Add ability to mask recovery phone number
2 parents 7fb6f8d + faa4c66 commit fde38f6

4 files changed

Lines changed: 115 additions & 15 deletions

File tree

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,19 @@ describe('RecoveryPhoneService', () => {
133133
).toHaveBeenCalledWith(uid);
134134
});
135135

136+
it('can return masked phone number', async () => {
137+
mockRecoveryPhoneManager.getConfirmedPhoneNumber.mockReturnValueOnce({
138+
phoneNumber,
139+
});
140+
141+
const result = await service.hasConfirmed(uid, 4);
142+
143+
expect(result.phoneNumber).toEqual('+•••••••1234');
144+
expect(
145+
mockRecoveryPhoneManager.getConfirmedPhoneNumber
146+
).toHaveBeenCalledWith(uid);
147+
});
148+
136149
it('can determine confirmed phone number does not exist', async () => {
137150
const mockError = new RecoveryNumberNotExistsError(uid);
138151
mockRecoveryPhoneManager.getConfirmedPhoneNumber.mockRejectedValueOnce(
@@ -354,4 +367,15 @@ describe('RecoveryPhoneService', () => {
354367
expect(result).toBe(false);
355368
});
356369
});
370+
371+
describe('mask phone number', () => {
372+
it('can mask number', () => {
373+
const phoneNumber = '+123456789';
374+
expect(service.maskPhoneNumber(phoneNumber, -1)).toEqual('+•••••••••');
375+
expect(service.maskPhoneNumber(phoneNumber, 0)).toEqual('+•••••••••');
376+
expect(service.maskPhoneNumber(phoneNumber, 4)).toEqual('+•••••6789');
377+
expect(service.maskPhoneNumber(phoneNumber, 9)).toEqual('+123456789');
378+
expect(service.maskPhoneNumber(phoneNumber, 12)).toEqual('+123456789');
379+
});
380+
});
357381
});

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

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,17 +153,26 @@ export class RecoveryPhoneService {
153153
/**
154154
* Checks if the given uid has confirmed a phone number.
155155
* @param uid Account id
156+
* @param phoneNumberMask When provided will mask the number so the full value is not shown.
156157
* @returns If the account has confirmed, returns {exists:true, phoneNumber }. If not returns {exists:false}
158+
*
159+
* @remarks The value provided for phoneNumberMask will preserve the last N digits of of the phone number.
160+
* e.g. If the phone number was +15005551234 and phoneNumberMask was 4, the result would be +*******1234.
157161
*/
158162
public async hasConfirmed(
159-
uid: string
160-
): Promise<{ exists: boolean; phoneNumber?: string }> {
163+
uid: string,
164+
phoneNumberMask?: number
165+
): Promise<{
166+
exists: boolean;
167+
phoneNumber?: string;
168+
}> {
161169
try {
162170
const { phoneNumber } =
163171
await this.recoveryPhoneManager.getConfirmedPhoneNumber(uid);
172+
164173
return {
165174
exists: true,
166-
phoneNumber,
175+
phoneNumber: this.maskPhoneNumber(phoneNumber, phoneNumberMask),
167176
};
168177
} catch (err) {
169178
if (err instanceof RecoveryNumberNotExistsError) {
@@ -178,6 +187,41 @@ export class RecoveryPhoneService {
178187
}
179188
}
180189

190+
/**
191+
* Masks a phone number so as to not divulge the entire value.
192+
*
193+
* @param phoneNumber The actual phone number
194+
* @param lastN The last N number of digits to show
195+
* @returns A masked number
196+
*
197+
* @remarks This will not mask a + symbol, since this technically isn't part of the
198+
* number. e.g. +15005551234 would be masked as +*******1234 if lastN was 4.
199+
*/
200+
public maskPhoneNumber(phoneNumber: string, lastN?: number) {
201+
// The + notation can be confusing in a masked number. Don't count it
202+
// as a digit.
203+
let prefix = '';
204+
if (phoneNumber.startsWith('+')) {
205+
prefix = '+';
206+
phoneNumber = phoneNumber.substring(1);
207+
}
208+
209+
// Clamp lastN between 0 and phoneNumber.length
210+
if (lastN === undefined) {
211+
lastN = phoneNumber.length;
212+
} else if (lastN > phoneNumber.length) {
213+
lastN = phoneNumber.length;
214+
} else if (lastN < 0) {
215+
lastN = 0;
216+
}
217+
218+
// Create mask
219+
const maskedPhoneNumber = phoneNumber
220+
.substring(phoneNumber.length - lastN)
221+
.padStart(phoneNumber.length, '•');
222+
return `${prefix}${maskedPhoneNumber}`;
223+
}
224+
181225
/**
182226
* Sends an totp code to a user
183227
* @param uid Account id

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

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -252,17 +252,21 @@ class RecoveryPhoneHandler {
252252
async exists(request: AuthRequest) {
253253
const { uid, emailVerified, mustVerify, tokenVerified } = request.auth
254254
.credentials as SessionTokenAuthCredential;
255-
256-
// Short circuit if the account / session still needs verification.
257-
// Note this is typically due to totp being required, but there are
258-
// other states that could also result in an unverified session, such
259-
// as a forced password change.
260-
if (!emailVerified || (mustVerify && !tokenVerified)) {
261-
return {};
255+
const payload = request.payload as unknown as { phoneNumberMask: number };
256+
let phoneNumberMask = payload?.phoneNumberMask;
257+
258+
// To ensure no data is leaked, we will never expose the full phone number, if
259+
// the session is not verified. e.g. The user has entered the correct password,
260+
// but failed to provide 2FA.
261+
if (
262+
phoneNumberMask === undefined &&
263+
(!emailVerified || (mustVerify && !tokenVerified))
264+
) {
265+
phoneNumberMask = 4;
262266
}
263267

264268
try {
265-
return await this.recoveryPhoneService.hasConfirmed(uid);
269+
return await this.recoveryPhoneService.hasConfirmed(uid, phoneNumberMask);
266270
} catch (error) {
267271
throw AppError.backendServiceFailure(
268272
'RecoveryPhoneService',

packages/fxa-auth-server/test/local/routes/recovery-phone.js

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -439,7 +439,7 @@ describe('/recovery_phone', () => {
439439
);
440440
});
441441

442-
it('indicates service', async () => {
442+
it('indicates error', async () => {
443443
mockRecoveryPhoneService.hasConfirmed = sinon.fake.returns(
444444
Promise.reject(new Error('BOOM'))
445445
);
@@ -453,7 +453,7 @@ describe('/recovery_phone', () => {
453453
assert.equal(mockGlean.twoStepAuthPhoneRemove.success.callCount, 0);
454454
});
455455

456-
it('returns empty response for unverified session', async () => {
456+
it('returns masked phone number for unverified session', async () => {
457457
mockRecoveryPhoneService.hasConfirmed = sinon.fake.returns({
458458
exists: true,
459459
phoneNumber,
@@ -464,8 +464,36 @@ describe('/recovery_phone', () => {
464464
credentials: { uid, mustVerify: true },
465465
});
466466
assert.isDefined(resp);
467-
assert.isEmpty(resp);
468-
assert.equal(mockRecoveryPhoneService.hasConfirmed.callCount, 0);
467+
assert.isDefined(resp.exists);
468+
assert.isDefined(resp.phoneNumber);
469+
assert.equal(mockRecoveryPhoneService.hasConfirmed.callCount, 1);
470+
assert.equal(
471+
mockRecoveryPhoneService.hasConfirmed.getCall(0).args[0],
472+
uid
473+
);
474+
assert.equal(mockRecoveryPhoneService.hasConfirmed.getCall(0).args[1], 4);
475+
});
476+
477+
it('returns masked phone number format requested', async () => {
478+
mockRecoveryPhoneService.hasConfirmed = sinon.fake.returns({
479+
exists: true,
480+
phoneNumber,
481+
});
482+
const resp = await makeRequest({
483+
method: 'GET',
484+
path: '/recovery_phone',
485+
credentials: { uid, mustVerify: true },
486+
payload: { phoneNumberMask: 2 },
487+
});
488+
assert.isDefined(resp);
489+
assert.isDefined(resp.exists);
490+
assert.isDefined(resp.phoneNumber);
491+
assert.equal(mockRecoveryPhoneService.hasConfirmed.callCount, 1);
492+
assert.equal(
493+
mockRecoveryPhoneService.hasConfirmed.getCall(0).args[0],
494+
uid
495+
);
496+
assert.equal(mockRecoveryPhoneService.hasConfirmed.getCall(0).args[1], 2);
469497
});
470498
});
471499
});

0 commit comments

Comments
 (0)