(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
This commit is contained in:
George Gevoian 2022-03-16 19:32:17 -07:00
parent 7ba4dff18f
commit 0f4f0d3dad
9 changed files with 44 additions and 1204 deletions

View File

@ -1,4 +1,5 @@
import {reportError} from 'app/client/models/errors'; import {reportError} from 'app/client/models/errors';
import {ApiError} from 'app/common/ApiError';
import {BaseAPI} from 'app/common/BaseAPI'; import {BaseAPI} from 'app/common/BaseAPI';
import {dom, Observable} from 'grainjs'; 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<any> { export async function submitForm(fields: { [key: string]: string }, form: HTMLFormElement): Promise<any> {
return BaseAPI.requestJson(form.action, {method: 'POST', body: JSON.stringify(fields)}); 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<string|null>) {
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);
}
}

View File

@ -84,13 +84,6 @@ export function getLoginOrSignupUrl(nextUrl: string = _getCurrentUrl()): string
return _getLoginLogoutUrl('signin', nextUrl); 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. // Returns the URL for the "you are signed out" page.
export function getSignedOutUrl(): string { return getMainOrgUrl() + "signed-out"; } export function getSignedOutUrl(): string { return getMainOrgUrl() + "signed-out"; }

View File

@ -1,7 +1,10 @@
{ {
"extends": "../../buildtools/tsconfig-base.json", "extends": "../../buildtools/tsconfig-base.json",
"include": [
"**/*",
"../../stubs/app/client/**/*"
],
"references": [ "references": [
{ "path": "../common" }, { "path": "../common" },
{ "path": "../../stubs/app" },
] ]
} }

View File

@ -1,19 +1,18 @@
import {AppModel, reportError} from 'app/client/models/AppModel'; 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 {ApiKey} from 'app/client/ui/ApiKey';
import {AppHeader} from 'app/client/ui/AppHeader'; import {AppHeader} from 'app/client/ui/AppHeader';
import {buildChangePasswordDialog} from 'app/client/ui/ChangePasswordDialog';
import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon'; import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon';
import {MFAConfig} from 'app/client/ui/MFAConfig'; import {MFAConfig} from 'app/client/ui/MFAConfig';
import {pagePanels} from 'app/client/ui/PagePanels'; import {pagePanels} from 'app/client/ui/PagePanels';
import {createTopBarHome} from 'app/client/ui/TopBar'; import {createTopBarHome} from 'app/client/ui/TopBar';
import {transientInput} from 'app/client/ui/transientInput'; 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 {cssBreadcrumbs, cssBreadcrumbsLink, separator} from 'app/client/ui2018/breadcrumbs';
import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox'; import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
import {icon} from 'app/client/ui2018/icons'; 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 {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'; import {Computed, Disposable, dom, domComputed, makeTestId, Observable, styled} from 'grainjs';
const testId = makeTestId('test-account-page-'); const testId = makeTestId('test-account-page-');
@ -24,7 +23,6 @@ const testId = makeTestId('test-account-page-');
export class AccountPage extends Disposable { export class AccountPage extends Disposable {
private _apiKey = Observable.create<string>(this, ''); private _apiKey = Observable.create<string>(this, '');
private _userObs = Observable.create<FullUser|null>(this, null); private _userObs = Observable.create<FullUser|null>(this, null);
private _userMfaPreferences = Observable.create<UserMFAPreferences|null>(this, null);
private _isEditingName = Observable.create(this, false); private _isEditingName = Observable.create(this, false);
private _nameEdit = Observable.create<string>(this, ''); private _nameEdit = Observable.create<string>(this, '');
private _isNameValid = Computed.create(this, this._nameEdit, (_use, val) => checkName(val)); private _isNameValid = Computed.create(this, this._nameEdit, (_use, val) => checkName(val));
@ -93,9 +91,8 @@ export class AccountPage extends Disposable {
cssDataRow( cssDataRow(
cssSubHeader('Login Method'), cssSubHeader('Login Method'),
cssLoginMethod(user.loginMethod), cssLoginMethod(user.loginMethod),
user.loginMethod === 'Email + Password' ? cssTextBtn( user.loginMethod === 'Email + Password' ? cssTextBtn('Change Password',
cssIcon('Settings'), 'Reset', dom.on('click', () => this._showChangePasswordDialog()),
dom.on('click', () => confirmPwdResetModal(user.email)),
) : null, ) : null,
testId('login-method'), 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 " + "to ensure that you're the only person who can access your account, even if someone " +
"knows your password." "knows your password."
), ),
dom.create(MFAConfig, this._userMfaPreferences, { dom.create(MFAConfig, user),
appModel: this._appModel,
onChange: () => this._fetchUserMfaPreferences(),
}),
), ),
cssHeader('API'), cssHeader('API'),
cssDataRow(cssSubHeader('API Key'), cssContent( cssDataRow(cssSubHeader('API Key'), cssContent(
@ -166,21 +160,11 @@ export class AccountPage extends Disposable {
this._userObs.set(await this._appModel.api.getUserProfile()); 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() { private async _fetchAll() {
await Promise.all([ await Promise.all([
this._fetchApiKey(), this._fetchApiKey(),
this._fetchUserProfile(), this._fetchUserProfile(),
]); ]);
const user = this._userObs.get();
if (user?.loginMethod === 'Email + Password') {
await this._fetchUserMfaPreferences();
}
} }
private async _updateUserName(val: string) { private async _updateUserName(val: string) {
@ -195,26 +179,10 @@ export class AccountPage extends Disposable {
await this._appModel.api.updateAllowGoogleLogin(allowGoogleLogin); await this._appModel.api.updateAllowGoogleLogin(allowGoogleLogin);
await this._fetchUserProfile(); await this._fetchUserProfile();
} }
}
function confirmPwdResetModal(userEmail: string) { private _showChangePasswordDialog() {
return modal((ctl, _owner) => { return buildChangePasswordDialog();
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()),
),
),
];
});
} }
/** /**
@ -292,7 +260,7 @@ const cssTextBtn = styled('button', `
border: none; border: none;
padding: 0; padding: 0;
text-align: left; text-align: left;
min-width: 90px; min-width: 110px;
&:hover { &:hover {
color: ${colors.darkGreen}; color: ${colors.darkGreen};

File diff suppressed because it is too large Load Diff

View File

@ -272,57 +272,6 @@ export interface DocStateComparisonDetails {
rightChanges: ActionSummary; 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 {UserProfile} from 'app/common/LoginSessionAPI';
export interface UserAPI { export interface UserAPI {
@ -361,7 +310,6 @@ export interface UserAPI {
unpinDoc(docId: string): Promise<void>; unpinDoc(docId: string): Promise<void>;
moveDoc(docId: string, workspaceId: number): Promise<void>; moveDoc(docId: string, workspaceId: number): Promise<void>;
getUserProfile(): Promise<FullUser>; getUserProfile(): Promise<FullUser>;
getUserMfaPreferences(): Promise<UserMFAPreferences>;
updateUserName(name: string): Promise<void>; updateUserName(name: string): Promise<void>;
updateAllowGoogleLogin(allowGoogleLogin: boolean): Promise<void>; updateAllowGoogleLogin(allowGoogleLogin: boolean): Promise<void>;
getWorker(key: string): Promise<string>; getWorker(key: string): Promise<string>;
@ -379,14 +327,6 @@ export interface UserAPI {
onUploadProgress?: (ev: ProgressEvent) => void, onUploadProgress?: (ev: ProgressEvent) => void,
}): Promise<string>; }): Promise<string>;
deleteUser(userId: number, name: string): Promise<void>; deleteUser(userId: number, name: string): Promise<void>;
registerSoftwareToken(): Promise<SoftwareTokenRegistrationInfo>;
confirmRegisterSoftwareToken(verificationCode: string): Promise<void>;
unregisterSoftwareToken(): Promise<void>;
registerSMS(phoneNumber: string): Promise<SMSRegistrationInfo>;
confirmRegisterSMS(verificationCode: string): Promise<void>;
unregisterSMS(): Promise<void>;
verifyPassword(password: string, preferredMfaMethod?: AuthMethod): Promise<PassVerificationResult>;
verifySecondStep(authMethod: AuthMethod, verificationCode: string, session: string): Promise<void>;
getBaseUrl(): string; // Get the prefix for all the endpoints this object wraps. 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. forRemoved(): UserAPI; // Get a version of the API that works on removed resources.
getWidgets(): Promise<ICustomWidget[]>; getWidgets(): Promise<ICustomWidget[]>;
@ -654,10 +594,6 @@ export class UserAPIImpl extends BaseAPI implements UserAPI {
return this.requestJson(`${this._url}/api/profile/user`); return this.requestJson(`${this._url}/api/profile/user`);
} }
public async getUserMfaPreferences(): Promise<UserMFAPreferences> {
return this.requestJson(`${this._url}/api/profile/mfa_preferences`);
}
public async updateUserName(name: string): Promise<void> { public async updateUserName(name: string): Promise<void> {
await this.request(`${this._url}/api/profile/user/name`, { await this.request(`${this._url}/api/profile/user/name`, {
method: 'POST', method: 'POST',
@ -751,57 +687,6 @@ export class UserAPIImpl extends BaseAPI implements UserAPI {
body: JSON.stringify({name})}); body: JSON.stringify({name})});
} }
public async registerSoftwareToken(): Promise<SoftwareTokenRegistrationInfo> {
return this.requestJson(`${this._url}/api/auth/register_totp`, {method: 'POST'});
}
public async confirmRegisterSoftwareToken(verificationCode: string): Promise<void> {
await this.request(`${this._url}/api/auth/confirm_register_totp`, {
method: 'POST',
body: JSON.stringify({verificationCode}),
});
}
public async unregisterSoftwareToken(): Promise<void> {
await this.request(`${this._url}/api/auth/unregister_totp`, {method: 'POST'});
}
public async registerSMS(phoneNumber: string): Promise<SMSRegistrationInfo> {
return this.requestJson(`${this._url}/api/auth/register_sms`, {
method: 'POST',
body: JSON.stringify({phoneNumber}),
});
}
public async confirmRegisterSMS(verificationCode: string): Promise<void> {
await this.request(`${this._url}/api/auth/confirm_register_sms`, {
method: 'POST',
body: JSON.stringify({verificationCode}),
});
}
public async unregisterSMS(): Promise<void> {
await this.request(`${this._url}/api/auth/unregister_sms`, {method: 'POST'});
}
public async verifyPassword(password: string, preferredMfaMethod?: AuthMethod): Promise<any> {
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<void> {
await this.request(`${this._url}/api/auth/verify_second_step`, {
method: 'POST',
body: JSON.stringify({authMethod, verificationCode, session}),
});
}
public getBaseUrl(): string { return this._url; } public getBaseUrl(): string { return this._url; }
// Recomputes the URL on every call to pick up changes in the URL when switching orgs. // Recomputes the URL on every call to pick up changes in the URL when switching orgs.

View File

@ -0,0 +1,3 @@
export function buildChangePasswordDialog() {
return null;
}

View File

@ -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; }
}

View File

@ -1,6 +1,7 @@
{ {
"extends": "../../buildtools/tsconfig-base.json", "extends": "../../buildtools/tsconfig-base.json",
"references": [ "references": [
{ "path": "../../app/client" },
{ "path": "../../app/common" }, { "path": "../../app/common" },
{ "path": "../../app/server" } { "path": "../../app/server" }
] ]