(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
pull/176/head
George Gevoian 2 years ago
parent 7ba4dff18f
commit 0f4f0d3dad

@ -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<any> {
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);
}
}

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

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

@ -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<string>(this, '');
private _userObs = Observable.create<FullUser|null>(this, null);
private _userMfaPreferences = Observable.create<UserMFAPreferences|null>(this, null);
private _isEditingName = Observable.create(this, false);
private _nameEdit = Observable.create<string>(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};

File diff suppressed because it is too large Load Diff

@ -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<void>;
moveDoc(docId: string, workspaceId: number): Promise<void>;
getUserProfile(): Promise<FullUser>;
getUserMfaPreferences(): Promise<UserMFAPreferences>;
updateUserName(name: string): Promise<void>;
updateAllowGoogleLogin(allowGoogleLogin: boolean): Promise<void>;
getWorker(key: string): Promise<string>;
@ -379,14 +327,6 @@ export interface UserAPI {
onUploadProgress?: (ev: ProgressEvent) => void,
}): Promise<string>;
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.
forRemoved(): UserAPI; // Get a version of the API that works on removed resources.
getWidgets(): Promise<ICustomWidget[]>;
@ -654,10 +594,6 @@ export class UserAPIImpl extends BaseAPI implements UserAPI {
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> {
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<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; }
// Recomputes the URL on every call to pick up changes in the URL when switching orgs.

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

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

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

Loading…
Cancel
Save