packages/fxa-auth-server/lib/routes/recovery-phone.ts (650 lines of code) (raw):
/* 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 { Request } from '@hapi/hapi';
import * as isA from 'joi';
import {
RecoveryPhoneService,
RecoveryPhoneNotEnabled,
RecoveryNumberNotSupportedError,
RecoveryNumberInvalidFormatError,
RecoveryNumberAlreadyExistsError,
RecoveryNumberNotExistsError,
SmsSendRateLimitExceededError,
RecoveryNumberRemoveMissingBackupCodes,
RecoveryPhoneRegistrationLimitReached,
TwilioMessageStatus,
} from '@fxa/accounts/recovery-phone';
import {
AccountManager,
VerificationMethods,
} from '@fxa/shared/account/account';
import { GleanMetricsType } from '../metrics/glean';
import { AuthRequest, SessionTokenAuthCredential } from '../types';
import { E164_NUMBER } from './validators';
import AppError from '../error';
import Localizer from '../l10n';
import NodeRendererBindings from '../senders/renderer/bindings-node';
import { AccountEventsManager } from '../account-events';
import { recordSecurityEvent } from './utils/security-event';
import { Container } from 'typedi';
import { ConfigType } from '../../config';
enum RecoveryPhoneStatus {
SUCCESS = 'success',
FAILURE = 'failure',
}
export type Customs = {
check: (req: AuthRequest, email: string, action: string) => Promise<void>;
checkAuthenticated: (
req: AuthRequest,
uid: string,
action: string
) => Promise<void>;
};
class RecoveryPhoneHandler {
private readonly recoveryPhoneService: RecoveryPhoneService;
private readonly accountManager: AccountManager;
private readonly accountEventsManager: AccountEventsManager;
private readonly localizer: Localizer;
constructor(
private readonly customs: Customs,
private readonly db: any,
private readonly glean: GleanMetricsType,
private readonly log: any,
private readonly mailer: any,
private readonly statsd: any
) {
this.recoveryPhoneService = Container.get(RecoveryPhoneService);
this.accountManager = Container.get(AccountManager);
this.accountEventsManager = Container.get(AccountEventsManager);
this.localizer = new Localizer(new NodeRendererBindings());
}
getLocalizedMessage = async (
request: AuthRequest,
code: string,
type: 'setup' | 'signin' | 'setup-short' | 'signin-short'
) => {
const l10nTypeMap = {
setup: {
id: 'recovery-phone-setup-sms-body',
fallbackMessage:
'${code} is your Mozilla verification code. Expires in 5 minutes.',
},
'setup-short': {
id: 'recovery-phone-setup-sms-short-body',
fallbackMessage: 'Mozilla verification code: ${code}',
},
signin: {
id: 'recovery-phone-signin-sms-body',
fallbackMessage:
'${code} is your Mozilla recovery code. Expires in 5 minutes.',
},
'signin-short': {
id: 'recovery-phone-signin-sms-short-body',
fallbackMessage: 'Mozilla code: ${code}',
},
};
const l10n = l10nTypeMap[type];
const localizedStrings = await this.localizer.localizeStrings(
request.app.locale,
[
{
id: l10n.id,
message: l10n.fallbackMessage,
vars: { code },
},
]
);
return localizedStrings[l10n.id];
};
async sendCode(request: AuthRequest) {
const { uid, email } = request.auth
.credentials as SessionTokenAuthCredential;
if (!email) {
throw AppError.invalidToken();
}
await this.customs.check(request, email, 'recoveryPhoneSendSigninCode');
const getFormattedMessage = async (code: string) => {
const localizedMessage = await this.getLocalizedMessage(
request,
code,
'signin'
);
const shortLocalizedMessage = await this.getLocalizedMessage(
request,
code,
'signin-short'
);
return {
msg: localizedMessage,
shortMsg: shortLocalizedMessage,
failsafeMsg: `Mozilla: ${code}`,
};
};
let success = false;
try {
success = await this.recoveryPhoneService.sendCode(
uid,
getFormattedMessage
);
} catch (error) {
if (error instanceof RecoveryNumberNotExistsError) {
throw AppError.recoveryPhoneNumberDoesNotExist();
}
if (error instanceof SmsSendRateLimitExceededError) {
throw AppError.smsSendRateLimitExceeded();
}
if (error instanceof RecoveryPhoneNotEnabled) {
throw AppError.featureNotEnabled();
}
throw AppError.backendServiceFailure(
'RecoveryPhoneService',
'sendCode',
{ uid },
error
);
}
if (success) {
this.statsd.increment('account.recoveryPhone.signinSendCode.success');
await this.glean.twoStepAuthPhoneCode.sent(request);
recordSecurityEvent('account.recovery_phone_send_code', {
db: this.db,
request,
});
return { status: RecoveryPhoneStatus.SUCCESS };
}
await this.glean.twoStepAuthPhoneCode.sendError(request);
return { status: RecoveryPhoneStatus.FAILURE };
}
async setupPhoneNumber(request: AuthRequest) {
const { uid, email } = request.auth
.credentials as SessionTokenAuthCredential;
const { phoneNumber } = request.payload as unknown as {
phoneNumber: string;
};
if (!email) {
throw AppError.invalidToken();
}
await this.customs.checkAuthenticated(
request,
uid,
'recoveryPhoneSendSetupCode'
);
const getFormattedMessages = async (code: string) => {
const msg = await this.getLocalizedMessage(request, code, 'setup');
const shortMsg = await this.getLocalizedMessage(
request,
code,
'setup-short'
);
const failsafeMsg = `Mozilla: ${code}`;
return {
msg,
shortMsg,
failsafeMsg,
};
};
let success = false;
try {
success = await this.recoveryPhoneService.setupPhoneNumber(
uid,
phoneNumber,
getFormattedMessages
);
if (success) {
this.statsd.increment('account.recoveryPhone.setupPhoneNumber.success');
await this.glean.twoStepAuthPhoneCode.sent(request);
let nationalFormat: string | null = null;
try {
nationalFormat =
await this.recoveryPhoneService.getNationalFormat(phoneNumber);
} catch (e) {
// This should not fail since the number was already validated with Twilio so
// if it does it's a network problem - just return a null value and don't error out.
}
return { status: RecoveryPhoneStatus.SUCCESS, nationalFormat };
}
await this.glean.twoStepAuthPhoneCode.sendError(request);
return { status: RecoveryPhoneStatus.FAILURE };
} catch (error) {
if (error instanceof RecoveryPhoneNotEnabled) {
throw AppError.featureNotEnabled();
}
await this.glean.twoStepAuthPhoneCode.sendError(request);
if (
error instanceof RecoveryNumberInvalidFormatError ||
error instanceof RecoveryNumberNotSupportedError ||
error instanceof RecoveryNumberAlreadyExistsError
) {
throw AppError.invalidPhoneNumber();
}
if (error instanceof SmsSendRateLimitExceededError) {
throw AppError.smsSendRateLimitExceeded();
}
if (error instanceof RecoveryPhoneRegistrationLimitReached) {
throw AppError.recoveryPhoneRegistrationLimitReached();
}
throw AppError.backendServiceFailure(
'RecoveryPhoneService',
'setupPhoneNumber',
{ uid },
error
);
}
}
async confirmCode(request: AuthRequest, isSetup: boolean) {
const {
id: sessionTokenId,
uid,
email,
} = request.auth.credentials as SessionTokenAuthCredential;
const { code } = request.payload as unknown as {
code: string;
};
if (!email) {
throw AppError.invalidToken();
}
await this.customs.checkAuthenticated(
request,
uid,
'verifyRecoveryPhoneTotpCode'
);
let success = false;
try {
if (isSetup) {
// This is the initial setup case, where a user is validating an sms
// code on their phone for the first time. It does NOT impact the totp
// token's database state.
success = await this.recoveryPhoneService.confirmSetupCode(uid, code);
} else {
// This is a sign in attempt. This will check the code, and if valid, mark the
// session token verified. This session will have a security level that allows
// the user to remove totp devices.
success = await this.recoveryPhoneService.confirmSigninCode(uid, code);
// Mark session as verified
if (success) {
await this.accountManager.verifySession(
uid,
sessionTokenId,
VerificationMethods.sms2fa
);
}
}
} catch (error) {
if (error instanceof RecoveryPhoneNotEnabled) {
throw AppError.featureNotEnabled();
}
if (error instanceof RecoveryNumberAlreadyExistsError) {
throw AppError.recoveryPhoneNumberAlreadyExists();
}
if (error instanceof RecoveryPhoneRegistrationLimitReached) {
throw AppError.recoveryPhoneRegistrationLimitReached();
}
throw AppError.backendServiceFailure(
'RecoveryPhoneService',
'confirmCode',
{ uid },
error
);
}
if (success) {
await this.glean.twoStepAuthPhoneCode.complete(request);
const account = await this.db.account(uid);
const { acceptLanguage, geo, ua } = request.app;
if (isSetup) {
this.statsd.increment('account.recoveryPhone.phoneAdded.success');
try {
const { phoneNumber, nationalFormat } =
// User has successfully set up a recovery phone. Give back the
// full nationalFormat (don't strip it).
await this.recoveryPhoneService.hasConfirmed(uid);
await this.mailer.sendPostAddRecoveryPhoneEmail(
account.emails,
account,
{
acceptLanguage,
maskedLastFourPhoneNumber: `••••••${this.recoveryPhoneService.stripPhoneNumber(
phoneNumber || '',
4
)}`,
timeZone: geo.timeZone,
uaBrowser: ua.browser,
uaBrowserVersion: ua.browserVersion,
uaOS: ua.os,
uaOSVersion: ua.osVersion,
uaDeviceType: ua.deviceType,
uid,
}
);
recordSecurityEvent('account.recovery_phone_setup_complete', {
db: this.db,
request,
});
return {
phoneNumber,
nationalFormat,
status: RecoveryPhoneStatus.SUCCESS,
};
} catch (error) {
// log email send error but don't throw
// user should be allowed to proceed
this.log.trace('account.recoveryPhone.phoneAddedNotification.error', {
error,
});
}
} else {
this.statsd.increment('account.recoveryPhone.phoneSignin.success');
// this signals the end of the login flow
await request.emitMetricsEvent('account.confirmed', { uid });
recordSecurityEvent('account.recovery_phone_signin_complete', {
db: this.db,
request,
});
try {
await this.mailer.sendPostSigninRecoveryPhoneEmail(
account.emails,
account,
{
acceptLanguage,
timeZone: geo.timeZone,
uaBrowser: ua.browser,
uaBrowserVersion: ua.browserVersion,
uaOS: ua.os,
uaOSVersion: ua.osVersion,
uaDeviceType: ua.deviceType,
uid,
}
);
} catch (error) {
// log email send error but don't throw
// user should be allowed to proceed
this.log.trace(
'account.recoveryPhone.phoneSigninNotification.error',
{
error,
}
);
}
}
return { status: RecoveryPhoneStatus.SUCCESS };
}
recordSecurityEvent('account.recovery_phone_signin_failed', {
db: this.db,
request,
});
throw AppError.invalidOrExpiredOtpCode();
}
async destroy(request: AuthRequest) {
const sessionToken = request.auth.credentials as SessionTokenAuthCredential;
const { uid } = sessionToken;
let success = false;
try {
success = await this.recoveryPhoneService.removePhoneNumber(uid);
} catch (error) {
if (error instanceof RecoveryNumberNotExistsError) {
throw AppError.recoveryPhoneNumberDoesNotExist();
}
if (error instanceof RecoveryNumberRemoveMissingBackupCodes) {
throw AppError.recoveryPhoneRemoveMissingRecoveryCodes();
}
throw AppError.backendServiceFailure(
'RecoveryPhoneService',
'destroy',
{ uid },
error
);
}
if (success) {
this.statsd.increment('account.recoveryPhone.phoneRemoved.success');
await this.glean.twoStepAuthPhoneRemove.success(request);
const account = await this.db.account(uid);
const { acceptLanguage, geo, ua } = request.app;
try {
recordSecurityEvent('account.recovery_phone_removed', {
db: this.db,
request,
account,
});
await this.mailer.sendPostRemoveRecoveryPhoneEmail(
account.emails,
account,
{
acceptLanguage,
timeZone: geo.timeZone,
uaBrowser: ua.browser,
uaBrowserVersion: ua.browserVersion,
uaOS: ua.os,
uaOSVersion: ua.osVersion,
uaDeviceType: ua.deviceType,
uid,
}
);
} catch (error) {
// log email send error but don't throw
// user should be allowed to proceed
this.log.trace('account.recoveryPhone.phoneRemovedNotification.error', {
error,
});
}
}
return {};
}
/**
* Check if a user can setup phone number, ie in correct region and does
* not already have a confirmed phone number.
*
* @param request
*/
async available(request: AuthRequest) {
const { uid, email } = request.auth
.credentials as unknown as SessionTokenAuthCredential;
if (!email || !uid) {
throw AppError.invalidToken();
}
// Maxmind countryCode is two-letter ISO country code (ex `US` for the United States)
// This is the same format as the `region` field in the recovery phone config
const location = request.app.geo?.location;
if (!location || !location.countryCode) {
return {
available: false,
};
}
try {
const available = await this.recoveryPhoneService.available(
uid,
location.countryCode
);
return {
available,
};
} catch (error) {
throw AppError.backendServiceFailure(
'RecoveryPhoneService',
'available',
{ uid },
error
);
}
}
async exists(request: AuthRequest) {
const { uid, emailVerified, mustVerify, tokenVerified } = request.auth
.credentials as SessionTokenAuthCredential;
// To ensure no data is leaked, we will never expose the full phone number, if
// the session is not verified. e.g. The user has entered the correct password,
// but failed to provide 2FA.
const phoneNumberStrip =
!emailVerified || (mustVerify && !tokenVerified) ? 4 : undefined;
try {
return await this.recoveryPhoneService.hasConfirmed(
uid,
phoneNumberStrip
);
} catch (error) {
throw AppError.backendServiceFailure(
'RecoveryPhoneService',
'destroy',
{ uid },
error
);
}
}
/**
* Validates if the request is a legitimate request from Twilio. Throws an unauthorized
* error if validation fails.
*
* Important notes!
*
* 1. We are doing this inline, because it could require the request payload and
* this is not available during the authentication lifecycle without jumping
* through some weird hoops.
*
* 2. We have two ways of validating requests. The first way is by using a signature
* we generate. This will be used when twilio is configured with api keys and the
* authToken isn't used, which is considered best practice. The downside to this
* approach is that while we can validate the incoming call was signed by us, we
* can't validate the message body. There is a very unlikely chance that a man in
* the middle attack on TLS could result in a bogus payload state. We aren't doing
* anything critical with message status updates, so this is probably good enough.
*
* 3. The second way of authenticating is the default twilio approach. Unfortunately
* this requires the authToken to be known and we don't to set this in the env.
* If at some point, validating the request payload becomes super important, we
* might consider this approach, despite the authToken requirement.
*
* @param request A typical hapi request.
*/
validateWebhookCall(request: Request) {
const fxaSignature = request.query?.fxaSignature;
const fxaMessage = request.query?.fxaMessage;
const twilioSignature = request.headers['X-Twilio-Signature'];
const twilioPayload = request.payload;
this.log?.debug('validateWebhookCall', {
fxaSignature,
fxaMessage,
twilioSignature,
twilioPayload,
});
let valid = false;
if (fxaSignature && fxaMessage) {
valid = this.recoveryPhoneService.validateTwilioWebhookCallback({
fxa: {
signature: fxaSignature,
message: fxaMessage,
},
});
} else if (twilioSignature && typeof twilioPayload === 'object') {
valid = this.recoveryPhoneService.validateTwilioWebhookCallback({
twilio: {
signature: twilioSignature,
params: twilioPayload,
},
});
}
this.log?.debug('validateWebhookCall', {
valid,
});
if (valid) {
this.statsd.increment('account.recoveryPhone.validateWebhookCall.valid');
} else {
this.statsd.increment(
'account.recoveryPhone.validateWebhookCall.invalid'
);
throw AppError.unauthorized(`Signature Invalid`);
}
}
/**
* Takes a request, and processes the message status provided by twilio.
* @param request An incoming web request from Twilio with a message status update.
* @returns true assuming no errors processing the message.
*/
async messageStatus(request: Request) {
this.validateWebhookCall(request);
// We can now continue.
await this.recoveryPhoneService.onMessageStatusUpdate(
request.payload as TwilioMessageStatus
);
return true;
}
}
export const recoveryPhoneRoutes = (
customs: Customs,
db: any,
glean: GleanMetricsType,
log: any,
mailer: any,
statsd: any,
config: ConfigType
) => {
const featureEnabledCheck = () => {
if (!config.recoveryPhone.enabled) {
throw AppError.featureNotEnabled();
}
return true;
};
const recoveryPhoneHandler = new RecoveryPhoneHandler(
customs,
db,
glean,
log,
mailer,
statsd
);
const routes = [
{
method: 'POST',
path: '/recovery_phone/create',
options: {
pre: [{ method: featureEnabledCheck }],
auth: {
strategy: 'sessionToken',
},
validate: {
payload: isA.object({
phoneNumber: isA.string().regex(E164_NUMBER).required(),
}),
},
},
handler: function (request: AuthRequest) {
log.begin('recoveryPhoneStartSetup', request);
return recoveryPhoneHandler.setupPhoneNumber(request);
},
},
{
method: 'POST',
path: '/recovery_phone/available',
options: {
auth: {
strategy: 'sessionToken',
},
},
handler: function (request: AuthRequest) {
log.begin('recoveryPhoneAvailable', request);
return recoveryPhoneHandler.available(request);
},
},
{
method: 'POST',
path: '/recovery_phone/confirm',
options: {
pre: [{ method: featureEnabledCheck }],
auth: {
strategy: 'sessionToken',
},
validate: {
payload: isA.object({
code: isA.string().min(6).max(8),
}),
},
},
handler: function (request: AuthRequest) {
log.begin('recoveryPhoneConfirmSetup', request);
return recoveryPhoneHandler.confirmCode(request, true);
},
},
{
method: 'POST',
path: '/recovery_phone/signin/send_code',
options: {
pre: [{ method: featureEnabledCheck }],
auth: {
strategy: 'sessionToken',
},
},
handler: function (request: AuthRequest) {
log.begin('recoveryPhoneSigninSendCode', request);
return recoveryPhoneHandler.sendCode(request);
},
},
{
method: 'POST',
path: '/recovery_phone/signin/confirm',
options: {
pre: [{ method: featureEnabledCheck }],
auth: {
strategy: 'sessionToken',
},
},
handler: function (request: AuthRequest) {
log.begin('recoveryPhoneSigninConfirmCode', request);
return recoveryPhoneHandler.confirmCode(request, false);
},
},
{
method: 'DELETE',
path: '/recovery_phone',
options: {
auth: {
strategy: 'sessionToken',
},
},
handler: function (request: AuthRequest) {
log.begin('recoveryPhoneRemove', request);
return recoveryPhoneHandler.destroy(request);
},
},
{
method: 'GET',
path: '/recovery_phone',
options: {
auth: {
strategy: 'sessionToken',
},
},
handler: function (request: AuthRequest) {
log.begin('recoveryPhoneInfo', request);
return recoveryPhoneHandler.exists(request);
},
},
{
method: 'POST',
path: '/recovery_phone/message_status',
options: {
payload: {
parse: true,
allow: 'application/x-www-form-urlencoded',
},
},
handler: function (request: Request) {
return recoveryPhoneHandler.messageStatus(request);
},
},
];
return routes;
};