From 35c0d738a07a3f58f2c3ae72087551c8361f297f Mon Sep 17 00:00:00 2001 From: Dan Schomburg <94418270+dschom@users.noreply.github.com> Date: Thu, 14 Nov 2024 10:03:12 -0800 Subject: [PATCH] task(recovery-phone): Create RecoveryPhoneService and new phone number setup method Because: - We want to be able to setup a new phone number This Commit: - Creates a recovery phone service - Stubs out storeUnconfirmed in RecoveryPhoneManager - Adds config for recovery phone service - Adds tests for recovery phone service - Adds new error type RecoveryNumberNotSupportedError - Public exposes generateCode on OtpManager. --- .../src/lib/recovery-phone.errors.ts | 6 ++ .../src/lib/recovery-phone.service.config.ts | 10 ++ .../src/lib/recovery-phone.service.spec.ts | 97 +++++++++++++++++++ .../src/lib/recovery-phone.service.ts | 52 ++++++++++ .../recovery-phone/src/lib/sms.manager.ts | 1 - libs/shared/otp/src/lib/otp.ts | 25 ++++- 6 files changed, 188 insertions(+), 3 deletions(-) create mode 100644 libs/accounts/recovery-phone/src/lib/recovery-phone.service.config.ts create mode 100644 libs/accounts/recovery-phone/src/lib/recovery-phone.service.spec.ts create mode 100644 libs/accounts/recovery-phone/src/lib/recovery-phone.service.ts diff --git a/libs/accounts/recovery-phone/src/lib/recovery-phone.errors.ts b/libs/accounts/recovery-phone/src/lib/recovery-phone.errors.ts index 48545dd7be..d70c919057 100644 --- a/libs/accounts/recovery-phone/src/lib/recovery-phone.errors.ts +++ b/libs/accounts/recovery-phone/src/lib/recovery-phone.errors.ts @@ -25,3 +25,9 @@ export class RecoveryNumberAlreadyExistsError extends RecoveryPhoneError { super('Recovery number already exists', { uid, phoneNumber }, cause); } } + +export class RecoveryNumberNotSupportedError extends RecoveryPhoneError { + constructor(uid: string, phoneNumber: string, cause?: Error) { + super('Phone number not supported.', { uid, phoneNumber }, cause); + } +} diff --git a/libs/accounts/recovery-phone/src/lib/recovery-phone.service.config.ts b/libs/accounts/recovery-phone/src/lib/recovery-phone.service.config.ts new file mode 100644 index 0000000000..dca19ea283 --- /dev/null +++ b/libs/accounts/recovery-phone/src/lib/recovery-phone.service.config.ts @@ -0,0 +1,10 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { IsArray } from 'class-validator'; + +export class RecoveryPhoneServiceConfig { + @IsArray() + public allowedNumbers?: Array; +} diff --git a/libs/accounts/recovery-phone/src/lib/recovery-phone.service.spec.ts b/libs/accounts/recovery-phone/src/lib/recovery-phone.service.spec.ts new file mode 100644 index 0000000000..00c9b0c7d7 --- /dev/null +++ b/libs/accounts/recovery-phone/src/lib/recovery-phone.service.spec.ts @@ -0,0 +1,97 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { RecoveryPhoneService } from './recovery-phone.service'; +import { OtpManager } from '@fxa/shared/otp'; +import { SmsManager } from './sms.manager'; +import { RecoveryPhoneServiceConfig } from './recovery-phone.service.config'; +import { RecoveryPhoneManager } from './recovery-phone.manager'; +import { RecoveryNumberNotSupportedError } from './recovery-phone.errors'; + +describe('RecoveryPhoneService', () => { + const phoneNumber = '+15005551234'; + const uid = '0123456789abcdef0123456789abcdef'; + const code = '000000'; + + const mockSmsManager = { sendSMS: jest.fn().mockReturnValue(true) }; + const mockRecoveryPhoneManager = { storeUnconfirmed: jest.fn() }; + const mockOtpManager = { generateCode: jest.fn() }; + const mockRecoveryPhoneServiceConfig = { + allowedNumbers: ['+1500'], + }; + const mockError = new Error('BOOM'); + + let service: RecoveryPhoneService; + + beforeEach(async () => { + jest.resetAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { provide: SmsManager, useValue: mockSmsManager }, + { provide: RecoveryPhoneManager, useValue: mockRecoveryPhoneManager }, + { provide: OtpManager, useValue: mockOtpManager }, + { + provide: RecoveryPhoneServiceConfig, + useValue: mockRecoveryPhoneServiceConfig, + }, + RecoveryPhoneService, + ], + }).compile(); + service = module.get(RecoveryPhoneService); + }); + + it('Should be injectable', () => { + expect(service).toBeDefined(); + expect(service).toBeInstanceOf(RecoveryPhoneService); + }); + + it('Should setup a phone number', async () => { + mockOtpManager.generateCode.mockReturnValue(code); + + const result = await service.setupPhoneNumber(uid, phoneNumber); + + expect(result).toBeTruthy(); + expect(mockOtpManager.generateCode).toBeCalled(); + expect(mockSmsManager.sendSMS).toBeCalledWith({ + to: phoneNumber, + body: code, + }); + expect(mockRecoveryPhoneManager.storeUnconfirmed).toBeCalledWith( + uid, + phoneNumber, + code, + true + ); + expect(result).toBeTruthy(); + }); + + it('Will reject a phone number that is not part of launch', async () => { + const to = '+16005551234'; + expect(service.setupPhoneNumber(uid, to)).rejects.toEqual( + new RecoveryNumberNotSupportedError(uid, to) + ); + }); + + it('Throws error during send sms', () => { + mockSmsManager.sendSMS.mockRejectedValueOnce(mockError); + expect(service.setupPhoneNumber(uid, phoneNumber)).rejects.toEqual( + mockError + ); + }); + + it('Throws error during otp code creation', () => { + mockOtpManager.generateCode.mockRejectedValueOnce(mockError); + expect(service.setupPhoneNumber(uid, phoneNumber)).rejects.toEqual( + mockError + ); + }); + + it('throws error during storing of unconfirmed number', () => { + mockRecoveryPhoneManager.storeUnconfirmed.mockRejectedValueOnce(mockError); + expect(service.setupPhoneNumber(uid, phoneNumber)).rejects.toEqual( + mockError + ); + }); +}); diff --git a/libs/accounts/recovery-phone/src/lib/recovery-phone.service.ts b/libs/accounts/recovery-phone/src/lib/recovery-phone.service.ts new file mode 100644 index 0000000000..6cf40816fd --- /dev/null +++ b/libs/accounts/recovery-phone/src/lib/recovery-phone.service.ts @@ -0,0 +1,52 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Injectable } from '@nestjs/common'; +import { SmsManager } from './sms.manager'; +import { OtpManager } from '@fxa/shared/otp'; +import { RecoveryPhoneServiceConfig } from './recovery-phone.service.config'; +import { RecoveryPhoneManager } from './recovery-phone.manager'; +import { RecoveryNumberNotSupportedError } from './recovery-phone.errors'; + +@Injectable() +export class RecoveryPhoneService { + constructor( + private readonly recoveryPhoneManager: RecoveryPhoneManager, + private readonly smsManager: SmsManager, + private readonly otpCode: OtpManager, + private readonly config: RecoveryPhoneServiceConfig + ) {} + + /** + * Setups (ie registers) a new phone number to an account uid. Accomplishes setup + * by sending the phone number provided an OTP code to verify. + * @param uid The account id + * @param phoneNumber The phone number to register + * @returns True if code was sent and stored + */ + public async setupPhoneNumber(uid: string, phoneNumber: string) { + if (this.config.allowedNumbers) { + const allowed = this.config.allowedNumbers.some((check) => { + return phoneNumber.startsWith(check); + }); + + if (!allowed) { + throw new RecoveryNumberNotSupportedError(uid, phoneNumber); + } + } + + const code = await this.otpCode.generateCode(); + await this.smsManager.sendSMS({ + to: phoneNumber, + body: code, + }); + await this.recoveryPhoneManager.storeUnconfirmed( + uid, + phoneNumber, + code, + true + ); + return true; + } +} diff --git a/libs/accounts/recovery-phone/src/lib/sms.manager.ts b/libs/accounts/recovery-phone/src/lib/sms.manager.ts index 3f33cba2ab..dc354494e2 100644 --- a/libs/accounts/recovery-phone/src/lib/sms.manager.ts +++ b/libs/accounts/recovery-phone/src/lib/sms.manager.ts @@ -51,7 +51,6 @@ export class SmsManager { }); return msg; } catch (err) { - // console.log('!!!', err, this.metrics.increment); this.metrics.increment('sms.send.error'); throw err; } diff --git a/libs/shared/otp/src/lib/otp.ts b/libs/shared/otp/src/lib/otp.ts index b0e0cbb372..5578d8147d 100644 --- a/libs/shared/otp/src/lib/otp.ts +++ b/libs/shared/otp/src/lib/otp.ts @@ -15,6 +15,7 @@ type OtpManagerOptions = { digits: number; }; +/** Manages creation and storage of otp codes. */ export class OtpManager { kind: OtpManagerOptions['kind']; digits: number; @@ -32,7 +33,11 @@ export class OtpManager { return `otp:${this.kind}:${key}`; } - private async getCode() { + /** + * Generates an otp code. Note that the calling code will be responsible for storing the code. + * @returns An otpCode + */ + public async generateCode() { const randInt = await new Promise((resolve, reject) => { randomInt(0, this.max, (err, value) => { if (err) reject(err); @@ -42,13 +47,24 @@ export class OtpManager { return String(randInt).padStart(this.digits, '0'); } + /** + * Creates and stores an otp code. + * @param key A unique key for the code + * @returns An otp code + */ async create(key: string) { const storageKey = this.getStorageKey(key); - const code = await this.getCode(); + const code = await this.generateCode(); await this.storage.set(storageKey, code); return code; } + /** + * Validates a previously created code. + * @param key The key + * @param code The code's value + * @returns True if code is valid + */ async isValid(key: string, code: string) { const storedVal = await this.storage.get(this.getStorageKey(key)); @@ -59,6 +75,11 @@ export class OtpManager { return timingSafeEqual(Buffer.from(code), Buffer.from(String(storedVal))); } + /** + * Removes a code from the store + * @param key Key to fetch code. + * @returns null upon deletion + */ async delete(key: string) { return await this.storage.del(this.getStorageKey(key)); }