Skip to content

Commit faa4c66

Browse files
committed
task(recovery-phone): Add support for masking phone number server side
1 parent b036be5 commit faa4c66

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
@@ -243,17 +243,21 @@ class RecoveryPhoneHandler {
243243
async exists(request: AuthRequest) {
244244
const { uid, emailVerified, mustVerify, tokenVerified } = request.auth
245245
.credentials as SessionTokenAuthCredential;
246-
247-
// Short circuit if the account / session still needs verification.
248-
// Note this is typically due to totp being required, but there are
249-
// other states that could also result in an unverified session, such
250-
// as a forced password change.
251-
if (!emailVerified || (mustVerify && !tokenVerified)) {
252-
return {};
246+
const payload = request.payload as unknown as { phoneNumberMask: number };
247+
let phoneNumberMask = payload?.phoneNumberMask;
248+
249+
// To ensure no data is leaked, we will never expose the full phone number, if
250+
// the session is not verified. e.g. The user has entered the correct password,
251+
// but failed to provide 2FA.
252+
if (
253+
phoneNumberMask === undefined &&
254+
(!emailVerified || (mustVerify && !tokenVerified))
255+
) {
256+
phoneNumberMask = 4;
253257
}
254258

255259
try {
256-
return await this.recoveryPhoneService.hasConfirmed(uid);
260+
return await this.recoveryPhoneService.hasConfirmed(uid, phoneNumberMask);
257261
} catch (error) {
258262
throw AppError.backendServiceFailure(
259263
'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
@@ -419,7 +419,7 @@ describe('/recovery_phone', () => {
419419
);
420420
});
421421

422-
it('indicates service', async () => {
422+
it('indicates error', async () => {
423423
mockRecoveryPhoneService.hasConfirmed = sinon.fake.returns(
424424
Promise.reject(new Error('BOOM'))
425425
);
@@ -433,7 +433,7 @@ describe('/recovery_phone', () => {
433433
assert.equal(mockGlean.twoStepAuthPhoneRemove.success.callCount, 0);
434434
});
435435

436-
it('returns empty response for unverified session', async () => {
436+
it('returns masked phone number for unverified session', async () => {
437437
mockRecoveryPhoneService.hasConfirmed = sinon.fake.returns({
438438
exists: true,
439439
phoneNumber,
@@ -444,8 +444,36 @@ describe('/recovery_phone', () => {
444444
credentials: { uid, mustVerify: true },
445445
});
446446
assert.isDefined(resp);
447-
assert.isEmpty(resp);
448-
assert.equal(mockRecoveryPhoneService.hasConfirmed.callCount, 0);
447+
assert.isDefined(resp.exists);
448+
assert.isDefined(resp.phoneNumber);
449+
assert.equal(mockRecoveryPhoneService.hasConfirmed.callCount, 1);
450+
assert.equal(
451+
mockRecoveryPhoneService.hasConfirmed.getCall(0).args[0],
452+
uid
453+
);
454+
assert.equal(mockRecoveryPhoneService.hasConfirmed.getCall(0).args[1], 4);
455+
});
456+
457+
it('returns masked phone number format requested', async () => {
458+
mockRecoveryPhoneService.hasConfirmed = sinon.fake.returns({
459+
exists: true,
460+
phoneNumber,
461+
});
462+
const resp = await makeRequest({
463+
method: 'GET',
464+
path: '/recovery_phone',
465+
credentials: { uid, mustVerify: true },
466+
payload: { phoneNumberMask: 2 },
467+
});
468+
assert.isDefined(resp);
469+
assert.isDefined(resp.exists);
470+
assert.isDefined(resp.phoneNumber);
471+
assert.equal(mockRecoveryPhoneService.hasConfirmed.callCount, 1);
472+
assert.equal(
473+
mockRecoveryPhoneService.hasConfirmed.getCall(0).args[0],
474+
uid
475+
);
476+
assert.equal(mockRecoveryPhoneService.hasConfirmed.getCall(0).args[1], 2);
449477
});
450478
});
451479
});

0 commit comments

Comments
 (0)