зеркало из https://github.com/mozilla/fxa.git
task(auth): Setup route handler for sms recovery phone
Because: - We want to hook up the API endpoints - We needed at least one end point to test this - We wanted to be able to setup a recovery phone number This Commit: - Setups the initialization process for the recovery phone service - Adds the /recovery-phone/create endpoint - Adds necessary configuration for recovery phone service / twilio - Adds unit tests - Adds integration tests - Adds necessary glean metrics - Does a little cleanup: - Uses actual RecoveryNumberNotSupportedError in sms.manager - Returns a 'failed' status incase something unexpected happens during sms send - Publicly exposes recovery-phone.errors and recovery-phone.service
This commit is contained in:
Родитель
91c20d82ef
Коммит
c860a37a84
|
@ -1,4 +1,6 @@
|
|||
export * from './lib/recovery-phone.manager';
|
||||
export * from './lib/recovery-phone.service';
|
||||
export * from './lib/sms.manager';
|
||||
export * from './lib/twilio.config';
|
||||
export * from './lib/twilio.provider';
|
||||
export * from './lib/recovery-phone.errors';
|
||||
|
|
|
@ -33,7 +33,7 @@ export class RecoveryNumberAlreadyExistsError extends RecoveryPhoneError {
|
|||
}
|
||||
|
||||
export class RecoveryNumberNotSupportedError extends RecoveryPhoneError {
|
||||
constructor(uid: string, phoneNumber: string, cause?: Error) {
|
||||
super('Phone number not supported.', { uid, phoneNumber }, cause);
|
||||
constructor(phoneNumber: string, cause?: Error) {
|
||||
super('Phone number not supported.', { phoneNumber }, cause);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,5 +6,5 @@ import { IsArray } from 'class-validator';
|
|||
|
||||
export class RecoveryPhoneServiceConfig {
|
||||
@IsArray()
|
||||
public allowedNumbers?: Array<string>;
|
||||
public validNumberPrefixes?: Array<string>;
|
||||
}
|
||||
|
|
|
@ -15,7 +15,9 @@ describe('RecoveryPhoneService', () => {
|
|||
const uid = '0123456789abcdef0123456789abcdef';
|
||||
const code = '000000';
|
||||
|
||||
const mockSmsManager = { sendSMS: jest.fn().mockReturnValue(true) };
|
||||
const mockSmsManager = {
|
||||
sendSMS: jest.fn(),
|
||||
};
|
||||
const mockRecoveryPhoneManager = {
|
||||
storeUnconfirmed: jest.fn(),
|
||||
getUnconfirmed: jest.fn(),
|
||||
|
@ -23,7 +25,7 @@ describe('RecoveryPhoneService', () => {
|
|||
};
|
||||
const mockOtpManager = { generateCode: jest.fn() };
|
||||
const mockRecoveryPhoneServiceConfig = {
|
||||
allowedNumbers: ['+1500'],
|
||||
validNumberPrefixes: ['+1500'],
|
||||
};
|
||||
const mockError = new Error('BOOM');
|
||||
|
||||
|
@ -31,6 +33,8 @@ describe('RecoveryPhoneService', () => {
|
|||
|
||||
beforeEach(async () => {
|
||||
jest.resetAllMocks();
|
||||
mockSmsManager.sendSMS.mockReturnValue({ status: 'success' });
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
{ provide: SmsManager, useValue: mockSmsManager },
|
||||
|
@ -73,28 +77,28 @@ describe('RecoveryPhoneService', () => {
|
|||
|
||||
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)
|
||||
await expect(service.setupPhoneNumber(uid, to)).rejects.toEqual(
|
||||
new RecoveryNumberNotSupportedError(to)
|
||||
);
|
||||
});
|
||||
|
||||
it('Throws error during send sms', () => {
|
||||
it('Throws error during send sms', async () => {
|
||||
mockSmsManager.sendSMS.mockRejectedValueOnce(mockError);
|
||||
expect(service.setupPhoneNumber(uid, phoneNumber)).rejects.toEqual(
|
||||
await expect(service.setupPhoneNumber(uid, phoneNumber)).rejects.toEqual(
|
||||
mockError
|
||||
);
|
||||
});
|
||||
|
||||
it('Throws error during otp code creation', () => {
|
||||
it('Throws error during otp code creation', async () => {
|
||||
mockOtpManager.generateCode.mockRejectedValueOnce(mockError);
|
||||
expect(service.setupPhoneNumber(uid, phoneNumber)).rejects.toEqual(
|
||||
await expect(service.setupPhoneNumber(uid, phoneNumber)).rejects.toEqual(
|
||||
mockError
|
||||
);
|
||||
});
|
||||
|
||||
it('throws error during storing of unconfirmed number', () => {
|
||||
it('throws error during storing of unconfirmed number', async () => {
|
||||
mockRecoveryPhoneManager.storeUnconfirmed.mockRejectedValueOnce(mockError);
|
||||
expect(service.setupPhoneNumber(uid, phoneNumber)).rejects.toEqual(
|
||||
await expect(service.setupPhoneNumber(uid, phoneNumber)).rejects.toEqual(
|
||||
mockError
|
||||
);
|
||||
});
|
||||
|
|
|
@ -26,21 +26,26 @@ export class RecoveryPhoneService {
|
|||
* @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) => {
|
||||
if (this.config.validNumberPrefixes) {
|
||||
const allowed = this.config.validNumberPrefixes.some((check) => {
|
||||
return phoneNumber.startsWith(check);
|
||||
});
|
||||
|
||||
if (!allowed) {
|
||||
throw new RecoveryNumberNotSupportedError(uid, phoneNumber);
|
||||
throw new RecoveryNumberNotSupportedError(phoneNumber);
|
||||
}
|
||||
}
|
||||
|
||||
const code = await this.otpCode.generateCode();
|
||||
await this.smsManager.sendSMS({
|
||||
const msg = await this.smsManager.sendSMS({
|
||||
to: phoneNumber,
|
||||
body: code,
|
||||
});
|
||||
|
||||
if (msg == null || msg.status === 'failed') {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.recoveryPhoneManager.storeUnconfirmed(
|
||||
uid,
|
||||
phoneNumber,
|
||||
|
|
|
@ -88,7 +88,7 @@ describe('SmsManager', () => {
|
|||
to: to.replace('+1', ''),
|
||||
body,
|
||||
})
|
||||
).rejects.toEqual(new Error('Number not allowed.'));
|
||||
).rejects.toEqual(new Error('Phone number not supported.'));
|
||||
});
|
||||
|
||||
it('Rejects long messages', async () => {
|
||||
|
|
|
@ -9,6 +9,7 @@ import { StatsD } from 'hot-shots';
|
|||
import { Twilio } from 'twilio';
|
||||
import { SmsManagerConfig } from './sms.manger.config';
|
||||
import { TwilioProvider } from './twilio.provider';
|
||||
import { RecoveryNumberNotSupportedError } from './recovery-phone.errors';
|
||||
|
||||
@Injectable()
|
||||
export class SmsManager {
|
||||
|
@ -30,7 +31,7 @@ export class SmsManager {
|
|||
if (
|
||||
!this.config.validNumberPrefixes.some((prefix) => to.startsWith(prefix))
|
||||
) {
|
||||
throw new Error(`Number not allowed.`);
|
||||
throw new RecoveryNumberNotSupportedError(to);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -43,6 +43,15 @@ const {
|
|||
AccountTasks,
|
||||
AccountTasksFactory,
|
||||
} = require('@fxa/shared/cloud-tasks');
|
||||
const { OtpManager } = require('@fxa/shared/otp');
|
||||
const {
|
||||
RecoveryPhoneManager,
|
||||
SmsManager,
|
||||
RecoveryPhoneService,
|
||||
TwilioFactory,
|
||||
} = require('@fxa/accounts/recovery-phone');
|
||||
const { setupAccountDatabase } = require('@fxa/shared/db/mysql/account');
|
||||
|
||||
async function run(config) {
|
||||
Container.set(AppConfig, config);
|
||||
|
||||
|
@ -194,6 +203,36 @@ async function run(config) {
|
|||
Container.get(AppleIAP);
|
||||
}
|
||||
|
||||
const twilio = TwilioFactory.useFactory(config.twilio);
|
||||
const recoveryPhoneDb = await setupAccountDatabase(
|
||||
config.database.mysql.auth
|
||||
);
|
||||
const recoveryPhoneRedis = require('../lib/redis')({
|
||||
...config.redis,
|
||||
...config.redis.recoveryPhone,
|
||||
});
|
||||
const otpCodeManager = new OtpManager(
|
||||
config.recoveryPhone.otp,
|
||||
recoveryPhoneRedis
|
||||
);
|
||||
const recoveryPhoneManager = new RecoveryPhoneManager(
|
||||
recoveryPhoneDb,
|
||||
recoveryPhoneRedis
|
||||
);
|
||||
const smsManager = new SmsManager(
|
||||
twilio,
|
||||
statsd,
|
||||
log,
|
||||
config.recoveryPhone.sms
|
||||
);
|
||||
const recoveryPhoneService = new RecoveryPhoneService(
|
||||
recoveryPhoneManager,
|
||||
smsManager,
|
||||
otpCodeManager,
|
||||
config.recoveryPhone.sms
|
||||
);
|
||||
Container.set('RecoveryPhoneService', recoveryPhoneService);
|
||||
|
||||
// The AccountDeleteManager is dependent on some of the object set into
|
||||
// Container above.
|
||||
const accountTasks = AccountTasksFactory(config, statsd);
|
||||
|
@ -240,7 +279,6 @@ async function run(config) {
|
|||
config
|
||||
);
|
||||
const glean = gleanMetrics(config);
|
||||
|
||||
const routes = require('../lib/routes')(
|
||||
log,
|
||||
serverPublicKeys,
|
||||
|
|
|
@ -2074,6 +2074,56 @@ const convictConf = convict({
|
|||
},
|
||||
},
|
||||
},
|
||||
recoveryPhone: {
|
||||
otp: {
|
||||
kind: {
|
||||
default: 'recovery-phone-code',
|
||||
doc: 'An identifier for the type of otp codes being sent out',
|
||||
env: 'RECOVERY_PHONE__OTP__KIND',
|
||||
format: String,
|
||||
},
|
||||
digits: {
|
||||
default: 6,
|
||||
doc: 'The number of digits in an otp code',
|
||||
env: 'RECOVERY_PHONE__OTP__DIGITS',
|
||||
format: Number,
|
||||
},
|
||||
},
|
||||
redis: {},
|
||||
sms: {
|
||||
from: {
|
||||
default: '555555',
|
||||
doc: 'The twilio number messages are sent from. This should be a short-code resource.',
|
||||
env: 'RECOVERY_PHONE__SMS__FROM',
|
||||
format: String,
|
||||
},
|
||||
maxMessageLength: {
|
||||
default: 60,
|
||||
doc: 'Max allows sms message lenght',
|
||||
env: 'RECOVERY_PHONE__SMS__MAX_MESSAGE_LENGTH',
|
||||
format: Number,
|
||||
},
|
||||
validNumberPrefixes: {
|
||||
default: ['+1'], // USA and Canada
|
||||
doc: 'Allowed phone number prefixes. Controls the locales that a message can be sent to.',
|
||||
env: 'RECOVERY_PHONE__SMS__VALID_NUMBER_PREFIXES',
|
||||
format: Array,
|
||||
},
|
||||
},
|
||||
},
|
||||
twilio: {
|
||||
accountSid: {
|
||||
default: 'AC_REPLACEMEWITHKEY',
|
||||
doc: 'Twilio Account ID',
|
||||
env: 'RECOVERY_PHONE__TWILIO__ACCOUNT_SID',
|
||||
format: String,
|
||||
},
|
||||
authToken: {
|
||||
default: '?',
|
||||
doc: 'Twilio Auth Token, required to access api',
|
||||
env: 'RECOVERY_PHONE_TWILIO_AUTH_TOKEN',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// handle configuration files. you can specify a CSV list of configuration
|
||||
|
|
|
@ -106,7 +106,7 @@ const getMetricMethod = (eventName: string) => {
|
|||
!gleanServerEventLogger[methodName as keyof typeof gleanServerEventLogger]
|
||||
) {
|
||||
process.stderr.write(
|
||||
`Method ${methodName} not found in gleanServerEventLogger`
|
||||
`Method ${methodName} for eventName ${eventName} not found in gleanServerEventLogger`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
@ -298,10 +298,15 @@ export function gleanMetrics(config: ConfigType) {
|
|||
account: {
|
||||
deleteComplete: createEventFn('account_delete_complete'),
|
||||
},
|
||||
|
||||
twoFactorAuth: {
|
||||
codeComplete: createEventFn('two_factor_auth_code_complete'),
|
||||
},
|
||||
twoFactorAuthSetup: {
|
||||
sentPhoneCode: createEventFn('two_factor_auth_setup_sent_phone_code'),
|
||||
sendPhoneCodeError: createEventFn(
|
||||
'two_factor_auth_setup_send_phone_code_error'
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -334,6 +339,7 @@ export const logErrorWithGlean = ({
|
|||
| 'thirdPartyAuth'
|
||||
| 'account'
|
||||
| 'twoFactorAuth'
|
||||
| 'twoFactorAuthSetup'
|
||||
>
|
||||
];
|
||||
funnelFns[event as keyof typeof funnelFns](request, {
|
||||
|
|
|
@ -2964,6 +2964,170 @@ class EventsServerEventLogger {
|
|||
event,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Record and submit a two_factor_auth_setup_send_phone_code_error event:
|
||||
* User encounters error while submitting a new phone number to be used as a recovery option
|
||||
* Event is logged using internal mozlog logger.
|
||||
*
|
||||
* @param {string} user_agent - The user agent.
|
||||
* @param {string} ip_address - The IP address. Will be used to decode Geo
|
||||
* information and scrubbed at ingestion.
|
||||
* @param {string} account_user_id - The firefox/mozilla account id.
|
||||
* @param {string} account_user_id_sha256 - A hex string of a sha256 hash of the account's uid.
|
||||
* @param {string} relying_party_oauth_client_id - The client id of the relying party.
|
||||
* @param {string} relying_party_service - The service name of the relying party.
|
||||
* @param {string} session_device_type - one of 'mobile', 'tablet', or ''.
|
||||
* @param {string} session_entrypoint - Entrypoint to the service.
|
||||
* @param {string} session_entrypoint_experiment - Identifier for the experiment the user is part of at the entrypoint.
|
||||
* @param {string} session_entrypoint_variation - Identifier for the experiment variation the user is part of at the entrypoint.
|
||||
* @param {string} session_flow_id - an ID generated by FxA for its flow metrics.
|
||||
* @param {string} utm_campaign - A marketing campaign. For example, if a user signs into FxA from selecting a Mozilla VPN plan on Mozilla VPN's product site, then the value of this metric could be 'vpn-product-page'. The value has a max length of 128 characters with the alphanumeric characters, _ (underscore), forward slash (/), . (period), % (percentage sign), and - (hyphen) in the allowed set of characters. The special value of 'page+referral+-+not+part+of+a+campaign' is also allowed..
|
||||
* @param {string} utm_content - The content on which the user acted. For example, if the user clicked on the (previously available) "Get started here" link in "Looking for Firefox Sync? Get started here", then the value for this metric would be 'fx-sync-get-started'. The value has a max length of 128 characters with the alphanumeric characters, _ (underscore), forward slash (/), . (period), % (percentage sign), and - (hyphen) in the allowed set of characters..
|
||||
* @param {string} utm_medium - The "medium" on which the user acted. For example, if the user clicked on a link in an email, then the value of this metric would be 'email'. The value has a max length of 128 characters with the alphanumeric characters, _ (underscore), forward slash (/), . (period), % (percentage sign), and - (hyphen) in the allowed set of characters..
|
||||
* @param {string} utm_source - The source from where the user started. For example, if the user clicked on a link on the Mozilla accounts web site, this value could be 'fx-website'. The value has a max length of 128 characters with the alphanumeric characters, _ (underscore), forward slash (/), . (period), % (percentage sign), and - (hyphen) in the allowed set of characters..
|
||||
* @param {string} utm_term - This metric is similar to the `utm.source`; it is used in the Firefox browser. For example, if the user started from about:welcome, then the value could be 'aboutwelcome-default-screen'. The value has a max length of 128 characters with the alphanumeric characters, _ (underscore), forward slash (/), . (period), % (percentage sign), and - (hyphen) in the allowed set of characters..
|
||||
*/
|
||||
recordTwoFactorAuthSetupSendPhoneCodeError({
|
||||
user_agent,
|
||||
ip_address,
|
||||
account_user_id,
|
||||
account_user_id_sha256,
|
||||
relying_party_oauth_client_id,
|
||||
relying_party_service,
|
||||
session_device_type,
|
||||
session_entrypoint,
|
||||
session_entrypoint_experiment,
|
||||
session_entrypoint_variation,
|
||||
session_flow_id,
|
||||
utm_campaign,
|
||||
utm_content,
|
||||
utm_medium,
|
||||
utm_source,
|
||||
utm_term,
|
||||
}: {
|
||||
user_agent: string;
|
||||
ip_address: string;
|
||||
account_user_id: string;
|
||||
account_user_id_sha256: string;
|
||||
relying_party_oauth_client_id: string;
|
||||
relying_party_service: string;
|
||||
session_device_type: string;
|
||||
session_entrypoint: string;
|
||||
session_entrypoint_experiment: string;
|
||||
session_entrypoint_variation: string;
|
||||
session_flow_id: string;
|
||||
utm_campaign: string;
|
||||
utm_content: string;
|
||||
utm_medium: string;
|
||||
utm_source: string;
|
||||
utm_term: string;
|
||||
}) {
|
||||
const event = {
|
||||
category: 'two_factor_auth_setup',
|
||||
name: 'send_phone_code_error',
|
||||
};
|
||||
this.#record({
|
||||
user_agent,
|
||||
ip_address,
|
||||
account_user_id,
|
||||
account_user_id_sha256,
|
||||
relying_party_oauth_client_id,
|
||||
relying_party_service,
|
||||
session_device_type,
|
||||
session_entrypoint,
|
||||
session_entrypoint_experiment,
|
||||
session_entrypoint_variation,
|
||||
session_flow_id,
|
||||
utm_campaign,
|
||||
utm_content,
|
||||
utm_medium,
|
||||
utm_source,
|
||||
utm_term,
|
||||
event,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Record and submit a two_factor_auth_setup_sent_phone_code event:
|
||||
* User successfully submits a new phone number to be used as a recovery option
|
||||
* Event is logged using internal mozlog logger.
|
||||
*
|
||||
* @param {string} user_agent - The user agent.
|
||||
* @param {string} ip_address - The IP address. Will be used to decode Geo
|
||||
* information and scrubbed at ingestion.
|
||||
* @param {string} account_user_id - The firefox/mozilla account id.
|
||||
* @param {string} account_user_id_sha256 - A hex string of a sha256 hash of the account's uid.
|
||||
* @param {string} relying_party_oauth_client_id - The client id of the relying party.
|
||||
* @param {string} relying_party_service - The service name of the relying party.
|
||||
* @param {string} session_device_type - one of 'mobile', 'tablet', or ''.
|
||||
* @param {string} session_entrypoint - Entrypoint to the service.
|
||||
* @param {string} session_entrypoint_experiment - Identifier for the experiment the user is part of at the entrypoint.
|
||||
* @param {string} session_entrypoint_variation - Identifier for the experiment variation the user is part of at the entrypoint.
|
||||
* @param {string} session_flow_id - an ID generated by FxA for its flow metrics.
|
||||
* @param {string} utm_campaign - A marketing campaign. For example, if a user signs into FxA from selecting a Mozilla VPN plan on Mozilla VPN's product site, then the value of this metric could be 'vpn-product-page'. The value has a max length of 128 characters with the alphanumeric characters, _ (underscore), forward slash (/), . (period), % (percentage sign), and - (hyphen) in the allowed set of characters. The special value of 'page+referral+-+not+part+of+a+campaign' is also allowed..
|
||||
* @param {string} utm_content - The content on which the user acted. For example, if the user clicked on the (previously available) "Get started here" link in "Looking for Firefox Sync? Get started here", then the value for this metric would be 'fx-sync-get-started'. The value has a max length of 128 characters with the alphanumeric characters, _ (underscore), forward slash (/), . (period), % (percentage sign), and - (hyphen) in the allowed set of characters..
|
||||
* @param {string} utm_medium - The "medium" on which the user acted. For example, if the user clicked on a link in an email, then the value of this metric would be 'email'. The value has a max length of 128 characters with the alphanumeric characters, _ (underscore), forward slash (/), . (period), % (percentage sign), and - (hyphen) in the allowed set of characters..
|
||||
* @param {string} utm_source - The source from where the user started. For example, if the user clicked on a link on the Mozilla accounts web site, this value could be 'fx-website'. The value has a max length of 128 characters with the alphanumeric characters, _ (underscore), forward slash (/), . (period), % (percentage sign), and - (hyphen) in the allowed set of characters..
|
||||
* @param {string} utm_term - This metric is similar to the `utm.source`; it is used in the Firefox browser. For example, if the user started from about:welcome, then the value could be 'aboutwelcome-default-screen'. The value has a max length of 128 characters with the alphanumeric characters, _ (underscore), forward slash (/), . (period), % (percentage sign), and - (hyphen) in the allowed set of characters..
|
||||
*/
|
||||
recordTwoFactorAuthSetupSentPhoneCode({
|
||||
user_agent,
|
||||
ip_address,
|
||||
account_user_id,
|
||||
account_user_id_sha256,
|
||||
relying_party_oauth_client_id,
|
||||
relying_party_service,
|
||||
session_device_type,
|
||||
session_entrypoint,
|
||||
session_entrypoint_experiment,
|
||||
session_entrypoint_variation,
|
||||
session_flow_id,
|
||||
utm_campaign,
|
||||
utm_content,
|
||||
utm_medium,
|
||||
utm_source,
|
||||
utm_term,
|
||||
}: {
|
||||
user_agent: string;
|
||||
ip_address: string;
|
||||
account_user_id: string;
|
||||
account_user_id_sha256: string;
|
||||
relying_party_oauth_client_id: string;
|
||||
relying_party_service: string;
|
||||
session_device_type: string;
|
||||
session_entrypoint: string;
|
||||
session_entrypoint_experiment: string;
|
||||
session_entrypoint_variation: string;
|
||||
session_flow_id: string;
|
||||
utm_campaign: string;
|
||||
utm_content: string;
|
||||
utm_medium: string;
|
||||
utm_source: string;
|
||||
utm_term: string;
|
||||
}) {
|
||||
const event = {
|
||||
category: 'two_factor_auth_setup',
|
||||
name: 'sent_phone_code',
|
||||
};
|
||||
this.#record({
|
||||
user_agent,
|
||||
ip_address,
|
||||
account_user_id,
|
||||
account_user_id_sha256,
|
||||
relying_party_oauth_client_id,
|
||||
relying_party_service,
|
||||
session_device_type,
|
||||
session_entrypoint,
|
||||
session_entrypoint_experiment,
|
||||
session_entrypoint_variation,
|
||||
session_flow_id,
|
||||
utm_campaign,
|
||||
utm_content,
|
||||
utm_medium,
|
||||
utm_source,
|
||||
utm_term,
|
||||
event,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -133,6 +133,10 @@ module.exports = function (
|
|||
authServerCacheRedis,
|
||||
statsd
|
||||
);
|
||||
const recoveryPhone = require('./recovery-phone').recoveryPhoneRoutes(
|
||||
log,
|
||||
glean
|
||||
);
|
||||
const securityEvents = require('./security-events')(log, db, config);
|
||||
const session = require('./session')(
|
||||
log,
|
||||
|
@ -232,6 +236,7 @@ module.exports = function (
|
|||
emails,
|
||||
password,
|
||||
recoveryCodes,
|
||||
recoveryPhone,
|
||||
securityEvents,
|
||||
session,
|
||||
sign,
|
||||
|
|
|
@ -0,0 +1,92 @@
|
|||
/* 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 {
|
||||
RecoveryPhoneService,
|
||||
RecoveryNumberNotSupportedError,
|
||||
RecoveryNumberInvalidFormatError,
|
||||
RecoveryNumberAlreadyExistsError,
|
||||
} from '@fxa/accounts/recovery-phone';
|
||||
import * as isA from 'joi';
|
||||
import { GleanMetricsType } from '../metrics/glean';
|
||||
import { AuthLogger, AuthRequest } from '../types';
|
||||
import { E164_NUMBER } from './validators';
|
||||
import AppError from '../error';
|
||||
const { Container } = require('typedi');
|
||||
|
||||
class RecoveryPhoneHandler {
|
||||
private readonly recoveryPhoneService: RecoveryPhoneService;
|
||||
constructor(
|
||||
private readonly log: AuthLogger,
|
||||
private readonly glean: GleanMetricsType
|
||||
) {
|
||||
this.recoveryPhoneService = Container.get('RecoveryPhoneService');
|
||||
}
|
||||
|
||||
async setupPhoneNumber(request: AuthRequest) {
|
||||
const { uid } = request.auth.credentials as unknown as { uid: string };
|
||||
const { phoneNumber } = request.payload as unknown as {
|
||||
phoneNumber: string;
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await this.recoveryPhoneService.setupPhoneNumber(
|
||||
uid,
|
||||
phoneNumber
|
||||
);
|
||||
if (result) {
|
||||
await this.glean.twoFactorAuthSetup.sentPhoneCode(request);
|
||||
return { status: 'success' };
|
||||
}
|
||||
await this.glean.twoFactorAuthSetup.sendPhoneCodeError(request);
|
||||
return { status: 'failure' };
|
||||
} catch (error) {
|
||||
await this.glean.twoFactorAuthSetup.sendPhoneCodeError(request);
|
||||
|
||||
if (
|
||||
error instanceof RecoveryNumberInvalidFormatError ||
|
||||
error instanceof RecoveryNumberNotSupportedError ||
|
||||
error instanceof RecoveryNumberAlreadyExistsError
|
||||
) {
|
||||
throw AppError.invalidPhoneNumber();
|
||||
}
|
||||
|
||||
throw AppError.backendServiceFailure(
|
||||
'RecoveryPhoneService',
|
||||
'setupPhoneNumber',
|
||||
{ uid },
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const recoveryPhoneRoutes = (
|
||||
log: AuthLogger,
|
||||
glean: GleanMetricsType
|
||||
) => {
|
||||
const recoveryPhoneHandler = new RecoveryPhoneHandler(log, glean);
|
||||
const routes = [
|
||||
// TODO: See blocked tasks for FXA-10354
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/recovery-phone/create',
|
||||
options: {
|
||||
auth: {
|
||||
strategies: ['sessionToken'],
|
||||
},
|
||||
validate: {
|
||||
payload: isA.object({
|
||||
phoneNumber: isA.string().regex(E164_NUMBER).required(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
handler: function (request: AuthRequest) {
|
||||
return recoveryPhoneHandler.setupPhoneNumber(request);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return routes;
|
||||
};
|
|
@ -944,6 +944,21 @@ module.exports = (config) => {
|
|||
});
|
||||
};
|
||||
|
||||
ClientApi.prototype.recoveryPhoneNumberCreate = async function (
|
||||
sessionTokenHex,
|
||||
phoneNumber
|
||||
) {
|
||||
const token = await tokens.SessionToken.fromHex(sessionTokenHex);
|
||||
return await this.doRequest(
|
||||
'POST',
|
||||
`${this.baseURL}/recovery-phone/create`,
|
||||
token,
|
||||
{
|
||||
phoneNumber,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
ClientApi.prototype.sessionDestroy = function (sessionTokenHex, options) {
|
||||
let data = null;
|
||||
|
||||
|
|
|
@ -343,6 +343,16 @@ module.exports = (config) => {
|
|||
return p;
|
||||
};
|
||||
|
||||
Client.prototype.createRecoveryPhoneNumber = async function (phoneNumber) {
|
||||
if (this.sessionToken) {
|
||||
const resp = await this.api.recoveryPhoneNumberCreate(
|
||||
this.sessionToken,
|
||||
phoneNumber
|
||||
);
|
||||
return resp;
|
||||
}
|
||||
};
|
||||
|
||||
Client.prototype.reauth = function (opts) {
|
||||
return this.api
|
||||
.sessionReauth(
|
||||
|
|
|
@ -42,6 +42,8 @@ const recordAccountDeleteCompleteStub = sinon.stub();
|
|||
const recordPasswordResetEmailConfirmationSentStub = sinon.stub();
|
||||
const recordPasswordResetEmailConfirmationSuccessStub = sinon.stub();
|
||||
const recordTwoFactorAuthCodeCompleteStub = sinon.stub();
|
||||
const recordTwoFactorAuthSetupSentPhoneCodeStub = sinon.stub();
|
||||
const recordTwoFactorAuthSetupSendPhoneCodeErrorStub = sinon.stub();
|
||||
const recordPasswordResetTwoFactorSuccessStub = sinon.stub();
|
||||
const recordPasswordResetRecoveryCodeSuccessStub = sinon.stub();
|
||||
|
||||
|
@ -89,6 +91,10 @@ const gleanProxy = proxyquire('../../../lib/metrics/glean', {
|
|||
recordPasswordResetEmailConfirmationSuccess:
|
||||
recordPasswordResetEmailConfirmationSuccessStub,
|
||||
recordTwoFactorAuthCodeComplete: recordTwoFactorAuthCodeCompleteStub,
|
||||
recordTwoFactorAuthSetupSentPhoneCode:
|
||||
recordTwoFactorAuthSetupSentPhoneCodeStub,
|
||||
recordTwoFactorAuthSetupSendPhoneCodeError:
|
||||
recordTwoFactorAuthSetupSendPhoneCodeErrorStub,
|
||||
recordPasswordResetTwoFactorSuccess:
|
||||
recordPasswordResetTwoFactorSuccessStub,
|
||||
recordPasswordResetRecoveryCodeSuccess:
|
||||
|
|
|
@ -0,0 +1,157 @@
|
|||
/* 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/. */
|
||||
|
||||
const chai = require('chai');
|
||||
const chaiAsPromised = require('chai-as-promised');
|
||||
|
||||
const sinon = require('sinon');
|
||||
const assert = { ...sinon.assert, ...chai.assert };
|
||||
const { recoveryPhoneRoutes } = require('../../../lib/routes/recovery-phone');
|
||||
import { RecoveryNumberNotSupportedError } from '@fxa/accounts/recovery-phone';
|
||||
|
||||
const { getRoute } = require('../../routes_helpers');
|
||||
const { mockRequest } = require('../../mocks');
|
||||
const { Container } = require('typedi');
|
||||
chai.use(chaiAsPromised);
|
||||
|
||||
describe('/recovery-phone', () => {
|
||||
const sandbox = sinon.createSandbox();
|
||||
const uid = '123435678123435678123435678123435678';
|
||||
const phoneNumber = '+15550005555';
|
||||
const mockLog = {};
|
||||
const mockGlean = {
|
||||
twoFactorAuthSetup: {
|
||||
sentPhoneCode: sandbox.fake(),
|
||||
sendPhoneCodeError: sandbox.fake(),
|
||||
},
|
||||
};
|
||||
const mockRecoveryPhoneService = {
|
||||
setupPhoneNumber: sandbox.fake(),
|
||||
};
|
||||
let routes = [];
|
||||
|
||||
before(() => {
|
||||
Container.set('RecoveryPhoneService', mockRecoveryPhoneService);
|
||||
routes = recoveryPhoneRoutes(mockLog, mockGlean);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sandbox.reset();
|
||||
});
|
||||
|
||||
async function makeRequest(req) {
|
||||
const route = getRoute(routes, req.path, req.method);
|
||||
return await route.handler(mockRequest(req));
|
||||
}
|
||||
|
||||
describe('POST /recovery-phone/create', () => {
|
||||
it('creates recovery phone number', async () => {
|
||||
mockRecoveryPhoneService.setupPhoneNumber = sinon.fake.returns(true);
|
||||
|
||||
const resp = await makeRequest({
|
||||
method: 'POST',
|
||||
path: '/recovery-phone/create',
|
||||
credentials: { uid },
|
||||
payload: { phoneNumber },
|
||||
});
|
||||
|
||||
assert.isDefined(resp);
|
||||
assert.equal(resp.status, 'success');
|
||||
assert.equal(mockRecoveryPhoneService.setupPhoneNumber.callCount, 1);
|
||||
assert.equal(
|
||||
mockRecoveryPhoneService.setupPhoneNumber.getCall(0).args[0],
|
||||
uid
|
||||
);
|
||||
assert.equal(
|
||||
mockRecoveryPhoneService.setupPhoneNumber.getCall(0).args[1],
|
||||
phoneNumber
|
||||
);
|
||||
assert.equal(mockGlean.twoFactorAuthSetup.sentPhoneCode.callCount, 1);
|
||||
assert.equal(
|
||||
mockGlean.twoFactorAuthSetup.sendPhoneCodeError.callCount,
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
it('indicates failure sending sms', async () => {
|
||||
mockRecoveryPhoneService.setupPhoneNumber = sinon.fake.returns(false);
|
||||
|
||||
const resp = await makeRequest({
|
||||
method: 'POST',
|
||||
path: '/recovery-phone/create',
|
||||
credentials: { uid },
|
||||
payload: { phoneNumber: 'invalid' },
|
||||
});
|
||||
|
||||
assert.isDefined(resp);
|
||||
assert.equal(resp.status, 'failure');
|
||||
assert.equal(mockGlean.twoFactorAuthSetup.sentPhoneCode.callCount, 0);
|
||||
assert.equal(
|
||||
mockGlean.twoFactorAuthSetup.sendPhoneCodeError.callCount,
|
||||
1
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an unsupported dialing code', async () => {
|
||||
mockRecoveryPhoneService.setupPhoneNumber = sinon.fake.returns(
|
||||
Promise.reject(new RecoveryNumberNotSupportedError())
|
||||
);
|
||||
|
||||
const promise = makeRequest({
|
||||
method: 'POST',
|
||||
path: '/recovery-phone/create',
|
||||
credentials: { uid },
|
||||
payload: { phoneNumber: '+495550005555' },
|
||||
});
|
||||
|
||||
await assert.isRejected(promise, 'Invalid phone number');
|
||||
assert.equal(mockGlean.twoFactorAuthSetup.sentPhoneCode.callCount, 0);
|
||||
assert.equal(
|
||||
mockGlean.twoFactorAuthSetup.sendPhoneCodeError.callCount,
|
||||
1
|
||||
);
|
||||
});
|
||||
|
||||
it('handles unexpected backend error', async () => {
|
||||
mockRecoveryPhoneService.setupPhoneNumber = sinon.fake.returns(
|
||||
Promise.reject(new Error('BOOM'))
|
||||
);
|
||||
|
||||
const promise = makeRequest({
|
||||
method: 'POST',
|
||||
path: '/recovery-phone/create',
|
||||
credentials: { uid },
|
||||
payload: { phoneNumber },
|
||||
});
|
||||
|
||||
await assert.isRejected(promise, 'A backend service request failed.');
|
||||
assert.equal(mockGlean.twoFactorAuthSetup.sentPhoneCode.callCount, 0);
|
||||
assert.equal(
|
||||
mockGlean.twoFactorAuthSetup.sendPhoneCodeError.callCount,
|
||||
1
|
||||
);
|
||||
});
|
||||
|
||||
it('validates incoming phone number', () => {
|
||||
const route = getRoute(routes, '/recovery-phone/create', 'POST');
|
||||
const joiSchema = route.options.validate.payload;
|
||||
|
||||
const validNumber = joiSchema.validate({ phoneNumber: '+15550005555' });
|
||||
const missingNumber = joiSchema.validate({});
|
||||
const invalidNumber = joiSchema.validate({ phoneNumber: '5550005555' });
|
||||
|
||||
assert.isUndefined(validNumber.error);
|
||||
assert.include(missingNumber.error.message, 'is required');
|
||||
assert.include(
|
||||
invalidNumber.error.message,
|
||||
'fails to match the required pattern'
|
||||
);
|
||||
});
|
||||
|
||||
it('requires session authorization', () => {
|
||||
const route = getRoute(routes, '/recovery-phone/create', 'POST');
|
||||
assert.include(route.options.auth.strategies, 'sessionToken');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -192,6 +192,7 @@ describe('#integration - /v1', function () {
|
|||
|
||||
before(async function () {
|
||||
Server = await testServer.start();
|
||||
assert.isDefined(Server);
|
||||
AN_ASSERTION = await genAssertion(
|
||||
USERID + config.get('oauthServer.browserid.issuer')
|
||||
);
|
||||
|
@ -2864,6 +2865,7 @@ describe('#integration - /v1', function () {
|
|||
);
|
||||
config.set('oauthServer.expiration.accessTokenExpiryEpoch', undefined);
|
||||
Server = await testServer.start();
|
||||
assert.isDefined(Server);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
|
@ -2873,6 +2875,7 @@ describe('#integration - /v1', function () {
|
|||
accessTokenExpiryEpoch
|
||||
);
|
||||
Server = await testServer.start();
|
||||
assert.isDefined(Server);
|
||||
});
|
||||
|
||||
it('should not reject expired tokens from pocket clients', async function () {
|
||||
|
|
|
@ -0,0 +1,48 @@
|
|||
/* 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/. */
|
||||
|
||||
'use strict';
|
||||
|
||||
const chai = require('chai');
|
||||
const chaiAsPromised = require('chai-as-promised');
|
||||
const Client = require('../client')();
|
||||
const TestServer = require('../test_server');
|
||||
const config = require('../../config').default.getProperties();
|
||||
|
||||
const { assert } = chai;
|
||||
chai.use(chaiAsPromised);
|
||||
|
||||
describe(`#integration - recovery phone`, function () {
|
||||
this.timeout(60000);
|
||||
let server;
|
||||
|
||||
before(async function () {
|
||||
config.securityHistory.ipProfiling.allowedRecency = 0;
|
||||
config.signinConfirmation.skipForNewAccounts.enabled = false;
|
||||
server = await TestServer.start(config);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await TestServer.stop(server);
|
||||
});
|
||||
|
||||
it('adds a recovery phone number to the account', async () => {
|
||||
const email = server.uniqueEmail();
|
||||
const password = 'password';
|
||||
const client = await Client.createAndVerify(
|
||||
config.publicUrl,
|
||||
email,
|
||||
password,
|
||||
server.mailbox,
|
||||
{
|
||||
version: 'V2',
|
||||
}
|
||||
);
|
||||
|
||||
const promise = client.createRecoveryPhoneNumber('+15550005555');
|
||||
|
||||
// TODO: Setup test account / twilio emulator
|
||||
assert.isRejected(promise, 'A backend service request failed.');
|
||||
});
|
||||
});
|
|
@ -377,5 +377,36 @@ export function makeRedisConfig() {
|
|||
doc: 'Key prefix for Redis',
|
||||
},
|
||||
},
|
||||
recoveryPhone: {
|
||||
enabled: {
|
||||
default: true,
|
||||
doc: 'Enable Redis for recovery phone library',
|
||||
format: Boolean,
|
||||
env: 'RECOVERY_PHONE_REDIS_ENABLED',
|
||||
},
|
||||
host: {
|
||||
default: 'localhost',
|
||||
env: 'RECOVERY_PHONE_REDIS_HOST',
|
||||
format: String,
|
||||
},
|
||||
port: {
|
||||
default: 6379,
|
||||
env: 'RECOVERY_PHONE_REDIS_PORT',
|
||||
format: 'port',
|
||||
},
|
||||
password: {
|
||||
default: '',
|
||||
env: 'RECOVERY_PHONE_REDIS_PASSWORD',
|
||||
format: String,
|
||||
sensitive: true,
|
||||
doc: `Password for connecting to Redis`,
|
||||
},
|
||||
prefix: {
|
||||
default: 'recovery-phone:',
|
||||
env: 'RECOVERY_PHONE_REDIS_KEY_PREFIX',
|
||||
format: String,
|
||||
doc: 'Key prefix for Redis',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -950,3 +950,39 @@ two_factor_auth:
|
|||
expires: never
|
||||
data_sensitivity:
|
||||
- interaction
|
||||
|
||||
two_factor_auth_setup:
|
||||
sent_phone_code:
|
||||
type: event
|
||||
description: |
|
||||
User successfully submits a new phone number to be used as a recovery option
|
||||
lifetime: ping
|
||||
send_in_pings:
|
||||
- events
|
||||
notification_emails:
|
||||
- vzare@mozilla.com
|
||||
- fxa-staff@mozilla.com
|
||||
bugs:
|
||||
- https://mozilla-hub.atlassian.net/browse/FXA-10355
|
||||
data_reviews:
|
||||
- https://bugzilla.mozilla.org/show_bug.cgi?id=1830504
|
||||
expires: never
|
||||
data_sensitivity:
|
||||
- interaction
|
||||
send_phone_code_error:
|
||||
type: event
|
||||
description: |
|
||||
User encounters error while submitting a new phone number to be used as a recovery option
|
||||
lifetime: ping
|
||||
send_in_pings:
|
||||
- events
|
||||
notification_emails:
|
||||
- vzare@mozilla.com
|
||||
- fxa-staff@mozilla.com
|
||||
bugs:
|
||||
- https://mozilla-hub.atlassian.net/browse/FXA-10355
|
||||
data_reviews:
|
||||
- https://bugzilla.mozilla.org/show_bug.cgi?id=1830504
|
||||
expires: never
|
||||
data_sensitivity:
|
||||
- interaction
|
||||
|
|
Загрузка…
Ссылка в новой задаче