mirror of
https://github.com/mozilla/fxa.git
synced 2025-12-13 20:36:41 +01:00
fix(totp): Use otplib/preset-browser for consistent 2FA setup, handle server OTP reject
Because: * There's an error case some users experience where it appears a client-side OTP code check is valid but our server then rejects it. We mishandle the error state and tell the user 2FA setup was successful This commit: * Updates our front-end OTP check in fxa-settings to use the same library our backend uses (otplib, but for the browser) * Has auth-server throw an error if the TOTP code is invalid during set up, and handles it properly in the front-end by checking for an error, not updating apollo cache to show a successful TOTP setup if there is an error, and displays an error for the user closes FXA-12035
This commit is contained in:
@@ -319,6 +319,9 @@ export function gleanMetrics(config: ConfigType) {
|
||||
replaceCodeComplete: createEventFn(
|
||||
'two_factor_auth_replace_code_complete'
|
||||
),
|
||||
setupInvalidCodeError: createEventFn(
|
||||
'two_factor_auth_setup_invalid_code_error'
|
||||
),
|
||||
},
|
||||
twoStepAuthPhoneCode: {
|
||||
sent: createEventFn('two_step_auth_phone_code_sent'),
|
||||
|
||||
@@ -4904,6 +4904,88 @@ class EventsServerEventLogger {
|
||||
event,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Record and submit a two_factor_auth_setup_invalid_code_error event:
|
||||
* Edge case error that indicates the user verified their OTP code client-side during setup, but the server rejected it
|
||||
* 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..
|
||||
*/
|
||||
recordTwoFactorAuthSetupInvalidCodeError({
|
||||
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',
|
||||
name: 'setup_invalid_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_step_auth_phone_code_complete event:
|
||||
* User successfully entered and submitted an authentication code
|
||||
|
||||
@@ -261,9 +261,7 @@ module.exports = (
|
||||
log.error('totp.destroy.remove_phone_number.error');
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof RecoveryNumberNotExistsError
|
||||
) {
|
||||
if (error instanceof RecoveryNumberNotExistsError) {
|
||||
statsd.increment('totp.destroy.remove_phone_number.fail');
|
||||
} else {
|
||||
statsd.increment('totp.destroy.remove_phone_number.error');
|
||||
@@ -567,29 +565,37 @@ module.exports = (
|
||||
|
||||
const isSetup = !tokenVerified;
|
||||
|
||||
// Once a valid TOTP code has been detected, the token becomes verified
|
||||
// and enabled for the user.
|
||||
if (isValidCode && isSetup) {
|
||||
await db.replaceTotpToken({
|
||||
uid,
|
||||
sharedSecret,
|
||||
verified: true,
|
||||
enabled: true,
|
||||
epoch: 0,
|
||||
});
|
||||
await authServerCacheRedis.del(toRedisTotpSecretKey(uid));
|
||||
if (isSetup) {
|
||||
// We currently check for code validity client-side, and then check again
|
||||
// server-side at the end of the flow with this request. This guards against
|
||||
// an edgecase where the client may accept a code that the server rejects.
|
||||
if (!isValidCode) {
|
||||
glean.twoFactorAuth.setupInvalidCodeError(request, { uid });
|
||||
throw errors.invalidTokenVerficationCode();
|
||||
} else {
|
||||
// Once a valid TOTP code has been detected, the token becomes verified
|
||||
// and enabled for the user.
|
||||
await db.replaceTotpToken({
|
||||
uid,
|
||||
sharedSecret,
|
||||
verified: true,
|
||||
enabled: true,
|
||||
epoch: 0,
|
||||
});
|
||||
await authServerCacheRedis.del(toRedisTotpSecretKey(uid));
|
||||
|
||||
recordSecurityEvent('account.two_factor_added', {
|
||||
db,
|
||||
request,
|
||||
});
|
||||
recordSecurityEvent('account.two_factor_added', {
|
||||
db,
|
||||
request,
|
||||
});
|
||||
|
||||
glean.twoFactorAuth.codeComplete(request, { uid });
|
||||
glean.twoFactorAuth.codeComplete(request, { uid });
|
||||
|
||||
await profileClient.deleteCache(uid);
|
||||
await log.notifyAttachedServices('profileDataChange', request, {
|
||||
uid,
|
||||
});
|
||||
await profileClient.deleteCache(uid);
|
||||
await log.notifyAttachedServices('profileDataChange', request, {
|
||||
uid,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If a valid code was sent, this verifies the session using the `totp-2fa` method.
|
||||
|
||||
@@ -46,6 +46,7 @@ const recordPasswordResetEmailConfirmationSuccessStub = sinon.stub();
|
||||
const recordTwoFactorAuthCodeCompleteStub = sinon.stub();
|
||||
const recordTwoFactorAuthReplaceCodeCompleteStub = sinon.stub();
|
||||
const recordTwoFactorAuthSetCodesCompleteStub = sinon.stub();
|
||||
const recordTwoFactorAuthSetupInvalidCodeErrorStub = sinon.stub();
|
||||
const recordTwoStepAuthPhoneCodeSentStub = sinon.stub();
|
||||
const recordTwoStepAuthPhoneCodeSendErrorStub = sinon.stub();
|
||||
const recordTwoStepAuthPhoneCodeCompleteStub = sinon.stub();
|
||||
@@ -126,6 +127,8 @@ const gleanProxy = proxyquire('../../../lib/metrics/glean', {
|
||||
recordTwoFactorAuthReplaceCodeCompleteStub,
|
||||
recordTwoFactorAuthSetCodesComplete:
|
||||
recordTwoFactorAuthSetCodesCompleteStub,
|
||||
recordTwoFactorAuthSetupInvalidCodeError:
|
||||
recordTwoFactorAuthSetupInvalidCodeErrorStub,
|
||||
recordTwoStepAuthPhoneCodeSent: recordTwoStepAuthPhoneCodeSentStub,
|
||||
recordTwoStepAuthPhoneCodeSendError:
|
||||
recordTwoStepAuthPhoneCodeSendErrorStub,
|
||||
@@ -537,6 +540,17 @@ describe('Glean server side events', () => {
|
||||
);
|
||||
sinon.assert.calledOnce(recordTwoFactorAuthReplaceCodeCompleteStub);
|
||||
});
|
||||
|
||||
it('logs a "two_factor_auth_setup_invalid_code_error" event', async () => {
|
||||
await glean.twoFactorAuth.setupInvalidCodeError(request);
|
||||
sinon.assert.calledOnce(recordStub);
|
||||
const metrics = recordStub.args[0][0];
|
||||
assert.equal(
|
||||
metrics['event_name'],
|
||||
'two_factor_auth_setup_invalid_code_error'
|
||||
);
|
||||
sinon.assert.calledOnce(recordTwoFactorAuthSetupInvalidCodeErrorStub);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registration', () => {
|
||||
|
||||
@@ -387,6 +387,38 @@ describe('totp', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw errors.invalidTokenVerficationCode for invalid code during setup', async () => {
|
||||
// Simulate setup (isSetup = true) by making totpTokenVerified false
|
||||
requestOptions.credentials.tokenVerified = false;
|
||||
requestOptions.payload = {
|
||||
code: 'INVALID_CODE',
|
||||
};
|
||||
try {
|
||||
await setup(
|
||||
{
|
||||
db: { email: TEST_EMAIL },
|
||||
redis: { secret },
|
||||
totpTokenVerified: false,
|
||||
totpTokenEnabled: false,
|
||||
},
|
||||
{},
|
||||
'/session/verify/totp',
|
||||
requestOptions
|
||||
);
|
||||
assert.fail('Invalid token verification code error was not thrown');
|
||||
} catch (err) {
|
||||
assert.equal(
|
||||
err.errno,
|
||||
authErrors.ERRNO.INVALID_TOKEN_VERIFICATION_CODE
|
||||
);
|
||||
assert.equal(
|
||||
err.message,
|
||||
authErrors.invalidTokenVerficationCode().message
|
||||
);
|
||||
assert.calledOnce(glean.twoFactorAuth.setupInvalidCodeError);
|
||||
}
|
||||
});
|
||||
|
||||
it('should verify session with TOTP token - sync', () => {
|
||||
const authenticator = new otplib.authenticator.Authenticator();
|
||||
authenticator.options = Object.assign({}, otplib.authenticator.options, {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
const { resolve } = require('path');
|
||||
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
|
||||
const webpack = require('webpack');
|
||||
|
||||
const allFxa = resolve(__dirname, '../../');
|
||||
const allLibs = resolve(__dirname, '../../libs/');
|
||||
@@ -19,6 +20,16 @@ const additionalJSImports = {
|
||||
|
||||
const customizeWebpackConfig = ({ config }) => ({
|
||||
...config,
|
||||
plugins: [
|
||||
...config.plugins,
|
||||
new webpack.ProvidePlugin({
|
||||
process: 'process/browser',
|
||||
}),
|
||||
new webpack.ProvidePlugin({
|
||||
buffer: ['buffer', 'Buffer'],
|
||||
Buffer: ['buffer', 'Buffer'],
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
...config.resolve,
|
||||
plugins: [
|
||||
@@ -42,6 +53,8 @@ const customizeWebpackConfig = ({ config }) => ({
|
||||
...config.fallback,
|
||||
fs: false,
|
||||
path: false,
|
||||
crypto: require.resolve('crypto-browserify'),
|
||||
stream: require.resolve('stream-browserify'),
|
||||
},
|
||||
},
|
||||
module: {
|
||||
|
||||
@@ -623,6 +623,10 @@ module.exports = function (webpackEnv) {
|
||||
new webpack.ProvidePlugin({
|
||||
process: 'process/browser',
|
||||
}),
|
||||
new webpack.ProvidePlugin({
|
||||
buffer: ['buffer', 'Buffer'], // otplib/preset-browser expects a global 'buffer'
|
||||
Buffer: ['buffer', 'Buffer'],
|
||||
}),
|
||||
// Generates an `index.html` file with the <script> injected.
|
||||
new HtmlWebpackPlugin(
|
||||
Object.assign(
|
||||
|
||||
@@ -126,6 +126,7 @@
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@emotion/styled": "^11.13.0",
|
||||
"@material-ui/core": "v5.0.0-alpha.24",
|
||||
"@otplib/preset-browser": "^12.0.1",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.3",
|
||||
"@reach/router": "^1.3.4",
|
||||
"@react-pdf/renderer": "3.2.1",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { RouteComponentProps } from '@reach/router';
|
||||
import { Link, RouteComponentProps } from '@reach/router';
|
||||
|
||||
import LoadingSpinner from 'fxa-react/components/LoadingSpinner';
|
||||
|
||||
@@ -28,6 +28,8 @@ import FlowSetup2faBackupCodeConfirm from '../FlowSetup2faBackupCodeConfirm';
|
||||
import FlowSetupRecoveryPhoneSubmitNumber from '../FlowSetupRecoveryPhoneSubmitNumber';
|
||||
import FlowSetupRecoveryPhoneConfirmCode from '../FlowSetupRecoveryPhoneConfirmCode';
|
||||
import VerifiedSessionGuard from '../VerifiedSessionGuard';
|
||||
import { AuthUiErrors } from '../../../lib/auth-errors/auth-errors';
|
||||
import { FtlMsg } from 'fxa-react/lib/utils';
|
||||
|
||||
const Page2faSetup = (_: RouteComponentProps) => {
|
||||
const account = useAccount();
|
||||
@@ -186,14 +188,43 @@ const Page2faSetup = (_: RouteComponentProps) => {
|
||||
await account.setRecoveryCodes(backupCodes);
|
||||
await enable2fa();
|
||||
} catch (error) {
|
||||
// If this throws, the initial client-side code check succeeded but the
|
||||
// back-end rejected it. The user needs to begin the flow again.
|
||||
if (error.errno === AuthUiErrors.INVALID_OTP_CODE.errno) {
|
||||
const startOverLink = (
|
||||
<Link
|
||||
className="link-blue"
|
||||
to="/settings/two_step_authentication"
|
||||
onClick={() => {
|
||||
navigateWithQuery('/settings/two_step_authentication');
|
||||
alertBar.hide();
|
||||
}}
|
||||
>
|
||||
start over
|
||||
</Link>
|
||||
);
|
||||
|
||||
alertBar.error(
|
||||
<FtlMsg
|
||||
id="two-factor-auth-setup-token-verification-error"
|
||||
elems={{ a: startOverLink }}
|
||||
>
|
||||
<>
|
||||
There was a problem enabling two-step authentication. Check that
|
||||
your device’s clock is set to update automatically and{' '}
|
||||
{startOverLink}.
|
||||
</>
|
||||
</FtlMsg>
|
||||
);
|
||||
} else {
|
||||
showGenericError();
|
||||
}
|
||||
// for any error other than an incorrect backup code
|
||||
// return to main settings page with generic error message
|
||||
// there is no action the user can take at this step
|
||||
showGenericError();
|
||||
goHome();
|
||||
return;
|
||||
}
|
||||
|
||||
showSuccess();
|
||||
goHome();
|
||||
return;
|
||||
|
||||
@@ -3,3 +3,7 @@
|
||||
# Variables:
|
||||
# $lastFourPhoneNumber (Number) - The last 4 digits of the user's recovery phone number
|
||||
recovery-phone-number-ending-digits = Number ending in { $lastFourPhoneNumber }
|
||||
# This error is shown when there is a particular kind of error at the very end of the 2FA flow
|
||||
# and the user should begin it again. A system/device clock not being synced to the internet time is
|
||||
# a common problem when using 2FA.
|
||||
two-factor-auth-setup-token-verification-error = There was a problem enabling two-step authentication. Check that your device’s clock is set to update automatically and <a>start over</a>.
|
||||
|
||||
@@ -2,58 +2,65 @@
|
||||
* 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 base32Decode from 'base32-decode';
|
||||
|
||||
import random from './random';
|
||||
|
||||
function trimOrPad(num: number, digits: number): string {
|
||||
const str = num.toString().substr(-digits);
|
||||
if (str.length === digits) {
|
||||
return str;
|
||||
let authenticatorInstance: any = null;
|
||||
|
||||
// Polyfill for Playwright tests because they run in a Node context where some
|
||||
// browser globals are undefined, which `@otplib/preset-browser` uses.
|
||||
function setupTestPolyfills() {
|
||||
if (typeof window === 'undefined') {
|
||||
(global as any).window = {
|
||||
crypto: (global as any).crypto || {
|
||||
getRandomValues: (arr: any) => {
|
||||
const crypto = require('crypto');
|
||||
const bytes = crypto.randomBytes(arr.length);
|
||||
arr.set(bytes);
|
||||
return arr;
|
||||
},
|
||||
},
|
||||
};
|
||||
if (typeof (global as any).buffer === 'undefined') {
|
||||
(global as any).buffer = require('buffer');
|
||||
}
|
||||
if (typeof (global as any).Buffer === 'undefined') {
|
||||
(global as any).Buffer = require('buffer').Buffer;
|
||||
}
|
||||
}
|
||||
return new Array(digits - str.length + 1).join('0') + str;
|
||||
}
|
||||
|
||||
export async function getCode(
|
||||
secret: string,
|
||||
digits: number = 6,
|
||||
timestamp: number = Date.now()
|
||||
): Promise<string> {
|
||||
const secretKey = base32Decode(secret, 'RFC4648');
|
||||
const counter = new ArrayBuffer(8);
|
||||
const cv = new DataView(counter);
|
||||
cv.setUint32(4, Math.floor(timestamp / 30000), false);
|
||||
// Lazy import and configure authenticator only when needed. This is required
|
||||
// because when importing this lib top-level, Playwright will error.
|
||||
async function getAuthenticator() {
|
||||
if (authenticatorInstance) {
|
||||
return authenticatorInstance;
|
||||
}
|
||||
setupTestPolyfills();
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
secretKey,
|
||||
{
|
||||
name: 'HMAC',
|
||||
hash: { name: 'SHA-1' },
|
||||
},
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
const signature = await crypto.subtle.sign('HMAC', key, counter);
|
||||
const hmac = new DataView(signature);
|
||||
const offset = hmac.getUint8(hmac.byteLength - 1) & 0x0f;
|
||||
return trimOrPad(hmac.getInt32(offset, false) & 0x7fffffff, digits);
|
||||
const { authenticator } = await import('@otplib/preset-browser');
|
||||
|
||||
// Configure otplib to match auth-server settings
|
||||
authenticator.options = {
|
||||
...authenticator.options,
|
||||
step: 30,
|
||||
window: 1,
|
||||
};
|
||||
|
||||
authenticatorInstance = authenticator;
|
||||
return authenticator;
|
||||
}
|
||||
|
||||
export async function getCode(secret: string): Promise<string> {
|
||||
const authenticator = await getAuthenticator();
|
||||
return authenticator.generate(secret);
|
||||
}
|
||||
|
||||
export async function checkCode(
|
||||
secret: string,
|
||||
code: string,
|
||||
timestamp: number = Date.now(),
|
||||
tries = 2
|
||||
code: string
|
||||
): Promise<boolean> {
|
||||
for (; tries > 0; tries--, timestamp -= 30000) {
|
||||
const x = await getCode(secret, 6, timestamp);
|
||||
if (x === code) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
const authenticator = await getAuthenticator();
|
||||
return authenticator.verify({ token: code, secret });
|
||||
}
|
||||
|
||||
export function copyRecoveryCodes(event: React.ClipboardEvent<HTMLElement>) {
|
||||
|
||||
@@ -1244,22 +1244,28 @@ export class Account implements AccountData {
|
||||
}
|
||||
|
||||
async verifyTotp(code: string) {
|
||||
await this.withLoadingStatus(
|
||||
this.authClient.verifyTotpCode(sessionToken()!, code)
|
||||
);
|
||||
// We must requery for this because this endpoint checks
|
||||
// for if recovery codes exist
|
||||
await this.refresh('recoveryPhone');
|
||||
const cache = this.apolloClient.cache;
|
||||
cache.modify({
|
||||
id: cache.identify({ __typename: 'Account' }),
|
||||
fields: {
|
||||
totp(currentTotp) {
|
||||
return { ...currentTotp, verified: true };
|
||||
try {
|
||||
await this.withLoadingStatus(
|
||||
this.authClient.verifyTotpCode(sessionToken()!, code)
|
||||
);
|
||||
// We must requery for this because this endpoint checks
|
||||
// for if recovery codes exist
|
||||
await this.refresh('recoveryPhone');
|
||||
const cache = this.apolloClient.cache;
|
||||
cache.modify({
|
||||
id: cache.identify({ __typename: 'Account' }),
|
||||
fields: {
|
||||
totp(currentTotp) {
|
||||
return { ...currentTotp, verified: true };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await this.refresh('backupCodes');
|
||||
});
|
||||
await this.refresh('backupCodes');
|
||||
} catch (e) {
|
||||
// Re-throw and allow the presentation layer to display the error.
|
||||
// We don't want to set `totp.verified` to `true` if there's an error.
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async uploadAvatar(file: Blob) {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { makeVar } from '@apollo/client';
|
||||
import { ReactNode } from 'react';
|
||||
import { consumeAlertTextExternal } from '../lib/cache';
|
||||
|
||||
export type NotificationType = 'success' | 'info' | 'error' | 'warning';
|
||||
|
||||
export const alertContent = makeVar(consumeAlertTextExternal() || '');
|
||||
export const alertContent = makeVar<string | ReactNode>(consumeAlertTextExternal() || '');
|
||||
export const alertType = makeVar<NotificationType>('success');
|
||||
export const alertVisible = makeVar(!!alertContent());
|
||||
|
||||
@@ -24,24 +25,24 @@ export class AlertBarInfo {
|
||||
alertType(type);
|
||||
}
|
||||
|
||||
setContent(text: string) {
|
||||
setContent(text: string | ReactNode) {
|
||||
alertContent(text);
|
||||
}
|
||||
|
||||
success(message: string, gleanEvent?: () => void) {
|
||||
success(message: string | ReactNode, gleanEvent?: () => void) {
|
||||
this.setType('success');
|
||||
this.setContent(message);
|
||||
gleanEvent && gleanEvent();
|
||||
this.show();
|
||||
}
|
||||
|
||||
error(message: string, error?: Error) {
|
||||
error(message: string | ReactNode, error?: Error) {
|
||||
this.setType('error');
|
||||
this.setContent(message);
|
||||
this.show();
|
||||
}
|
||||
|
||||
info(message: string) {
|
||||
info(message: string | ReactNode) {
|
||||
this.setType('success');
|
||||
this.setContent(message);
|
||||
this.show();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 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 { act, screen, waitFor } from '@testing-library/react';
|
||||
import { act, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent, { UserEvent } from '@testing-library/user-event';
|
||||
import InlineRecoverySetup from '.';
|
||||
import { MozServices } from '../../lib/types';
|
||||
@@ -10,6 +10,7 @@ import { renderWithRouter } from '../../models/mocks';
|
||||
import { MOCK_BACKUP_CODES, MOCK_EMAIL } from '../mocks';
|
||||
import GleanMetrics from '../../lib/glean';
|
||||
import { OAUTH_ERRORS, OAuthError } from '../../lib/oauth';
|
||||
import { AuthUiErrors } from '../../lib/auth-errors/auth-errors';
|
||||
|
||||
jest.mock('../../lib/metrics', () => ({
|
||||
logViewEvent: jest.fn(),
|
||||
@@ -225,4 +226,39 @@ describe('InlineRecoverySetup', () => {
|
||||
'There was a problem confirming your backup authentication code'
|
||||
);
|
||||
});
|
||||
|
||||
it('shows a banner error when AuthUiErrors.INVALID_OTP_CODE.errno is thrown', async () => {
|
||||
const verifyTotpHandler = jest.fn().mockImplementation(() => {
|
||||
const error: any = new Error();
|
||||
error.errno = AuthUiErrors.INVALID_OTP_CODE.errno;
|
||||
throw error;
|
||||
});
|
||||
renderWithRouter(
|
||||
<InlineRecoverySetup
|
||||
email={MOCK_EMAIL}
|
||||
serviceName={MozServices.MozillaVPN}
|
||||
{...props}
|
||||
{...{ verifyTotpHandler }}
|
||||
/>
|
||||
);
|
||||
await waitFor(async () =>
|
||||
user.click(screen.getByRole('button', { name: 'Continue' }))
|
||||
);
|
||||
await waitFor(async () =>
|
||||
user.type(
|
||||
screen.getByLabelText('Backup authentication code'),
|
||||
MOCK_BACKUP_CODES[0]
|
||||
)
|
||||
);
|
||||
await waitFor(async () =>
|
||||
user.click(screen.getByRole('button', { name: 'Confirm' }))
|
||||
);
|
||||
const errorEl = await screen.findByText(
|
||||
/There was a problem enabling two-step authentication\./i
|
||||
);
|
||||
const startOverLink = within(errorEl).getByRole('link', {
|
||||
name: /start over/i,
|
||||
});
|
||||
expect(startOverLink).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { RouteComponentProps } from '@reach/router';
|
||||
import { Link, RouteComponentProps, useLocation } from '@reach/router';
|
||||
import { FtlMsg } from 'fxa-react/lib/utils';
|
||||
import { useFtlMsgResolver } from '../../models';
|
||||
import { useNavigateWithQuery } from '../../lib/hooks/useNavigateWithQuery';
|
||||
import DataBlock from '../../components/DataBlock';
|
||||
import { BackupCodesImage } from '../../components/images';
|
||||
import CardHeader from '../../components/CardHeader';
|
||||
@@ -15,8 +16,15 @@ import FormVerifyCode, {
|
||||
commonBackupCodeFormAttributes,
|
||||
} from '../../components/FormVerifyCode';
|
||||
import { AuthUiErrors } from '../../lib/auth-errors/auth-errors';
|
||||
import { InlineRecoverySetupProps } from './interfaces';
|
||||
import { getErrorFtlId, getLocalizedErrorMessage } from '../../lib/error-utils';
|
||||
import {
|
||||
InlineRecoverySetupProps,
|
||||
SigninRecoveryLocationState,
|
||||
} from './interfaces';
|
||||
import {
|
||||
getErrorFtlId,
|
||||
getHandledError,
|
||||
getLocalizedErrorMessage,
|
||||
} from '../../lib/error-utils';
|
||||
import GleanMetrics from '../../lib/glean';
|
||||
import { GleanClickEventType2FA } from '../../lib/types';
|
||||
import Banner from '../../components/Banner';
|
||||
@@ -31,6 +39,12 @@ const InlineRecoverySetup = ({
|
||||
email,
|
||||
}: InlineRecoverySetupProps & RouteComponentProps) => {
|
||||
const ftlMsgResolver = useFtlMsgResolver();
|
||||
const navigateWithQuery = useNavigateWithQuery();
|
||||
const location = useLocation() as ReturnType<typeof useLocation> & {
|
||||
state?: SigninRecoveryLocationState;
|
||||
};
|
||||
const signinRecoveryLocationState = location.state;
|
||||
const { totp, ...signinLocationState } = signinRecoveryLocationState || {};
|
||||
const localizedIncorrectBackupCodeError = ftlMsgResolver.getMsg(
|
||||
'tfa-incorrect-recovery-code-1',
|
||||
'Incorrect backup authentication code'
|
||||
@@ -44,6 +58,8 @@ const InlineRecoverySetup = ({
|
||||
const [successfulTotpSetup, setSuccessfulTotpSetup] =
|
||||
useState<boolean>(false);
|
||||
const [recoveryCodeError, setRecoveryCodeError] = useState<string>('');
|
||||
const [bannerErrorLocalized, setBannerErrorLocalized] =
|
||||
useState<React.ReactNode>(null);
|
||||
|
||||
const showBannerSuccess = useCallback(
|
||||
() =>
|
||||
@@ -61,7 +77,7 @@ const InlineRecoverySetup = ({
|
||||
[ftlMsgResolver, successfulTotpSetup]
|
||||
);
|
||||
|
||||
const showBannerError = useCallback(
|
||||
const showBannerOAuthError = useCallback(
|
||||
() =>
|
||||
oAuthError && (
|
||||
<Banner
|
||||
@@ -87,6 +103,8 @@ const InlineRecoverySetup = ({
|
||||
|
||||
const completeSetup = useCallback(
|
||||
async (code: string) => {
|
||||
setBannerErrorLocalized(null);
|
||||
|
||||
if (!recoveryCodes.includes(code.trim())) {
|
||||
setRecoveryCodeError(localizedIncorrectBackupCodeError);
|
||||
return;
|
||||
@@ -100,11 +118,12 @@ const InlineRecoverySetup = ({
|
||||
setSuccessfulTotpSetup(true);
|
||||
setTimeout(successfulSetupHandler, 500);
|
||||
} else {
|
||||
// Some server side error occurred. Generic error message in catch
|
||||
// Some server side error occurred. Generic error message in catch
|
||||
// block.
|
||||
throw new Error('cannot enable TOTP');
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (err) {
|
||||
const { error } = getHandledError(err);
|
||||
if (error.errno === AuthUiErrors.TOTP_TOKEN_NOT_FOUND.errno) {
|
||||
setRecoveryCodeError(
|
||||
ftlMsgResolver.getMsg(
|
||||
@@ -112,6 +131,34 @@ const InlineRecoverySetup = ({
|
||||
AuthUiErrors.TOTP_TOKEN_NOT_FOUND.message
|
||||
)
|
||||
);
|
||||
} else if (error.errno === AuthUiErrors.INVALID_OTP_CODE.errno) {
|
||||
const startOverLink = (
|
||||
<Link
|
||||
className="link-blue"
|
||||
to="/inline_totp_setup"
|
||||
state={signinLocationState}
|
||||
onClick={() => {
|
||||
navigateWithQuery('/inline_totp_setup', {
|
||||
state: signinLocationState,
|
||||
});
|
||||
}}
|
||||
>
|
||||
start over
|
||||
</Link>
|
||||
);
|
||||
|
||||
setBannerErrorLocalized(
|
||||
<FtlMsg
|
||||
id="two-factor-auth-setup-token-verification-error"
|
||||
elems={{ a: startOverLink }}
|
||||
>
|
||||
<>
|
||||
There was a problem enabling two-step authentication. Check that
|
||||
your device’s clock is set to update automatically and{' '}
|
||||
{startOverLink}.
|
||||
</>
|
||||
</FtlMsg>
|
||||
);
|
||||
} else {
|
||||
setRecoveryCodeError(
|
||||
ftlMsgResolver.getMsg(
|
||||
@@ -128,6 +175,8 @@ const InlineRecoverySetup = ({
|
||||
recoveryCodes,
|
||||
successfulSetupHandler,
|
||||
verifyTotpHandler,
|
||||
navigateWithQuery,
|
||||
signinLocationState,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -156,7 +205,19 @@ const InlineRecoverySetup = ({
|
||||
{...{ serviceName }}
|
||||
/>
|
||||
{showBannerSuccess()}
|
||||
{showBannerError()}
|
||||
{!bannerErrorLocalized && showBannerOAuthError()}
|
||||
{/* Only show one banner-style error at a time. At the time of writing the
|
||||
* only non-oauth error banner tells the user to start the flow again,
|
||||
* so allow it to take precedence.
|
||||
*/}
|
||||
{bannerErrorLocalized && (
|
||||
<Banner
|
||||
type="error"
|
||||
customContent={
|
||||
<p className="font-bold">{bannerErrorLocalized}</p>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<section>
|
||||
<div>
|
||||
<BackupCodesImage />
|
||||
|
||||
@@ -1075,6 +1075,23 @@ two_factor_auth:
|
||||
expires: never
|
||||
data_sensitivity:
|
||||
- interaction
|
||||
setup_invalid_code_error:
|
||||
type: event
|
||||
description: |
|
||||
Edge case error that indicates the user verified their OTP code client-side during setup, but the server rejected it
|
||||
lifetime: ping
|
||||
send_in_pings:
|
||||
- events
|
||||
notification_emails:
|
||||
- vzare@mozilla.com
|
||||
- fxa-staff@mozilla.com
|
||||
bugs:
|
||||
- https://mozilla-hub.atlassian.net/browse/FXA-11087
|
||||
data_reviews:
|
||||
- https://bugzilla.mozilla.org/show_bug.cgi?id=1830504
|
||||
expires: never
|
||||
data_sensitivity:
|
||||
- interaction
|
||||
|
||||
two_step_auth_phone_code:
|
||||
sent:
|
||||
|
||||
1
types/@otplib/index.d.ts
vendored
Normal file
1
types/@otplib/index.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module '@otplib';
|
||||
1
types/@otplib/preset-browser/index.d.ts
vendored
Normal file
1
types/@otplib/preset-browser/index.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module '@otplib/preset-browser';
|
||||
@@ -11102,6 +11102,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@otplib/preset-browser@npm:^12.0.1":
|
||||
version: 12.0.1
|
||||
resolution: "@otplib/preset-browser@npm:12.0.1"
|
||||
checksum: 10c0/ec0f61905d0e337fc68e0717b85fcdfb1b42263b1b7a7e96f89379a86d004c88a9ff5cf82a37de50696d6f3ecb219bdb1992f85a69320f8bccf38b49c492e1e2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@oxc-resolver/binding-darwin-arm64@npm:1.12.0":
|
||||
version: 1.12.0
|
||||
resolution: "@oxc-resolver/binding-darwin-arm64@npm:1.12.0"
|
||||
@@ -33219,6 +33226,7 @@ __metadata:
|
||||
"@emotion/react": "npm:^11.13.3"
|
||||
"@emotion/styled": "npm:^11.13.0"
|
||||
"@material-ui/core": "npm:v5.0.0-alpha.24"
|
||||
"@otplib/preset-browser": "npm:^12.0.1"
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "npm:^0.5.3"
|
||||
"@reach/router": "npm:^1.3.4"
|
||||
"@react-pdf/renderer": "npm:3.2.1"
|
||||
|
||||
Reference in New Issue
Block a user