gristlabs_grist-core/app/client/ui/MFAConfig.ts
George Gevoian ba6ecc5e9e (core) Move user profile to new page and begin MFA work
Summary:
The user profile dialog is now a separate page, in preparation
for upcoming work to enable MFA. This commit also contains
some MFA changes, but the UI is currently disabled and the
implementation is limited to software tokens (TOTP) only.

Test Plan:
Updated browser tests for new profile page. Tests for MFAConfig
and CognitoClient will be added in a later diff, once the UI is enabled.

Reviewers: paulfitz

Reviewed By: paulfitz

Subscribers: dsagal

Differential Revision: https://phab.getgrist.com/D3199
2022-01-13 21:21:49 -08:00

626 lines
21 KiB
TypeScript

import {submitForm} from 'app/client/lib/uploads';
import {AppModel} from 'app/client/models/AppModel';
import {reportError, reportSuccess} from 'app/client/models/errors';
import {getMainOrgUrl} from 'app/client/models/gristUrlState';
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, IModalControl, modal,
cssModalButtons as modalButtons} from 'app/client/ui2018/modals';
import {ApiError} from 'app/common/ApiError';
import {FullUser} from 'app/common/LoginSessionAPI';
import {UserMFAPreferences} from 'app/common/UserAPI';
import {Disposable, dom, input, makeTestId, Observable, styled} from 'grainjs';
import {toDataURL} from 'qrcode';
const testId = makeTestId('test-mfa-');
type AuthMethod =
| 'SOFTWARE_TOKEN';
/**
* Step in the dialog flow for enabling a MFA method.
*/
type EnableAuthMethodStep =
| 'verify-password'
| 'choose-auth-method'
| 'configure-auth-app';
/**
* Step in the dialog flow for disabling a MFA method.
*/
type DisableAuthMethodStep =
| 'confirm-disable'
| 'verify-password'
| 'disable-method';
interface MFAConfigOptions {
appModel: AppModel;
user: FullUser;
}
/**
* Shows information about multi-factor authentication preferences for the logged-in user
* and buttons for enabling/disabling them.
*
* Currently supports software tokens only.
*/
export class MFAConfig extends Disposable {
private _appModel: AppModel;
private _user: FullUser;
constructor(
private _mfaPrefs: Observable<UserMFAPreferences|null>,
options: MFAConfigOptions
) {
super();
this._appModel = options.appModel;
this._user = options.user;
}
public buildDom() {
return this._buildButtons();
}
private _buildButtons() {
return dom.maybe(this._mfaPrefs, mfaPrefs => {
const {isSmsMfaEnabled, isSoftwareTokenMfaEnabled} = mfaPrefs;
return cssContainer(
!isSmsMfaEnabled && !isSoftwareTokenMfaEnabled ?
cssTextBtn(
'Enable two-factor authentication',
dom.on('click', () => this._showAddAuthMethodModal()),
testId('enable-2fa'),
) :
dom.frag(
isSoftwareTokenMfaEnabled ?
cssDataRow(
cssIconAndText(cssIcon('BarcodeQR'), cssText('Authenticator app')),
cssTextBtn(
'Disable',
dom.on('click', () => this._showDisableAuthMethodModal('SOFTWARE_TOKEN')),
testId('disable-auth-app'),
)
) :
cssTextBtn(
'Add an authenticator app',
dom.on('click', () => this._showAddAuthMethodModal('SOFTWARE_TOKEN')),
testId('add-auth-app'),
),
),
testId('container'),
);
});
}
/**
* Displays a modal that allows users to enable a MFA method for their account.
*
* @param {AuthMethod} method If specified, skips the 'choose-auth-method' step.
*/
private _showAddAuthMethodModal(method?: AuthMethod): void {
return modal((ctl, owner) => {
const selectedAuthMethod = Observable.create(owner, method ?? null);
const currentStep = Observable.create<EnableAuthMethodStep>(owner, 'verify-password');
return [
dom.domComputed((use) => {
const step = use(currentStep);
switch (step) {
case 'verify-password': {
return [
this._buildSecurityVerificationForm({onSuccess: async () => {
currentStep.set('choose-auth-method');
}}),
cssTextBtn('← Back', dom.on('click', () => { ctl.close(); })),
];
}
case 'choose-auth-method': {
return [
cssModalTitle('Two-factor authentication'),
cssModalBody(
cssText(
"Once you enable two step verification, 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'),
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', () => {
selectedAuthMethod.set('SOFTWARE_TOKEN');
currentStep.set('configure-auth-app');
}),
),
)
),
];
}
case 'configure-auth-app': {
return [
this._buildConfigureAuthAppForm(ctl, {onSuccess: async () => {
ctl.close();
reportSuccess('Two-factor authentication enabled');
this._mfaPrefs.set({...this._mfaPrefs.get()!, isSoftwareTokenMfaEnabled: true});
}}),
cssTextBtn('← Back to methods', dom.on('click', () => { currentStep.set('choose-auth-method'); })),
];
}
}
}),
cssModal.cls(''),
];
});
}
/**
* Displays a modal that allows users to disable a MFA method for their account.
*
* @param {AuthMethod} method The auth method to disable. Currently unused, until additional methods are added.
*/
private _showDisableAuthMethodModal(method: AuthMethod): void {
return modal((ctl, owner) => {
const currentStep = Observable.create<DisableAuthMethodStep>(owner, 'confirm-disable');
return [
dom.domComputed((use) => {
const step = use(currentStep);
switch (step) {
case 'confirm-disable': {
return [
cssModalTitle('Disable authenticator app?'),
cssModalBody(
cssText(
"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('Confirm', dom.on('click', () => { currentStep.set('verify-password'); })),
bigBasicButton('Cancel', dom.on('click', () => ctl.close())),
),
),
];
}
case 'verify-password': {
return [
this._buildSecurityVerificationForm({onSuccess: () => currentStep.set('disable-method')}),
cssTextBtn('← Back', dom.on('click', () => { currentStep.set('confirm-disable'); })),
];
}
case 'disable-method': {
this._unregisterSoftwareToken()
.then(() => {
reportSuccess('Authenticator app disabled');
this._mfaPrefs.set({...this._mfaPrefs.get()!, isSoftwareTokenMfaEnabled: false});
})
.catch(reportError)
.finally(() => ctl.close());
return cssLoadingSpinner(loadingSpinner());
}
}
}),
cssModal.cls(''),
];
});
}
/**
* 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({onSuccess}: {onSuccess: () => void}) {
const securityStep = Observable.create<'password' | 'verification-code'>(null, 'password');
const pending = Observable.create(null, false);
const session = Observable.create(null, '');
return [
dom.autoDispose(securityStep),
dom.autoDispose(session),
dom.autoDispose(pending),
dom.domComputed(securityStep, (step) => {
switch (step) {
case 'password': {
const verifyPasswordUrl = getMainOrgUrl() + 'api/auth/verify_pass';
const password = Observable.create(null, '');
let passwordElement: HTMLInputElement;
setTimeout(() => passwordElement.focus(), 10);
const error: Observable<string|null> = Observable.create(null, null);
const errorListener = pending.addListener(isPending => isPending && error.set(null));
return dom.frag(
dom.autoDispose(password),
dom.autoDispose(error),
dom.autoDispose(errorListener),
cssModalTitle('Confirm your password'),
cssModalBody(
dom('form',
{method: 'post', action: verifyPasswordUrl},
handleSubmit(pending,
(result) => {
if (!result.isChallengeRequired) { return onSuccess(); }
session.set(result.session);
securityStep.set('verification-code');
},
(err) => {
if (isUserError(err)) {
error.set(err.details?.userError ?? err.message);
} else {
reportError(err as Error|string);
}
},
),
cssConfirmText('Please confirm your password to continue.'),
cssBoldSubHeading('Password'),
passwordElement = cssInput(password,
{onInput: true},
{name: 'password', placeholder: 'password', type: 'password'},
),
cssFormError(dom.text(use => use(error) ?? '')),
cssModalButtons(
bigPrimaryButton('Confirm',
dom.boolAttr('disabled', use => use(pending) || use(password).trim().length === 0),
),
),
),
),
);
}
case 'verification-code': {
const verifyAuthCodeUrl = getMainOrgUrl() + 'api/auth/verify_totp';
const authCode = Observable.create(null, '');
const error: Observable<string|null> = Observable.create(null, null);
const errorListener = pending.addListener(isPending => isPending && error.set(null));
return dom.frag(
dom.autoDispose(authCode),
dom.autoDispose(error),
dom.autoDispose(errorListener),
cssModalTitle('Almost there!'),
cssModalBody(
dom('form',
{method: 'post', action: verifyAuthCodeUrl},
handleSubmit(pending,
() => onSuccess(),
(err) => {
if (isUserError(err)) {
error.set(err.details?.userError ?? err.message);
} else {
reportError(err as Error|string);
}
},
),
cssConfirmText('Enter the authentication code generated by your app to confirm your account.'),
cssBoldSubHeading('Verification Code '),
cssCodeInput(authCode, {onInput: true}, {name: 'verificationCode', type: 'number'}),
cssFormError(dom.text(use => use(error) ?? '')),
cssInput(session, {onInput: true}, {name: 'session', type: 'hidden'}),
cssModalButtons(
bigPrimaryButton('Submit',
dom.boolAttr('disabled', use => use(pending) || use(authCode).trim().length !== 6),
),
),
),
),
);
}
}
}),
];
}
/**
* 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}) {
const confirmCodeUrl = getMainOrgUrl() + 'api/auth/confirm_totp_registration';
const qrCode: Observable<string|null> = Observable.create(null, null);
const verificationCode = Observable.create(null, '');
const pending = Observable.create(null, false);
const error: Observable<string|null> = Observable.create(null, null);
const errorListener = pending.addListener(isPending => isPending && error.set(null));
this._getSoftwareTokenQRCode()
.then(code => qrCode.isDisposed() || qrCode.set(code))
.catch(e => { ctl.close(); reportError(e); });
return [
dom.autoDispose(qrCode),
dom.autoDispose(verificationCode),
dom.autoDispose(pending),
dom.autoDispose(error),
dom.autoDispose(errorListener),
dom.domComputed(qrCode, code => {
if (code === null) { return cssLoadingSpinner(loadingSpinner()); }
return [
cssModalTitle('Configure authenticator app'),
cssModalBody(
cssModalBody(
cssConfigureAuthAppDesc(
"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',
}),
".",
),
cssConfigureAuthAppSubHeading('To configure your authenticator app:'),
cssConfigureAuthAppStep('1. Add a new account'),
cssConfigureAuthAppStep('2. Scan the following QR code', {style: 'margin-bottom: 0px'}),
cssQRCode({src: code}),
cssConfigureAuthAppStep('3. Enter the verification code that appears after scanning the QR code'),
dom('form',
{method: 'post', action: confirmCodeUrl},
handleSubmit(pending,
() => onSuccess(),
(err) => {
if (isUserError(err)) {
error.set(err.details?.userError ?? err.message);
} else {
reportError(err as Error|string);
}
},
),
cssBoldSubHeading('Authentication code'),
cssCodeInput(verificationCode, {onInput: true}, {name: 'userCode', type: 'number'}),
cssFormError(dom.text(use => use(error) ?? '')),
cssModalButtons(
bigPrimaryButton('Verify',
dom.boolAttr('disabled', use => use(pending) || use(verificationCode).trim().length !== 6),
),
bigBasicButton('Cancel', dom.on('click', () => ctl.close())),
),
),
),
),
];
}),
];
}
private async _registerSoftwareToken() {
return await this._appModel.api.registerSoftwareToken();
}
private async _unregisterSoftwareToken() {
return await this._appModel.api.unregisterSoftwareToken();
}
/**
* 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;
}
}
/**
* Helper function that handles form submissions. Sets `pending` to true after
* submitting, and resets it to false after submission completes.
*
* Callback functions `onSuccess` and `onError` handle post-submission logic.
*/
function handleSubmit(pending: Observable<boolean>,
onSuccess: (v: any) => void,
onError?: (e: unknown) => void
): (elem: HTMLFormElement) => void {
return dom.on('submit', async (e, form) => {
e.preventDefault();
await submit(form, pending, onSuccess, onError);
});
}
/**
* Submits an HTML form, and forwards responses and errors to `onSuccess` and `onError` respectively.
*/
async function submit(form: HTMLFormElement, pending: Observable<boolean>,
onSuccess: (v: any) => void,
onError: (e: unknown) => void = (e) => reportError(e as string|Error)
) {
try {
if (pending.get()) { return; }
pending.set(true);
const result = await submitForm(form).finally(() => pending.set(false));
onSuccess(result);
} catch (err) {
onError(err);
}
}
/**
* Returns true if `error` is an API error with a 4XX status code.
*
* Used to determine which errors should be shown in-line in forms.
*/
function isUserError(error: unknown): error is ApiError {
if (!(error instanceof ApiError)) { return false; }
return error.status >= 400 && error.status < 500;
}
const cssContainer = styled('div', `
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 cssConfirmText = styled(cssText, `
margin-bottom: 32px;
`);
const cssFormError = styled('div', `
color: red;
min-height: 20px;
margin-top: 16px;
`);
const cssConfigureAuthAppDesc = styled(cssText, `
margin-bottom: 32px;
`);
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 cssAuthMethods = styled('div', `
display: flex;
flex-direction: column;
margin-top: 16px;
gap: 8px;
`);
const cssAuthMethod = styled('div', `
border: 1px solid ${colors.mediumGreyOpaque};
cursor: pointer;
&:hover {
border: 1px solid ${colors.slate};
}
`);
const cssAuthMethodTitle = styled(cssIconAndText, `
font-size: ${vars.mediumFontSize};
font-weight: bold;
color: ${colors.lightGreen};
margin: 16px;
`);
const cssAuthMethodDesc = styled('div', `
color: #8a8a8a;
padding-left: 28px;
margin: 16px;
`);
const cssInput = styled(input, `
margin-top: 16px;
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 cssModal = styled('div', `
width: 600px;
`);
const cssLoadingSpinner = styled('div', `
height: 200px;
display: flex;
justify-content: center;
align-items: center;
`);
const cssBoldSubHeading = styled('div', `
font-weight: bold;
`);
const cssConfigureAuthAppSubHeading = styled(cssBoldSubHeading, `
margin-bottom: 16px;
`);
const cssConfigureAuthAppStep = styled(cssText, `
margin-bottom: 16px;
`);
const cssQRCode = styled('img', `
width: 140px;
height: 140px;
`);
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;
`);