Setup flow for cross-signing on login / registration

Still outstanding:
 * Keep password from login / registration
 * Confirmation on skip button

Fixes https://github.com/vector-im/riot-web/issues/11902
This commit is contained in:
David Baker 2020-01-24 19:11:57 +00:00
parent 9722b34c35
commit 3d7137d4ad
9 changed files with 159 additions and 25 deletions

View file

@ -386,7 +386,13 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus {
text-align: right; text-align: right;
} }
.mx_Dialog button, .mx_Dialog input[type="submit"] { /* XXX: Our button style are a mess: buttons that happen to appear in dialogs get special styles applied
* to them that no button anywhere else in the app gets by default. In practice, buttons in other places
* in the app look the same by being AccessibleButtons, or possibly by having explict button classes.
* We should go through and have one consistent set of styles for buttons throughout the app.
* For now, I am duplicating the selectors here for mx_Dialog and mx_DialogButtons.
*/
.mx_Dialog button, .mx_Dialog input[type="submit"], .mx_Dialog_buttons button, .mx_Dialog_buttons input[type="submit"] {
@mixin mx_DialogButton; @mixin mx_DialogButton;
margin-left: 0px; margin-left: 0px;
margin-right: 8px; margin-right: 8px;
@ -402,27 +408,27 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus {
margin-right: 0px; margin-right: 0px;
} }
.mx_Dialog button:hover, .mx_Dialog input[type="submit"]:hover { .mx_Dialog button:hover, .mx_Dialog input[type="submit"]:hover, .mx_Dialog_buttons button:hover, .mx_Dialog_buttons input[type="submit"]:hover {
@mixin mx_DialogButton_hover; @mixin mx_DialogButton_hover;
} }
.mx_Dialog button:focus, .mx_Dialog input[type="submit"]:focus { .mx_Dialog button:focus, .mx_Dialog input[type="submit"]:focus, .mx_Dialog_buttons button:focus, .mx_Dialog_buttons input[type="submit"]:focus {
filter: brightness($focus-brightness); filter: brightness($focus-brightness);
} }
.mx_Dialog button.mx_Dialog_primary, .mx_Dialog input[type="submit"].mx_Dialog_primary { .mx_Dialog button.mx_Dialog_primary, .mx_Dialog input[type="submit"].mx_Dialog_primary, .mx_Dialog_buttons button.mx_Dialog_primary, .mx_Dialog_buttons input[type="submit"].mx_Dialog_primary {
color: $accent-fg-color; color: $accent-fg-color;
background-color: $accent-color; background-color: $accent-color;
min-width: 156px; min-width: 156px;
} }
.mx_Dialog button.danger, .mx_Dialog input[type="submit"].danger { .mx_Dialog button.danger, .mx_Dialog input[type="submit"].danger, .mx_Dialog_buttons button.danger, .mx_Dialog_buttons input[type="submit"].danger {
background-color: $warning-color; background-color: $warning-color;
border: solid 1px $warning-color; border: solid 1px $warning-color;
color: $accent-fg-color; color: $accent-fg-color;
} }
.mx_Dialog button:disabled, .mx_Dialog input[type="submit"]:disabled { .mx_Dialog button:disabled, .mx_Dialog input[type="submit"]:disabled, .mx_Dialog_buttons button:disabled, .mx_Dialog_buttons input[type="submit"]:disabled {
background-color: $light-fg-color; background-color: $light-fg-color;
border: solid 1px $light-fg-color; border: solid 1px $light-fg-color;
opacity: 0.7; opacity: 0.7;

View file

@ -15,13 +15,10 @@ limitations under the License.
*/ */
.mx_AuthBody { .mx_AuthBody {
width: 500px;
background-color: $authpage-body-bg-color; background-color: $authpage-body-bg-color;
border-radius: 0 4px 4px 0; border-radius: 0 4px 4px 0;
padding: 25px 60px; padding: 25px 60px;
box-sizing: border-box; box-sizing: border-box;
font-size: 12px;
color: $authpage-secondary-color;
h2 { h2 {
font-size: 24px; font-size: 24px;
@ -99,6 +96,12 @@ limitations under the License.
border-radius: 4px; border-radius: 4px;
} }
.mx_AuthBody_loginRegister {
width: 500px;
font-size: 12px;
color: $authpage-secondary-color;
}
.mx_AuthBody_editServerDetails { .mx_AuthBody_editServerDetails {
padding-left: 1em; padding-left: 1em;
font-size: 12px; font-size: 12px;

View file

@ -78,6 +78,10 @@ limitations under the License.
align-items: center; align-items: center;
} }
.mx_CreateSecretStorageDialog_recoveryKeyButtons .mx_AccessibleButton {
margin-right: 10px;
}
.mx_CreateSecretStorageDialog_recoveryKeyButtons button { .mx_CreateSecretStorageDialog_recoveryKeyButtons button {
flex: 1; flex: 1;
white-space: nowrap; white-space: nowrap;

View file

@ -16,6 +16,7 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../../index'; import * as sdk from '../../../../index';
import {MatrixClientPeg} from '../../../../MatrixClientPeg'; import {MatrixClientPeg} from '../../../../MatrixClientPeg';
import { scorePassword } from '../../../../utils/PasswordScorer'; import { scorePassword } from '../../../../utils/PasswordScorer';
@ -52,6 +53,14 @@ function selectText(target) {
* Secret Storage in account data. * Secret Storage in account data.
*/ */
export default class CreateSecretStorageDialog extends React.PureComponent { export default class CreateSecretStorageDialog extends React.PureComponent {
static propTypes = {
hasCancel: PropTypes.bool,
};
defaultProps = {
hasCancel: true,
};
constructor(props) { constructor(props) {
super(props); super(props);
@ -82,9 +91,12 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
this._fetchBackupInfo(); this._fetchBackupInfo();
this._queryKeyUploadAuth(); this._queryKeyUploadAuth();
MatrixClientPeg.get().on('crypto.keyBackupStatus', this._onKeyBackupStatusChange);
} }
componentWillUnmount() { componentWillUnmount() {
MatrixClientPeg.get().removeListener('crypto.keyBackupStatus', this._onKeyBackupStatusChange);
if (this._setZxcvbnResultTimeout !== null) { if (this._setZxcvbnResultTimeout !== null) {
clearTimeout(this._setZxcvbnResultTimeout); clearTimeout(this._setZxcvbnResultTimeout);
} }
@ -92,7 +104,10 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
async _fetchBackupInfo() { async _fetchBackupInfo() {
const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion(); const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion();
const backupSigStatus = await MatrixClientPeg.get().isKeyBackupTrusted(backupInfo); const backupSigStatus = (
// we may not have started crypto yet, in which case we definitely don't trust the backup
MatrixClientPeg.get().isCryptoEnabled() && await MatrixClientPeg.get().isKeyBackupTrusted(backupInfo)
);
const phase = backupInfo ? const phase = backupInfo ?
(backupSigStatus.usable ? PHASE_MIGRATE : PHASE_RESTORE_KEY_BACKUP) : (backupSigStatus.usable ? PHASE_MIGRATE : PHASE_RESTORE_KEY_BACKUP) :
@ -127,6 +142,10 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
} }
} }
_onKeyBackupStatusChange = () => {
this._fetchBackupInfo();
}
_collectRecoveryKeyNode = (n) => { _collectRecoveryKeyNode = (n) => {
this._recoveryKeyNode = n; this._recoveryKeyNode = n;
} }
@ -229,7 +248,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
_onRestoreKeyBackupClick = () => { _onRestoreKeyBackupClick = () => {
const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog'); const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog');
Modal.createTrackedDialog( Modal.createTrackedDialog(
'Restore Backup', '', RestoreKeyBackupDialog, null, null, 'Restore Backup', '', RestoreKeyBackupDialog, {showSummary: false}, null,
/* priority = */ false, /* static = */ true, /* priority = */ false, /* static = */ true,
); );
} }
@ -411,6 +430,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
_renderPhasePassPhrase() { _renderPhasePassPhrase() {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const Field = sdk.getComponent('views.elements.Field'); const Field = sdk.getComponent('views.elements.Field');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let strengthMeter; let strengthMeter;
let helpText; let helpText;
@ -472,9 +492,9 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
<details> <details>
<summary>{_t("Advanced")}</summary> <summary>{_t("Advanced")}</summary>
<p><button onClick={this._onSkipPassPhraseClick} > <p><AccessibleButton kind='primary' onClick={this._onSkipPassPhraseClick} >
{_t("Set up with a recovery key")} {_t("Set up with a recovery key")}
</button></p> </AccessibleButton></p>
</details> </details>
</div>; </div>;
} }
@ -554,6 +574,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
); );
} }
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return <div> return <div>
<p>{_t( <p>{_t(
"Your recovery key is a safety net - you can use it to restore " + "Your recovery key is a safety net - you can use it to restore " +
@ -572,12 +593,12 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
<code ref={this._collectRecoveryKeyNode}>{this._encodedRecoveryKey}</code> <code ref={this._collectRecoveryKeyNode}>{this._encodedRecoveryKey}</code>
</div> </div>
<div className="mx_CreateSecretStorageDialog_recoveryKeyButtons"> <div className="mx_CreateSecretStorageDialog_recoveryKeyButtons">
<button className="mx_Dialog_primary" onClick={this._onCopyClick}> <AccessibleButton kind='primary' className="mx_Dialog_primary" onClick={this._onCopyClick}>
{_t("Copy to clipboard")} {_t("Copy to clipboard")}
</button> </AccessibleButton>
<button className="mx_Dialog_primary" onClick={this._onDownloadClick}> <AccessibleButton kind='primary' className="mx_Dialog_primary" onClick={this._onDownloadClick}>
{_t("Download")} {_t("Download")}
</button> </AccessibleButton>
</div> </div>
</div> </div>
</div> </div>
@ -740,7 +761,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
onFinished={this.props.onFinished} onFinished={this.props.onFinished}
title={this._titleForPhase(this.state.phase)} title={this._titleForPhase(this.state.phase)}
headerImage={headerImage} headerImage={headerImage}
hasCancel={[PHASE_PASSPHRASE].includes(this.state.phase)} hasCancel={this.props.hasCancel && [PHASE_PASSPHRASE].includes(this.state.phase)}
> >
<div> <div>
{content} {content}

View file

@ -89,12 +89,15 @@ export const VIEWS = {
// showing flow to trust this new device with cross-signing // showing flow to trust this new device with cross-signing
COMPLETE_SECURITY: 6, COMPLETE_SECURITY: 6,
// flow to setup SSSS / cross-signing on this account
E2E_SETUP: 7,
// we are logged in with an active matrix client. // we are logged in with an active matrix client.
LOGGED_IN: 7, LOGGED_IN: 8,
// We are logged out (invalid token) but have our local state again. The user // We are logged out (invalid token) but have our local state again. The user
// should log back in to rehydrate the client. // should log back in to rehydrate the client.
SOFT_LOGOUT: 8, SOFT_LOGOUT: 9,
}; };
// Actions that are redirected through the onboarding process prior to being // Actions that are redirected through the onboarding process prior to being
@ -657,7 +660,9 @@ export default createReactClass({
if ( if (
!Lifecycle.isSoftLogout() && !Lifecycle.isSoftLogout() &&
this.state.view !== VIEWS.LOGIN && this.state.view !== VIEWS.LOGIN &&
this.state.view !== VIEWS.COMPLETE_SECURITY this.state.view !== VIEWS.REGISTER &&
this.state.view !== VIEWS.COMPLETE_SECURITY &&
this.state.view !== VIEWS.E2E_SETUP
) { ) {
this._onLoggedIn(); this._onLoggedIn();
} }
@ -1724,6 +1729,11 @@ export default createReactClass({
this.showScreen("forgot_password"); this.showScreen("forgot_password");
}, },
onRegisterFlowComplete: function(credentials) {
this.onUserCompletedLoginFlow();
return this.onRegistered(credentials);
},
// returns a promise which resolves to the new MatrixClient // returns a promise which resolves to the new MatrixClient
onRegistered: function(credentials) { onRegistered: function(credentials) {
return Lifecycle.setLoggedIn(credentials); return Lifecycle.setLoggedIn(credentials);
@ -1847,12 +1857,18 @@ export default createReactClass({
if (masterKeyInStorage) { if (masterKeyInStorage) {
this.setStateForNewView({ view: VIEWS.COMPLETE_SECURITY }); this.setStateForNewView({ view: VIEWS.COMPLETE_SECURITY });
} else if (SettingsStore.isFeatureEnabled("feature_cross_signing")) {
// This will only work if the feature is set to 'enable' in the config,
// since it's too early in the lifecycle for users to have turned the
// labs flag on.
this.setStateForNewView({ view: VIEWS.E2E_SETUP });
} else { } else {
this._onLoggedIn(); this._onLoggedIn();
} }
}, },
onCompleteSecurityFinished() { // complete security / e2e setup has finished
onCompleteSecurityE2eSetupFinished() {
this._onLoggedIn(); this._onLoggedIn();
}, },
@ -1872,7 +1888,14 @@ export default createReactClass({
const CompleteSecurity = sdk.getComponent('structures.auth.CompleteSecurity'); const CompleteSecurity = sdk.getComponent('structures.auth.CompleteSecurity');
view = ( view = (
<CompleteSecurity <CompleteSecurity
onFinished={this.onCompleteSecurityFinished} onFinished={this.onCompleteSecurityE2eSetupFinished}
/>
);
} else if (this.state.view === VIEWS.E2E_SETUP) {
const E2eSetup = sdk.getComponent('structures.auth.E2eSetup');
view = (
<E2eSetup
onFinished={this.onCompleteSecurityE2eSetupFinished}
/> />
); );
} else if (this.state.view === VIEWS.POST_REGISTRATION) { } else if (this.state.view === VIEWS.POST_REGISTRATION) {
@ -1939,7 +1962,7 @@ export default createReactClass({
email={this.props.startingFragmentQueryParams.email} email={this.props.startingFragmentQueryParams.email}
brand={this.props.config.brand} brand={this.props.config.brand}
makeRegistrationUrl={this._makeRegistrationUrl} makeRegistrationUrl={this._makeRegistrationUrl}
onLoggedIn={this.onRegistered} onLoggedIn={this.onRegisterFlowComplete}
onLoginClick={this.onLoginClick} onLoginClick={this.onLoginClick}
onServerConfigChange={this.onServerConfigChange} onServerConfigChange={this.onServerConfigChange}
{...this.getServerProperties()} {...this.getServerProperties()}

View file

@ -766,7 +766,7 @@ export default createReactClass({
onUserVerificationChanged: function(userId, _trustStatus) { onUserVerificationChanged: function(userId, _trustStatus) {
const room = this.state.room; const room = this.state.room;
if (!room.currentState.getMember(userId)) { if (!room || !room.currentState.getMember(userId)) {
return; return;
} }
this._updateE2EStatus(room); this._updateE2EStatus(room);

View file

@ -0,0 +1,48 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import AsyncWrapper from '../../../AsyncWrapper';
import * as sdk from '../../../index';
export default class E2eSetup extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
};
constructor() {
super();
// awkwardly indented because https://github.com/eslint/eslint/issues/11310
this._createStorageDialogPromise =
import("../../../async-components/views/dialogs/secretstorage/CreateSecretStorageDialog");
}
render() {
const AuthPage = sdk.getComponent("auth.AuthPage");
const AuthBody = sdk.getComponent("auth.AuthBody");
return (
<AuthPage>
<AuthBody header={false}>
<AsyncWrapper prom={this._createStorageDialogPromise}
hasCancel={false}
onFinished={this.props.onFinished}
/>
</AuthBody>
</AuthPage>
);
}
}

View file

@ -33,6 +33,10 @@ export default class AuthBody extends React.PureComponent {
const classes = { const classes = {
'mx_AuthBody': true, 'mx_AuthBody': true,
'mx_AuthBody_noHeader': !this.props.header, 'mx_AuthBody_noHeader': !this.props.header,
// XXX The login pages all use a smaller fonts size but we don't want this
// for subsequent auth screens like the e2e setup. Doing this a terrible way
// for now.
'mx_AuthBody_loginRegister': this.props.header,
}; };
return <div className={classnames(classes)}> return <div className={classnames(classes)}>

View file

@ -16,6 +16,7 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../../index'; import * as sdk from '../../../../index';
import {MatrixClientPeg} from '../../../../MatrixClientPeg'; import {MatrixClientPeg} from '../../../../MatrixClientPeg';
import { MatrixClient } from 'matrix-js-sdk'; import { MatrixClient } from 'matrix-js-sdk';
@ -32,6 +33,16 @@ const RESTORE_TYPE_SECRET_STORAGE = 2;
* Dialog for restoring e2e keys from a backup and the user's recovery key * Dialog for restoring e2e keys from a backup and the user's recovery key
*/ */
export default class RestoreKeyBackupDialog extends React.PureComponent { export default class RestoreKeyBackupDialog extends React.PureComponent {
static propTypes = {
// if false, will close the dialog as soon as the restore completes succesfully
// default: true
showSummary: PropTypes.bool,
};
defaultProps = {
showSummary: true,
};
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
@ -96,6 +107,10 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
const recoverInfo = await MatrixClientPeg.get().restoreKeyBackupWithPassword( const recoverInfo = await MatrixClientPeg.get().restoreKeyBackupWithPassword(
this.state.passPhrase, undefined, undefined, this.state.backupInfo, this.state.passPhrase, undefined, undefined, this.state.backupInfo,
); );
if (!this.props.showSummary) {
this.props.onFinished(true);
return;
}
this.setState({ this.setState({
loading: false, loading: false,
recoverInfo, recoverInfo,
@ -119,6 +134,10 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
const recoverInfo = await MatrixClientPeg.get().restoreKeyBackupWithRecoveryKey( const recoverInfo = await MatrixClientPeg.get().restoreKeyBackupWithRecoveryKey(
this.state.recoveryKey, undefined, undefined, this.state.backupInfo, this.state.recoveryKey, undefined, undefined, this.state.backupInfo,
); );
if (!this.props.showSummary) {
this.props.onFinished(true);
return;
}
this.setState({ this.setState({
loading: false, loading: false,
recoverInfo, recoverInfo,
@ -253,6 +272,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
title = _t("Error"); title = _t("Error");
content = _t("No backup found!"); content = _t("No backup found!");
} else if (this.state.recoverInfo) { } else if (this.state.recoverInfo) {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
title = _t("Backup Restored"); title = _t("Backup Restored");
let failedToDecrypt; let failedToDecrypt;
if (this.state.recoverInfo.total > this.state.recoverInfo.imported) { if (this.state.recoverInfo.total > this.state.recoverInfo.imported) {
@ -264,6 +284,11 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
content = <div> content = <div>
<p>{_t("Restored %(sessionCount)s session keys", {sessionCount: this.state.recoverInfo.imported})}</p> <p>{_t("Restored %(sessionCount)s session keys", {sessionCount: this.state.recoverInfo.imported})}</p>
{failedToDecrypt} {failedToDecrypt}
<DialogButtons primaryButton={_t('OK')}
onPrimaryButtonClick={this._onDone}
hasCancel={false}
focus={true}
/>
</div>; </div>;
} else if (backupHasPassphrase && !this.state.forceRecoveryKey) { } else if (backupHasPassphrase && !this.state.forceRecoveryKey) {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons');