Merge pull request #18007 from mozilla/FXA-10347

task(recovery-phone): Create new phone number setup method
This commit is contained in:
Dan Schomburg 2024-11-14 13:19:14 -08:00 коммит произвёл GitHub
Родитель a1b137c04f 35c0d738a0
Коммит 448265b44b
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
6 изменённых файлов: 188 добавлений и 3 удалений

Просмотреть файл

@ -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);
}
}

Просмотреть файл

@ -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<string>;
}

Просмотреть файл

@ -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>(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
);
});
});

Просмотреть файл

@ -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;
}
}

Просмотреть файл

@ -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;
}

Просмотреть файл

@ -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));
}