зеркало из https://github.com/mozilla/fxa.git
Merge pull request #18007 from mozilla/FXA-10347
task(recovery-phone): Create new phone number setup method
This commit is contained in:
Коммит
448265b44b
|
@ -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));
|
||||
}
|
||||
|
|
Загрузка…
Ссылка в новой задаче