From 0f4f0d3dadd38b5d7c29ac9d97bfc701fb5ec1c5 Mon Sep 17 00:00:00 2001 From: George Gevoian Date: Wed, 16 Mar 2022 19:32:17 -0700 Subject: [PATCH] (core) Migrate to SRP and add change password dialog Summary: Moves some auth-related UI components, like MFAConfig, out of core, and adds a new ChangePasswordDialog component for allowing direct password changes, replacing the old reset password link to hosted Cognito. Updates all MFA endpoints to use SRP for authentication. Also refactors MFAConfig into smaller files, and polishes up some parts of the UI to be more consistent with the login pages. Test Plan: New server and deployment tests. Updated existing tests. Reviewers: paulfitz Reviewed By: paulfitz Differential Revision: https://phab.getgrist.com/D3311 --- app/client/lib/formUtils.ts | 18 + app/client/models/gristUrlState.ts | 7 - app/client/tsconfig.json | 5 +- app/client/ui/AccountPage.ts | 52 +- app/client/ui/MFAConfig.ts | 1039 ------------------- app/common/UserAPI.ts | 115 -- stubs/app/client/ui/ChangePasswordDialog.ts | 3 + stubs/app/client/ui/MFAConfig.ts | 8 + stubs/app/tsconfig.json | 1 + 9 files changed, 44 insertions(+), 1204 deletions(-) delete mode 100644 app/client/ui/MFAConfig.ts create mode 100644 stubs/app/client/ui/ChangePasswordDialog.ts create mode 100644 stubs/app/client/ui/MFAConfig.ts diff --git a/app/client/lib/formUtils.ts b/app/client/lib/formUtils.ts index 6ef74fab..02839862 100644 --- a/app/client/lib/formUtils.ts +++ b/app/client/lib/formUtils.ts @@ -1,4 +1,5 @@ import {reportError} from 'app/client/models/errors'; +import {ApiError} from 'app/common/ApiError'; import {BaseAPI} from 'app/common/BaseAPI'; import {dom, Observable} from 'grainjs'; @@ -52,3 +53,20 @@ export function formDataToObj(formElem: HTMLFormElement): { [key: string]: strin export async function submitForm(fields: { [key: string]: string }, form: HTMLFormElement): Promise { return BaseAPI.requestJson(form.action, {method: 'POST', body: JSON.stringify(fields)}); } + +/** + * Sets the error details on `errObs` if `err` is a 4XX error (except 401). Otherwise, reports the + * error via the Notifier instance. + */ +export function handleFormError(err: unknown, errObs: Observable) { + if ( + err instanceof ApiError && + err.status !== 401 && + err.status >= 400 && + err.status < 500 + ) { + errObs.set(err.details?.userError ?? err.message); + } else { + reportError(err as Error|string); + } +} diff --git a/app/client/models/gristUrlState.ts b/app/client/models/gristUrlState.ts index 7a109289..833f837f 100644 --- a/app/client/models/gristUrlState.ts +++ b/app/client/models/gristUrlState.ts @@ -84,13 +84,6 @@ export function getLoginOrSignupUrl(nextUrl: string = _getCurrentUrl()): string return _getLoginLogoutUrl('signin', nextUrl); } -// Get url for the reset password page. -export function getResetPwdUrl(): string { - const startUrl = new URL(window.location.href); - startUrl.pathname = '/resetPassword'; - return startUrl.href; -} - // Returns the URL for the "you are signed out" page. export function getSignedOutUrl(): string { return getMainOrgUrl() + "signed-out"; } diff --git a/app/client/tsconfig.json b/app/client/tsconfig.json index 403d97da..a2e17fe1 100644 --- a/app/client/tsconfig.json +++ b/app/client/tsconfig.json @@ -1,7 +1,10 @@ { "extends": "../../buildtools/tsconfig-base.json", + "include": [ + "**/*", + "../../stubs/app/client/**/*" + ], "references": [ { "path": "../common" }, - { "path": "../../stubs/app" }, ] } diff --git a/app/client/ui/AccountPage.ts b/app/client/ui/AccountPage.ts index 1a297711..1048cc73 100644 --- a/app/client/ui/AccountPage.ts +++ b/app/client/ui/AccountPage.ts @@ -1,19 +1,18 @@ import {AppModel, reportError} from 'app/client/models/AppModel'; -import {getResetPwdUrl, urlState} from 'app/client/models/gristUrlState'; +import {urlState} from 'app/client/models/gristUrlState'; import {ApiKey} from 'app/client/ui/ApiKey'; import {AppHeader} from 'app/client/ui/AppHeader'; +import {buildChangePasswordDialog} from 'app/client/ui/ChangePasswordDialog'; import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon'; import {MFAConfig} from 'app/client/ui/MFAConfig'; import {pagePanels} from 'app/client/ui/PagePanels'; import {createTopBarHome} from 'app/client/ui/TopBar'; import {transientInput} from 'app/client/ui/transientInput'; -import {bigBasicButton, bigPrimaryButtonLink} from 'app/client/ui2018/buttons'; import {cssBreadcrumbs, cssBreadcrumbsLink, separator} from 'app/client/ui2018/breadcrumbs'; import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox'; import {icon} from 'app/client/ui2018/icons'; -import {cssModalBody, cssModalButtons, cssModalTitle, modal} from 'app/client/ui2018/modals'; import {colors, vars} from 'app/client/ui2018/cssVars'; -import {FullUser, UserMFAPreferences} from 'app/common/UserAPI'; +import {FullUser} from 'app/common/UserAPI'; import {Computed, Disposable, dom, domComputed, makeTestId, Observable, styled} from 'grainjs'; const testId = makeTestId('test-account-page-'); @@ -24,7 +23,6 @@ const testId = makeTestId('test-account-page-'); export class AccountPage extends Disposable { private _apiKey = Observable.create(this, ''); private _userObs = Observable.create(this, null); - private _userMfaPreferences = Observable.create(this, null); private _isEditingName = Observable.create(this, false); private _nameEdit = Observable.create(this, ''); private _isNameValid = Computed.create(this, this._nameEdit, (_use, val) => checkName(val)); @@ -93,9 +91,8 @@ export class AccountPage extends Disposable { cssDataRow( cssSubHeader('Login Method'), cssLoginMethod(user.loginMethod), - user.loginMethod === 'Email + Password' ? cssTextBtn( - cssIcon('Settings'), 'Reset', - dom.on('click', () => confirmPwdResetModal(user.email)), + user.loginMethod === 'Email + Password' ? cssTextBtn('Change Password', + dom.on('click', () => this._showChangePasswordDialog()), ) : null, testId('login-method'), ), @@ -114,10 +111,7 @@ export class AccountPage extends Disposable { "to ensure that you're the only person who can access your account, even if someone " + "knows your password." ), - dom.create(MFAConfig, this._userMfaPreferences, { - appModel: this._appModel, - onChange: () => this._fetchUserMfaPreferences(), - }), + dom.create(MFAConfig, user), ), cssHeader('API'), cssDataRow(cssSubHeader('API Key'), cssContent( @@ -166,21 +160,11 @@ export class AccountPage extends Disposable { this._userObs.set(await this._appModel.api.getUserProfile()); } - private async _fetchUserMfaPreferences() { - this._userMfaPreferences.set(null); - this._userMfaPreferences.set(await this._appModel.api.getUserMfaPreferences()); - } - private async _fetchAll() { await Promise.all([ this._fetchApiKey(), this._fetchUserProfile(), ]); - - const user = this._userObs.get(); - if (user?.loginMethod === 'Email + Password') { - await this._fetchUserMfaPreferences(); - } } private async _updateUserName(val: string) { @@ -195,26 +179,10 @@ export class AccountPage extends Disposable { await this._appModel.api.updateAllowGoogleLogin(allowGoogleLogin); await this._fetchUserProfile(); } -} -function confirmPwdResetModal(userEmail: string) { - return modal((ctl, _owner) => { - return [ - cssModalTitle('Reset Password'), - cssModalBody(`Click continue to open the password reset form. Submit it for your email address: ${userEmail}`), - cssModalButtons( - bigPrimaryButtonLink( - { href: getResetPwdUrl(), target: '_blank' }, - 'Continue', - dom.on('click', () => ctl.close()), - ), - bigBasicButton( - 'Cancel', - dom.on('click', () => ctl.close()), - ), - ), - ]; - }); + private _showChangePasswordDialog() { + return buildChangePasswordDialog(); + } } /** @@ -292,7 +260,7 @@ const cssTextBtn = styled('button', ` border: none; padding: 0; text-align: left; - min-width: 90px; + min-width: 110px; &:hover { color: ${colors.darkGreen}; diff --git a/app/client/ui/MFAConfig.ts b/app/client/ui/MFAConfig.ts deleted file mode 100644 index a9f1985d..00000000 --- a/app/client/ui/MFAConfig.ts +++ /dev/null @@ -1,1039 +0,0 @@ -import {handleSubmit} from 'app/client/lib/formUtils'; -import {AppModel} from 'app/client/models/AppModel'; -import {reportError, reportSuccess} from 'app/client/models/errors'; -import {bigBasicButton, bigPrimaryButton} from 'app/client/ui2018/buttons'; -import {colors, vars} from 'app/client/ui2018/cssVars'; -import {icon} from 'app/client/ui2018/icons'; -import {cssLink} from 'app/client/ui2018/links'; -import {loadingSpinner} from 'app/client/ui2018/loaders'; -import {cssModalBody, cssModalTitle, cssModalWidth, IModalControl, - modal, cssModalButtons as modalButtons} from 'app/client/ui2018/modals'; -import {ApiError} from 'app/common/ApiError'; -import {FullUser} from 'app/common/LoginSessionAPI'; -import {AuthMethod, ChallengeRequired, UserMFAPreferences} from 'app/common/UserAPI'; -import {Disposable, dom, input, makeTestId, MultiHolder, Observable, styled} from 'grainjs'; -import {toDataURL} from 'qrcode'; - -const testId = makeTestId('test-mfa-'); - -/** - * Step in the dialog flow for enabling a MFA method. - */ -type EnableAuthMethodStep = - | 'verify-password' - | 'choose-auth-method' - | 'configure-auth-app' - | 'configure-phone-message'; - -/** - * Step in the dialog flow for disabling a MFA method. - */ -type DisableAuthMethodStep = - | 'confirm-disable' - | 'verify-password' - | 'disable-method'; - -interface MFAConfigOptions { - appModel: AppModel; - // Called when the MFA status is changed successfully. - onChange: () => void; -} - -interface EnablePhoneMessageOptions { - // Called on successful completion of the enable phone message form. - onSuccess: (newPhoneNumber: string) => void; - // If true, shows a back text button on the first screen of the form. - showBackButton?: boolean; - // The text to use for the back button if `showBackButton` is true. - backButtonText?: string; - // Called when the back button is clicked. - onBack?: () => void; -} - -// Common HTML input options for 6 digit verification fields (SMS and TOTP). -const verificationCodeInputOpts = { - name: 'verificationCode', - type: 'text', - inputmode: 'numeric', - pattern: '\\d{6}', - required: 'true', -}; - -/** - * Shows information about multi-factor authentication preferences for the logged-in user - * and buttons for enabling/disabling them. - * - * Currently supports software tokens (TOTP) and SMS. - */ -export class MFAConfig extends Disposable { - private _appModel: AppModel; - private _user: FullUser; - - constructor( - private _mfaPrefs: Observable, - private _options: MFAConfigOptions - ) { - super(); - this._appModel = _options.appModel; - this._user = this._appModel.currentUser!; - } - - public buildDom() { - return this._buildButtons(); - } - - private _buildButtons() { - return cssButtons( - dom.domComputed(this._mfaPrefs, mfaPrefs => { - if (!mfaPrefs) { return cssSmallSpinner(cssSmallLoadingSpinner()); } - - const {isSmsMfaEnabled, isSoftwareTokenMfaEnabled, phoneNumber} = mfaPrefs; - return [ - !isSmsMfaEnabled && !isSoftwareTokenMfaEnabled ? - cssTextBtn( - 'Configure two-factor authentication', - dom.on('click', () => this._showAddAuthMethodModal(undefined, { - onSuccess: () => { - reportSuccess('Two-factor authentication enabled'); - this._options.onChange(); - } - })), - testId('enable-2fa'), - ) : - dom.frag( - cssDataRow( - cssIconAndText(cssIcon('BarcodeQR'), cssText('Authenticator app')), - isSoftwareTokenMfaEnabled ? - cssTextBtn( - 'Disable', - dom.on('click', () => this._showDisableAuthMethodModal('TOTP', { - onSuccess: () => { - reportSuccess('Authentication app disabled'); - this._options.onChange(); - } - })), - testId('disable-auth-app'), - ) : - cssTextBtn( - 'Enable', - dom.on('click', () => this._showAddAuthMethodModal('TOTP', { - onSuccess: () => { - reportSuccess('Authentication app enabled'); - this._options.onChange(); - } - })), - testId('enable-auth-app'), - ), - testId('auth-app-row') - ), - cssDataRow( - cssIconAndText( - cssIcon('MobileChat'), - cssText('SMS', isSmsMfaEnabled && phoneNumber ? ` to ${phoneNumber}` : null), - ), - isSmsMfaEnabled ? - [ - cssTextBtn( - 'Change', - dom.on('click', () => this._showAddAuthMethodModal('SMS', { - onSuccess: () => { - reportSuccess('Phone number changed'); - this._options.onChange(); - } - })), - testId('change-phone-number'), - ), - cssTextBtn( - 'Disable', - dom.on('click', () => this._showDisableAuthMethodModal('SMS', { - onSuccess: () => { - reportSuccess('Phone message disabled'); - this._options.onChange(); - } - })), - testId('disable-sms'), - ), - ] : - cssTextBtn( - 'Enable', - dom.on('click', () => this._showAddAuthMethodModal('SMS', { - onSuccess: () => { - reportSuccess('Phone message enabled'); - this._options.onChange(); - } - })), - testId('enable-sms'), - ), - testId('sms-row') - ), - ), - ]; - }), - testId('buttons') - ); - } - - /** - * Displays a modal that allows users to enable a MFA method for their account. - * - * @param {AuthMethod | undefined} method If specified, skips the 'choose-auth-method' step. - * @param {() => void} options.onSuccess Called after successfully adding an auth method. - */ - private _showAddAuthMethodModal( - method: AuthMethod | undefined, - options: {onSuccess: () => void} - ): void { - return modal((ctl, owner) => { - const currentStep = Observable.create(owner, 'verify-password'); - - return [ - dom.domComputed((use) => { - const step = use(currentStep); - switch (step) { - case 'verify-password': { - return [ - this._buildSecurityVerificationForm(ctl, {onSuccess: async (hadSecondStep) => { - /** - * If method was unspecified, but second step verification occurred, we know that - * the client doesn't have up-to-date 2FA preferences. Close the modal, and force - * a refresh of UserMFAPreferences, which should cause the correct buttons to be - * rendered once preferences are loaded. - * - * This is ultimately caused by older Grist sessions (pre-2FA) not having access - * or refresh tokens, which makes it impossible to get MFA status without first - * requiring that users re-authenticate. Token-less sessions currently return a - * disabled status for all 2FA methods as an interim solution, until all old - * sessions have expired (3 months). - * - * TODO: Revisit this 3 months after this commit has landed in prod; we may no longer - * need much of these changes. - */ - if (!method && hadSecondStep) { - ctl.close(); - this._options.onChange(); - } - if (!method) { return currentStep.set('choose-auth-method'); } - - currentStep.set(method === 'SMS' ? 'configure-phone-message' : 'configure-auth-app'); - }}), - ]; - } - case 'choose-auth-method': { - return [ - cssModalTitle('Two-factor authentication', testId('title')), - cssModalBody( - cssMainText( - "Once you enable two step authentication, you'll need to enter a special code " + - "when you log in. Please choose a method you'd like to receive codes with." - ), - cssAuthMethods( - cssAuthMethod( - cssAuthMethodTitle(cssGreenIcon('BarcodeQR2'), 'Authenticator app', testId('auth-method-title')), - cssAuthMethodDesc( - "An authenticator app lets you access your security code without receiving a call " + - "or text message. If you don't already have an authenticator app, we'd recommend " + - "using ", - cssLink('Google Authenticator', dom.on('click', e => e.stopPropagation()), { - href: 'https://play.google.com/store/apps/' + - 'details?id=com.google.android.apps.authenticator2&hl=en_US&gl=US', - target: '_blank', - }), - ".", - ), - dom.on('click', () => currentStep.set('configure-auth-app')), - testId('auth-app-method'), - ), - cssAuthMethod( - cssAuthMethodTitle(cssGreenIcon('MobileChat2'), 'Phone message', testId('auth-method-title')), - cssAuthMethodDesc( - 'You need to add a U.S. phone number where you can receive authentication codes by text.', - ), - dom.on('click', () => currentStep.set('configure-phone-message')), - testId('sms-method'), - ), - ), - ), - ]; - } - case 'configure-auth-app': { - return [ - this._buildConfigureAuthAppForm(ctl, {onSuccess: async () => { - ctl.close(); - options.onSuccess(); - }}), - method ? null: cssBackBtn('← Back to methods', - dom.on('click', () => { currentStep.set('choose-auth-method'); }), - testId('back-to-methods'), - ), - ]; - } - case 'configure-phone-message': { - return [ - this._buildConfigurePhoneMessageForm(ctl, { - onSuccess: async () => { - ctl.close(); - options.onSuccess(); - }, - showBackButton: !method, - backButtonText: '← Back to methods', - onBack: () => currentStep.set('choose-auth-method'), - }), - ]; - } - } - }), - cssModalWidth('fixed-wide'), - ]; - }); - } - - /** - * Displays a modal that allows users to disable a MFA method for their account. - * - * @param {AuthMethod} method The auth method to disable. - * @param {() => void} options.onSuccess Called after successfully disabling an auth method. - */ - private _showDisableAuthMethodModal(method: AuthMethod, options: {onSuccess: () => void}): void { - return modal((ctl, owner) => { - const currentStep = Observable.create(owner, 'confirm-disable'); - - return [ - dom.domComputed((use) => { - const step = use(currentStep); - switch (step) { - case 'confirm-disable': { - return [ - cssModalTitle( - `Disable ${method === 'TOTP' ? 'authentication app' : 'phone message'}?`, - testId('title') - ), - cssModalBody( - cssMainText( - "Two-factor authentication is an extra layer of security for your Grist account designed " + - "to ensure that you're the only person who can access your account, even if someone " + - "knows your password." - ), - cssModalButtons( - bigPrimaryButton('Yes, disable', - dom.on('click', () => currentStep.set('verify-password')), - testId('disable'), - ), - bigBasicButton('Cancel', dom.on('click', () => ctl.close()), testId('cancel')), - ), - ), - ]; - } - case 'verify-password': { - return [ - this._buildSecurityVerificationForm(ctl, {onSuccess: () => { - currentStep.set('disable-method'); - }}), - ]; - } - case 'disable-method': { - const disableMethod = method === 'SMS' ? - this._unregisterSMS() : - this._unregisterSoftwareToken(); - disableMethod - .then(() => { options.onSuccess(); }) - .catch(reportError) - .finally(() => ctl.close()); - - return cssSpinner(loadingSpinner()); - } - } - }), - cssModalWidth('fixed-wide'), - ]; - }); - } - - /** - * Builds security verification forms, including a password form and optional 2FA verification form. - * - * A callback function must be passed, which will be called after successful completion of the - * verification forms. - * - * @param {() => void} options.onSuccess Called after successful completion of verification. - */ - private _buildSecurityVerificationForm( - ctl: IModalControl, - {onSuccess}: {onSuccess: (hadSecondStep: boolean) => void} - ) { - const holder = new MultiHolder(); - const securityStep = Observable.create<'password' | 'sms' | 'totp' | 'loading'>(holder, 'password'); - const password = Observable.create(holder, ''); - const session = Observable.create(holder, ''); - const challengeDetails = Observable.create(holder, null); - - return [ - dom.autoDispose(holder), - dom.domComputed(securityStep, (step) => { - switch (step) { - case 'loading': { - return cssSpinner(loadingSpinner()); - } - case 'password': { - let formElement: HTMLFormElement; - const multiHolder = new MultiHolder(); - const pending = Observable.create(multiHolder, false); - const errorObs: Observable = Observable.create(multiHolder, null); - - return dom.frag( - dom.autoDispose(pending.addListener(isPending => isPending && errorObs.set(null))), - dom.autoDispose(multiHolder), - cssModalTitle('Confirm your password', testId('title')), - cssModalBody( - formElement = dom('form', - cssMainText('Please confirm your password to continue.'), - cssBoldSubHeading('Password'), - cssInput(password, - {onInput: true}, - { - name: 'password', - placeholder: 'password', - type: 'password', - autocomplete: 'current-password', - id: 'current-password', - required: 'true', - }, - (el) => { setTimeout(() => el.focus(), 10); }, - dom.onKeyDown({Enter: () => formElement.requestSubmit()}), - testId('password-input'), - ), - cssFormError(dom.text(use => use(errorObs) ?? ''), testId('form-error')), - handleSubmit(pending, - ({password: pass}) => this._verifyPassword(pass), - (result) => { - if (!result.isChallengeRequired) { return onSuccess(false); } - - session.set(result.session); - challengeDetails.set(result); - if (result.challengeName === 'SMS_MFA') { - securityStep.set('sms'); - } else { - securityStep.set('totp'); - } - }, - (err) => handleFormError(err, errorObs), - ), - cssModalButtons( - bigPrimaryButton('Confirm', dom.boolAttr('disabled', pending), testId('confirm')), - bigBasicButton('Cancel', dom.on('click', () => ctl.close()), testId('cancel')), - ), - ), - ), - ); - } - case 'totp': { - let formElement: HTMLFormElement; - const multiHolder = new MultiHolder(); - const pending = Observable.create(multiHolder, false); - const verificationCode = Observable.create(multiHolder, ''); - const errorObs: Observable = Observable.create(multiHolder, null); - const {isAlternateChallengeAvailable, deliveryDestination} = challengeDetails.get()!; - - return dom.frag( - dom.autoDispose(pending.addListener(isPending => isPending && errorObs.set(null))), - dom.autoDispose(multiHolder), - cssModalTitle('Almost there!', testId('title')), - cssModalBody( - formElement = dom('form', - cssMainText( - 'Enter the authentication code generated by your app to confirm your account.', - testId('main-text'), - ), - cssBoldSubHeading('Verification Code'), - cssCodeInput(verificationCode, - {onInput: true}, - verificationCodeInputOpts, - (el) => { setTimeout(() => el.focus(), 10); }, - dom.onKeyDown({Enter: () => formElement.requestSubmit()}), - testId('verification-code-input'), - ), - cssInput(session, {onInput: true}, {name: 'session', type: 'hidden'}), - cssFormError(dom.text(use => use(errorObs) ?? ''), testId('form-error')), - handleSubmit(pending, - ({verificationCode: code, session: s}) => this._verifySecondStep('TOTP', code, s), - () => onSuccess(true), - (err) => handleFormError(err, errorObs), - ), - cssModalButtons( - bigPrimaryButton('Submit', dom.boolAttr('disabled', pending), testId('submit')), - bigBasicButton('Cancel', dom.on('click', () => ctl.close()), testId('cancel')), - ), - !isAlternateChallengeAvailable ? null : cssSubText( - 'Receive a code by SMS? ', - cssLink( - // Use masked phone from prefs or challenge response, if available. - `Text ${deliveryDestination}.`, - dom.on('click', async () => { - if (pending.get()) { return; } - - securityStep.set('loading'); - try { - const result = await this._verifyPassword(password.get(), 'SMS'); - if (result.isChallengeRequired) { - session.set(result.session); - challengeDetails.set(result); - securityStep.set('sms'); - } - } catch (err) { - reportError(err as Error|string); - securityStep.set('totp'); - } - }), - ), - testId('use-sms'), - ), - ), - ), - ); - } - case 'sms': { - let formElement: HTMLFormElement; - const multiHolder = new MultiHolder(); - const pending = Observable.create(multiHolder, false); - const verificationCode = Observable.create(multiHolder, ''); - const isResendingCode = Observable.create(multiHolder, false); - const errorObs: Observable = Observable.create(multiHolder, null); - const resendingListener = isResendingCode.addListener(isResending => { - if (!isResending) { return; } - - errorObs.set(null); - verificationCode.set(''); - }); - const {isAlternateChallengeAvailable, deliveryDestination} = challengeDetails.get()!; - - return dom.frag( - dom.autoDispose(pending.addListener(isPending => isPending && errorObs.set(null))), - dom.autoDispose(resendingListener), - dom.autoDispose(multiHolder), - dom.domComputed(isResendingCode, isLoading => { - if (isLoading) { return cssSpinner(loadingSpinner()); } - - return [ - cssModalTitle('Almost there!', testId('title')), - cssModalBody( - formElement = dom('form', - cssMainText( - 'We have sent an authentication code to ', - cssLightlyBoldedText(deliveryDestination), - '. Enter it below to confirm your account.', - testId('main-text'), - ), - cssBoldSubHeading('Authentication Code'), - cssCodeInput(verificationCode, - {onInput: true}, - {...verificationCodeInputOpts, autocomplete: 'one-time-code'}, - (el) => { setTimeout(() => el.focus(), 10); }, - dom.onKeyDown({Enter: () => formElement.requestSubmit()}), - testId('verification-code-input'), - ), - cssInput(session, {onInput: true}, {name: 'session', type: 'hidden'}), - cssSubText( - "Didn't receive a code? ", - cssLink( - 'Resend it', - dom.on('click', async () => { - if (pending.get()) { return; } - - try { - isResendingCode.set(true); - const result = await this._verifyPassword(password.get(), 'SMS'); - if (result.isChallengeRequired) { - session.set(result.session); - challengeDetails.set(result); - } - } finally { - isResendingCode.set(false); - } - }), - testId('resend-code'), - ), - ), - cssFormError(dom.text(use => use(errorObs) ?? ''), testId('form-error')), - handleSubmit(pending, - ({verificationCode: code, session: s}) => this._verifySecondStep('SMS', code, s), - () => onSuccess(true), - (err) => handleFormError(err, errorObs), - ), - cssModalButtons( - bigPrimaryButton('Submit', dom.boolAttr('disabled', pending), testId('submit')), - bigBasicButton('Cancel', dom.on('click', () => ctl.close()), testId('cancel')), - ), - !isAlternateChallengeAvailable ? null : cssSubText( - cssLink( - 'Use code from authenticator app?', - dom.on('click', async () => { - if (pending.get()) { return; } - - securityStep.set('loading'); - try { - const result = await this._verifyPassword(password.get(), 'TOTP'); - if (result.isChallengeRequired) { - session.set(result.session); - securityStep.set('totp'); - } - } catch (err) { - reportError(err as Error|string); - securityStep.set('sms'); - } - }), - testId('use-auth-app'), - ), - ), - ) - ) - ]; - }), - ); - } - } - }), - ]; - } - - /** - * Builds a form for registering a software token (TOTP) MFA method. - * - * A callback function must be passed, which will be called after successful completion of the - * registration form. - * - * @param {() => void} options.onSuccess Called after successful completion of registration. - */ - private _buildConfigureAuthAppForm(ctl: IModalControl, {onSuccess}: {onSuccess: () => void}) { - let formElement: HTMLFormElement; - const holder = new MultiHolder(); - const qrCode: Observable = Observable.create(holder, null); - const verificationCode = Observable.create(holder, ''); - const pending = Observable.create(holder, false); - const errorObs: Observable = Observable.create(holder, null); - - this._getSoftwareTokenQRCode() - .then(code => qrCode.isDisposed() || qrCode.set(code)) - .catch(e => { ctl.close(); reportError(e); }); - - return [ - dom.autoDispose(pending.addListener(isPending => isPending && errorObs.set(null))), - dom.autoDispose(holder), - dom.domComputed(qrCode, code => { - if (code === null) { return cssSpinner(loadingSpinner()); } - - return [ - cssModalTitle('Configure authenticator app', testId('title')), - cssModalBody( - cssModalBody( - formElement = dom('form', - cssMainText( - "An authenticator app lets you access your security code without receiving a call " + - "or text message. If you don't already have an authenticator app, we'd recommend " + - "using ", - cssLink('Google Authenticator', { - href: 'https://play.google.com/store/apps/' + - 'details?id=com.google.android.apps.authenticator2&hl=en_US&gl=US', - target: '_blank', - }), - ".", - ), - cssBoldSubHeading('To configure your authenticator app:'), - cssListItem('1. Add a new account'), - cssListItem('2. Scan the following barcode', {style: 'margin-bottom: 8px'}), - cssQRCode({src: code}, testId('qr-code')), - cssListItem('3. Enter the verification code that appears after scanning the barcode'), - cssBoldSubHeading('Authentication code'), - cssCodeInput(verificationCode, - {onInput: true}, - verificationCodeInputOpts, - dom.onKeyDown({Enter: () => formElement.requestSubmit()}), - testId('verification-code-input'), - ), - cssFormError(dom.text(use => use(errorObs) ?? ''), testId('form-error')), - handleSubmit(pending, - ({verificationCode: c}) => this._confirmRegisterSoftwareToken(c), - () => onSuccess(), - (err) => handleFormError(err, errorObs), - ), - cssModalButtons( - bigPrimaryButton('Verify', dom.boolAttr('disabled', pending), testId('verify')), - bigBasicButton('Cancel', dom.on('click', () => ctl.close()), testId('cancel')), - ), - ), - ), - ), - ]; - }), - ]; - } - - /** - * Builds a form for registering a SMS MFA method. - * - * A callback function must be passed, which will be called after successful completion of the - * registration form. - * - * @param {EnablePhoneMessageOptions} options Form options. - */ - private _buildConfigurePhoneMessageForm( - ctl: IModalControl, - {onSuccess, showBackButton, backButtonText, onBack}: EnablePhoneMessageOptions, - ) { - const holder = new MultiHolder(); - const configStep = Observable.create<'enter-phone' | 'verify-phone'>(holder, 'enter-phone'); - const pending = Observable.create(holder, false); - const phoneNumber = Observable.create(holder, ''); - const maskedPhoneNumber = Observable.create(holder, ''); - - return [ - dom.autoDispose(holder), - dom.domComputed(configStep, (step) => { - switch (step) { - case 'enter-phone': { - let formElement: HTMLFormElement; - const multiHolder = new MultiHolder(); - const errorObs: Observable = Observable.create(multiHolder, null); - - return dom.frag( - dom.autoDispose(pending.addListener(isPending => isPending && errorObs.set(null))), - dom.autoDispose(multiHolder), - cssModalTitle('Configure phone message', testId('title')), - cssModalBody( - formElement = dom('form', - cssMainText( - 'You need to add a U.S. phone number where you can receive authentication codes by text.', - ), - cssBoldSubHeading('Phone number'), - cssInput(phoneNumber, - {onInput: true}, - {name: 'phoneNumber', placeholder: '+1 (201) 555 0123', type: 'text', required: 'true'}, - (el) => { setTimeout(() => el.focus(), 10); }, - dom.onKeyDown({Enter: () => formElement.requestSubmit()}), - testId('phone-number-input'), - ), - cssFormError(dom.text(use => use(errorObs) ?? ''), testId('form-error')), - handleSubmit(pending, - ({phoneNumber: phone}) => this._registerSMS(phone), - ({deliveryDestination}) => { - maskedPhoneNumber.set(deliveryDestination); - configStep.set('verify-phone'); - }, - (err) => handleFormError(err, errorObs), - ), - cssModalButtons( - bigPrimaryButton('Send code', dom.boolAttr('disabled', pending), testId('send-code')), - bigBasicButton('Cancel', dom.on('click', () => ctl.close()), testId('cancel')), - ), - ), - ), - showBackButton && backButtonText !== undefined && onBack ? - cssBackBtn(backButtonText, dom.on('click', () => onBack()), testId('back')) : - null, - ); - } - case 'verify-phone': { - let formElement: HTMLFormElement; - const multiHolder = new MultiHolder(); - const verificationCode = Observable.create(multiHolder, ''); - const isResendingCode = Observable.create(multiHolder, false); - const errorObs: Observable = Observable.create(multiHolder, null); - const resendingListener = isResendingCode.addListener(isResending => { - if (!isResending) { return; } - - errorObs.set(null); - verificationCode.set(''); - }); - - return dom.frag( - dom.autoDispose(pending.addListener(isPending => isPending && errorObs.set(null))), - dom.autoDispose(resendingListener), - dom.autoDispose(multiHolder), - dom.domComputed(isResendingCode, isLoading => { - if (isLoading) { return cssSpinner(loadingSpinner()); } - - return [ - cssModalTitle('Confirm your phone', testId('title')), - cssModalBody( - formElement = dom('form', - cssMainText( - 'We have sent the authentication code to ', - cssLightlyBoldedText(maskedPhoneNumber.get()), - '. Enter it below to confirm your account.', - testId('main-text'), - ), - cssBoldSubHeading('Authentication Code'), - cssCodeInput(verificationCode, - {onInput: true}, - {...verificationCodeInputOpts, autocomplete: 'one-time-code'}, - (el) => { setTimeout(() => el.focus(), 10); }, - dom.onKeyDown({Enter: () => formElement.requestSubmit()}), - testId('verification-code-input'), - ), - cssSubText( - "Didn't receive a code? ", - cssLink( - 'Resend it', - dom.on('click', async () => { - if (pending.get()) { return; } - - try { - isResendingCode.set(true); - await this._registerSMS(phoneNumber.get()); - } finally { - isResendingCode.set(false); - } - }), - testId('resend-code'), - ), - ), - cssFormError(dom.text(use => use(errorObs) ?? ''), testId('form-error')), - handleSubmit(pending, - ({verificationCode: code}) => this._confirmRegisterSMS(code), - () => onSuccess(maskedPhoneNumber.get()), - (err) => handleFormError(err, errorObs), - ), - cssModalButtons( - bigPrimaryButton('Confirm', dom.boolAttr('disabled', pending), testId('confirm')), - bigBasicButton('Cancel', dom.on('click', () => ctl.close()), testId('cancel')), - ), - ) - ), - cssBackBtn('← Back to phone number', - dom.on('click', () => configStep.set('enter-phone')), - testId('back-to-phone') - ), - ]; - }) - ); - } - } - }), - ]; - } - - private async _registerSoftwareToken() { - return await this._appModel.api.registerSoftwareToken(); - } - - private async _confirmRegisterSoftwareToken(verificationCode: string) { - await this._appModel.api.confirmRegisterSoftwareToken(verificationCode); - } - - private async _unregisterSoftwareToken() { - await this._appModel.api.unregisterSoftwareToken(); - } - - private async _registerSMS(phoneNumber: string) { - return await this._appModel.api.registerSMS(phoneNumber); - } - - private async _confirmRegisterSMS(verificationCode: string) { - await this._appModel.api.confirmRegisterSMS(verificationCode); - } - - private async _unregisterSMS() { - await this._appModel.api.unregisterSMS(); - } - - private async _verifyPassword(password: string, preferredMfaMethod?: AuthMethod) { - return await this._appModel.api.verifyPassword(password, preferredMfaMethod); - } - - private async _verifySecondStep(authMethod: AuthMethod, verificationCode: string, session: string) { - await this._appModel.api.verifySecondStep(authMethod, verificationCode, session); - } - - /** - * Returns a data URL for a QR code that encodes a software token (TOTP) MFA shared secret. The - * URL can be set on an HTML image tag to display an image of the QR code in the browser. - * - * Used by _buildConfigureAuthAppForm to build the TOTP registration form. - */ - private async _getSoftwareTokenQRCode() { - const {secretCode} = await this._registerSoftwareToken(); - const qrCodeUrl = `otpauth://totp/${encodeURI(`Grist:${this._user.email}`)}?secret=${secretCode}&issuer=Grist`; - const qrCode = await toDataURL(qrCodeUrl); - return qrCode; - } -} - -/** - * Sets the error details on `errObs` if `err` is a 4XX error (except 401). Otherwise, reports the - * error via the Notifier instance. - */ -function handleFormError(err: unknown, errObs: Observable) { - if ( - err instanceof ApiError && - err.status !== 401 && - err.status >= 400 && - err.status < 500 - ) { - errObs.set(err.details?.userError ?? err.message); - } else { - reportError(err as Error|string); - } -} - -const spinnerSizePixels = '24px'; - -const cssButtons = styled('div', ` - min-height: ${spinnerSizePixels}; - position: relative; - display: flex; - flex-direction: column; - margin: 8px 0px; -`); - -const cssDataRow = styled('div', ` - margin-top: 8px; - display: flex; - gap: 16px; -`); - -const cssText = styled('div', ` - font-size: ${vars.mediumFontSize}; - border: none; - padding: 0; - text-align: left; -`); - -const cssMainText = styled(cssText, ` - margin-bottom: 32px; -`); - -const cssListItem = styled(cssText, ` - margin-bottom: 16px; -`); - -const cssSubText = styled(cssText, ` - margin-top: 16px; -`); - -const cssFormError = styled('div', ` - color: red; - min-height: 20px; - margin-top: 16px; -`); - -const cssIconAndText = styled('div', ` - display: flex; - align-items: center; - gap: 8px; -`); - -const cssTextBtn = styled('button', ` - font-size: ${vars.mediumFontSize}; - color: ${colors.lightGreen}; - cursor: pointer; - background-color: transparent; - border: none; - padding: 0; - text-align: left; - - &:hover { - color: ${colors.darkGreen}; - } -`); - -const cssBackBtn = styled(cssTextBtn, ` - margin-top: 16px; -`); - -const cssAuthMethods = styled('div', ` - display: grid; - grid-auto-rows: 1fr; - margin-top: 16px; - gap: 8px; -`); - -const cssAuthMethod = styled('div', ` - border: 1px solid ${colors.mediumGreyOpaque}; - border-radius: 3px; - cursor: pointer; - - &:hover { - border: 1px solid ${colors.slate}; - } -`); - -const cssAuthMethodTitle = styled(cssIconAndText, ` - font-size: ${vars.mediumFontSize}; - font-weight: bold; - color: ${colors.lightGreen}; - margin: 16px 16px 8px 16px; -`); - -const cssAuthMethodDesc = styled('div', ` - color: #8a8a8a; - padding: 0px 16px 16px 40px; -`); - -const cssInput = styled(input, ` - font-size: ${vars.mediumFontSize}; - height: 42px; - line-height: 16px; - width: 100%; - padding: 13px; - border: 1px solid #D9D9D9; - border-radius: 3px; - outline: none; - - &[type=number] { - -moz-appearance: textfield; - } - &[type=number]::-webkit-inner-spin-button, - &[type=number]::-webkit-outer-spin-button { - -webkit-appearance: none; - margin: 0; - } -`); - -const cssCodeInput = styled(cssInput, ` - width: 200px; -`); - -const cssSmallLoadingSpinner = styled(loadingSpinner, ` - width: ${spinnerSizePixels}; - height: ${spinnerSizePixels}; - border-radius: ${spinnerSizePixels}; -`); - -const cssCenteredDiv = styled('div', ` - display: flex; - justify-content: center; - align-items: center; -`); - -const cssSmallSpinner = cssCenteredDiv; - -const cssSpinner = styled(cssCenteredDiv, ` - height: 200px; - min-width: 200px; -`); - -const cssBoldSubHeading = styled('div', ` - font-weight: bold; - margin-bottom: 16px; -`); - -const cssLightlyBoldedText = styled('span', ` - font-weight: 500; -`); - -const cssQRCode = styled('img', ` - width: 140px; - height: 140px; - margin-bottom: 16px; -`); - -const cssIcon = styled(icon, ` - width: 16px; - height: 16px; -`); - -const cssGreenIcon = styled(cssIcon, ` - background-color: ${colors.lightGreen}; -`); - -const cssModalButtons = styled(modalButtons, ` - margin: 16px 0 0 0; -`); diff --git a/app/common/UserAPI.ts b/app/common/UserAPI.ts index 19c94c40..50840d04 100644 --- a/app/common/UserAPI.ts +++ b/app/common/UserAPI.ts @@ -272,57 +272,6 @@ export interface DocStateComparisonDetails { rightChanges: ActionSummary; } -/** - * User multi-factor authentication preferences, as fetched from Cognito. - */ -export interface UserMFAPreferences { - isSmsMfaEnabled: boolean; - // If SMS MFA is enabled, the destination number for receiving verification codes. - phoneNumber?: string; - isSoftwareTokenMfaEnabled: boolean; -} - -/** - * Cognito response to initiating software token MFA registration. - */ -export interface SoftwareTokenRegistrationInfo { - secretCode: string; -} - -/** - * Cognito response to initiating SMS MFA registration. - */ -export interface SMSRegistrationInfo { - deliveryDestination: string; -} - -/** - * Cognito response to verifying a password (e.g. in a security verification form). - */ -export type PassVerificationResult = ChallengeRequired | ChallengeNotRequired; - -/** - * Information about the follow-up authentication challenge. - */ -export interface ChallengeRequired { - isChallengeRequired: true; - isAlternateChallengeAvailable: boolean; - // Session identifier that must be re-used in response to auth challenge. - session: string; - challengeName: 'SMS_MFA' | 'SOFTWARE_TOKEN_MFA'; - // If SMS MFA is enabled, the destination phone number that codes are sent to. - deliveryDestination?: string; -} - -/** - * Successful authentication, with no additional challenge required. - */ -interface ChallengeNotRequired { - isChallengeRequired: false; -} - -export type AuthMethod = 'TOTP' | 'SMS'; - export {UserProfile} from 'app/common/LoginSessionAPI'; export interface UserAPI { @@ -361,7 +310,6 @@ export interface UserAPI { unpinDoc(docId: string): Promise; moveDoc(docId: string, workspaceId: number): Promise; getUserProfile(): Promise; - getUserMfaPreferences(): Promise; updateUserName(name: string): Promise; updateAllowGoogleLogin(allowGoogleLogin: boolean): Promise; getWorker(key: string): Promise; @@ -379,14 +327,6 @@ export interface UserAPI { onUploadProgress?: (ev: ProgressEvent) => void, }): Promise; deleteUser(userId: number, name: string): Promise; - registerSoftwareToken(): Promise; - confirmRegisterSoftwareToken(verificationCode: string): Promise; - unregisterSoftwareToken(): Promise; - registerSMS(phoneNumber: string): Promise; - confirmRegisterSMS(verificationCode: string): Promise; - unregisterSMS(): Promise; - verifyPassword(password: string, preferredMfaMethod?: AuthMethod): Promise; - verifySecondStep(authMethod: AuthMethod, verificationCode: string, session: string): Promise; getBaseUrl(): string; // Get the prefix for all the endpoints this object wraps. forRemoved(): UserAPI; // Get a version of the API that works on removed resources. getWidgets(): Promise; @@ -654,10 +594,6 @@ export class UserAPIImpl extends BaseAPI implements UserAPI { return this.requestJson(`${this._url}/api/profile/user`); } - public async getUserMfaPreferences(): Promise { - return this.requestJson(`${this._url}/api/profile/mfa_preferences`); - } - public async updateUserName(name: string): Promise { await this.request(`${this._url}/api/profile/user/name`, { method: 'POST', @@ -751,57 +687,6 @@ export class UserAPIImpl extends BaseAPI implements UserAPI { body: JSON.stringify({name})}); } - public async registerSoftwareToken(): Promise { - return this.requestJson(`${this._url}/api/auth/register_totp`, {method: 'POST'}); - } - - public async confirmRegisterSoftwareToken(verificationCode: string): Promise { - await this.request(`${this._url}/api/auth/confirm_register_totp`, { - method: 'POST', - body: JSON.stringify({verificationCode}), - }); - } - - public async unregisterSoftwareToken(): Promise { - await this.request(`${this._url}/api/auth/unregister_totp`, {method: 'POST'}); - } - - public async registerSMS(phoneNumber: string): Promise { - return this.requestJson(`${this._url}/api/auth/register_sms`, { - method: 'POST', - body: JSON.stringify({phoneNumber}), - }); - } - - public async confirmRegisterSMS(verificationCode: string): Promise { - await this.request(`${this._url}/api/auth/confirm_register_sms`, { - method: 'POST', - body: JSON.stringify({verificationCode}), - }); - } - - public async unregisterSMS(): Promise { - await this.request(`${this._url}/api/auth/unregister_sms`, {method: 'POST'}); - } - - public async verifyPassword(password: string, preferredMfaMethod?: AuthMethod): Promise { - return this.requestJson(`${this._url}/api/auth/verify_pass`, { - method: 'POST', - body: JSON.stringify({password, preferredMfaMethod}), - }); - } - - public async verifySecondStep( - authMethod: AuthMethod, - verificationCode: string, - session: string - ): Promise { - await this.request(`${this._url}/api/auth/verify_second_step`, { - method: 'POST', - body: JSON.stringify({authMethod, verificationCode, session}), - }); - } - public getBaseUrl(): string { return this._url; } // Recomputes the URL on every call to pick up changes in the URL when switching orgs. diff --git a/stubs/app/client/ui/ChangePasswordDialog.ts b/stubs/app/client/ui/ChangePasswordDialog.ts new file mode 100644 index 00000000..8f8b1797 --- /dev/null +++ b/stubs/app/client/ui/ChangePasswordDialog.ts @@ -0,0 +1,3 @@ +export function buildChangePasswordDialog() { + return null; +} diff --git a/stubs/app/client/ui/MFAConfig.ts b/stubs/app/client/ui/MFAConfig.ts new file mode 100644 index 00000000..2e3156e5 --- /dev/null +++ b/stubs/app/client/ui/MFAConfig.ts @@ -0,0 +1,8 @@ +import {FullUser} from 'app/common/UserAPI'; +import {Disposable} from 'grainjs'; + +export class MFAConfig extends Disposable { + constructor(_user: FullUser) { super(); } + + public buildDom() { return null; } +} diff --git a/stubs/app/tsconfig.json b/stubs/app/tsconfig.json index 6d8e95fc..0b99c195 100644 --- a/stubs/app/tsconfig.json +++ b/stubs/app/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../../buildtools/tsconfig-base.json", "references": [ + { "path": "../../app/client" }, { "path": "../../app/common" }, { "path": "../../app/server" } ]