Allow user to control if they are signed out of all devices when changing password (#8259)

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Hugh Nimmo-Smith 2022-04-22 18:15:38 +01:00 committed by GitHub
parent ee2ee3c08c
commit bb4064ff43
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 157 additions and 76 deletions

View file

@ -19,6 +19,7 @@ limitations under the License.
import React from 'react';
import classNames from 'classnames';
import { logger } from "matrix-js-sdk/src/logger";
import { createClient } from "matrix-js-sdk/src/matrix";
import { _t, _td } from '../../../languageHandler';
import Modal from "../../../Modal";
@ -37,6 +38,7 @@ import AuthHeader from "../../views/auth/AuthHeader";
import AuthBody from "../../views/auth/AuthBody";
import PassphraseConfirmField from "../../views/auth/PassphraseConfirmField";
import AccessibleButton from '../../views/elements/AccessibleButton';
import StyledCheckbox from '../../views/elements/StyledCheckbox';
enum Phase {
// Show the forgot password inputs
@ -72,6 +74,9 @@ interface IState {
serverDeadError: string;
currentHttpRequest?: Promise<any>;
serverSupportsControlOfDevicesLogout: boolean;
logoutDevices: boolean;
}
enum ForgotPasswordField {
@ -97,11 +102,14 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
serverIsAlive: true,
serverErrorIsFatal: false,
serverDeadError: "",
serverSupportsControlOfDevicesLogout: false,
logoutDevices: false,
};
public componentDidMount() {
this.reset = null;
this.checkServerLiveliness(this.props.serverConfig);
this.checkServerCapabilities(this.props.serverConfig);
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
@ -112,6 +120,9 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
// Do a liveliness check on the new URLs
this.checkServerLiveliness(newProps.serverConfig);
// Do capabilities check on new URLs
this.checkServerCapabilities(newProps.serverConfig);
}
private async checkServerLiveliness(serverConfig): Promise<void> {
@ -129,12 +140,25 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
}
}
public submitPasswordReset(email: string, password: string): void {
private async checkServerCapabilities(serverConfig: ValidatedServerConfig): Promise<void> {
const tempClient = createClient({
baseUrl: serverConfig.hsUrl,
});
const serverSupportsControlOfDevicesLogout = await tempClient.doesServerSupportLogoutDevices();
this.setState({
logoutDevices: !serverSupportsControlOfDevicesLogout,
serverSupportsControlOfDevicesLogout,
});
}
public submitPasswordReset(email: string, password: string, logoutDevices = true): void {
this.setState({
phase: Phase.SendingEmail,
});
this.reset = new PasswordReset(this.props.serverConfig.hsUrl, this.props.serverConfig.isUrl);
this.reset.resetPassword(email, password).then(() => {
this.reset.resetPassword(email, password, logoutDevices).then(() => {
this.setState({
phase: Phase.EmailSent,
});
@ -174,24 +198,35 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
return;
}
Modal.createTrackedDialog('Forgot Password Warning', '', QuestionDialog, {
title: _t('Warning!'),
description:
<div>
{ _t(
"Changing your password will reset any end-to-end encryption keys " +
"on all of your sessions, making encrypted chat history unreadable. Set up " +
"Key Backup or export your room keys from another session before resetting your " +
"password.",
) }
</div>,
button: _t('Continue'),
onFinished: (confirmed) => {
if (confirmed) {
this.submitPasswordReset(this.state.email, this.state.password);
}
},
});
if (this.state.logoutDevices) {
const { finished } = Modal.createTrackedDialog<[boolean]>('Forgot Password Warning', '', QuestionDialog, {
title: _t('Warning!'),
description:
<div>
<p>{ !this.state.serverSupportsControlOfDevicesLogout ?
_t(
"Resetting your password on this homeserver will cause all of your devices to be " +
"signed out. This will delete the message encryption keys stored on them, " +
"making encrypted chat history unreadable.",
) :
_t(
"Signing out your devices will delete the message encryption keys stored on them, " +
"making encrypted chat history unreadable.",
)
}</p>
<p>{ _t(
"If you want to retain access to your chat history in encrypted rooms, set up Key Backup " +
"or export your message keys from one of your other devices before proceeding.",
) }</p>
</div>,
button: _t('Continue'),
});
const [confirmed] = await finished;
if (!confirmed) return;
}
this.submitPasswordReset(this.state.email, this.state.password, this.state.logoutDevices);
};
private async verifyFieldsBeforeSubmit() {
@ -316,6 +351,13 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
autoComplete="new-password"
/>
</div>
{ this.state.serverSupportsControlOfDevicesLogout ?
<div className="mx_AuthBody_fieldRow">
<StyledCheckbox onChange={() => this.setState({ logoutDevices: !this.state.logoutDevices })} checked={this.state.logoutDevices}>
{ _t("Sign out all devices") }
</StyledCheckbox>
</div> : null
}
<span>{ _t(
'A verification email will be sent to your inbox to confirm ' +
'setting your new password.',
@ -355,11 +397,14 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
renderDone() {
return <div>
<p>{ _t("Your password has been reset.") }</p>
<p>{ _t(
"You have been logged out of all sessions and will no longer receive " +
"push notifications. To re-enable notifications, sign in again on each " +
"device.",
) }</p>
{ this.state.logoutDevices ?
<p>{ _t(
"You have been logged out of all devices and will no longer receive " +
"push notifications. To re-enable notifications, sign in again on each " +
"device.",
) }</p>
: null
}
<input
className="mx_Login_submit"
type="button"

View file

@ -41,7 +41,11 @@ enum Phase {
}
interface IProps {
onFinished?: ({ didSetEmail: boolean }?) => void;
onFinished?: (outcome: {
didSetEmail?: boolean;
/** Was one or more other devices logged out whilst changing the password */
didLogoutOutOtherDevices: boolean;
}) => void;
onError?: (error: {error: string}) => void;
rowClassName?: string;
buttonClassName?: string;
@ -82,48 +86,58 @@ export default class ChangePassword extends React.Component<IProps, IState> {
};
}
private onChangePassword(oldPassword: string, newPassword: string): void {
private async onChangePassword(oldPassword: string, newPassword: string): Promise<void> {
const cli = MatrixClientPeg.get();
if (!this.props.confirm) {
this.changePassword(cli, oldPassword, newPassword);
return;
// if the server supports it then don't sign user out of all devices
const serverSupportsControlOfDevicesLogout = await cli.doesServerSupportLogoutDevices();
const userHasOtherDevices = (await cli.getDevices()).devices.length > 1;
if (userHasOtherDevices && !serverSupportsControlOfDevicesLogout && this.props.confirm) {
// warn about logging out all devices
const { finished } = Modal.createTrackedDialog<[boolean]>('Change Password', '', QuestionDialog, {
title: _t("Warning!"),
description:
<div>
<p>{ _t(
'Changing your password on this homeserver will cause all of your other devices to be ' +
'signed out. This will delete the message encryption keys stored on them, and may make ' +
'encrypted chat history unreadable.',
) }</p>
<p>{ _t(
'If you want to retain access to your chat history in encrypted rooms you should first ' +
'export your room keys and re-import them afterwards.',
) }</p>
<p>{ _t(
'You can also ask your homeserver admin to upgrade the server to change this behaviour.',
) }</p>
</div>,
button: _t("Continue"),
extraButtons: [
<button
key="exportRoomKeys"
className="mx_Dialog_primary"
onClick={this.onExportE2eKeysClicked}
>
{ _t('Export E2E room keys') }
</button>,
],
});
const [confirmed] = await finished;
if (!confirmed) return;
}
Modal.createTrackedDialog('Change Password', '', QuestionDialog, {
title: _t("Warning!"),
description:
<div>
{ _t(
'Changing password will currently reset any end-to-end encryption keys on all sessions, ' +
'making encrypted chat history unreadable, unless you first export your room keys ' +
'and re-import them afterwards. ' +
'In future this will be improved.',
) }
{ ' ' }
<a href="https://github.com/vector-im/element-web/issues/2671" target="_blank" rel="noreferrer noopener">
https://github.com/vector-im/element-web/issues/2671
</a>
</div>,
button: _t("Continue"),
extraButtons: [
<button
key="exportRoomKeys"
className="mx_Dialog_primary"
onClick={this.onExportE2eKeysClicked}
>
{ _t('Export E2E room keys') }
</button>,
],
onFinished: (confirmed) => {
if (confirmed) {
this.changePassword(cli, oldPassword, newPassword);
}
},
});
this.changePassword(cli, oldPassword, newPassword, serverSupportsControlOfDevicesLogout, userHasOtherDevices);
}
private changePassword(cli: MatrixClient, oldPassword: string, newPassword: string): void {
private changePassword(
cli: MatrixClient,
oldPassword: string,
newPassword: string,
serverSupportsControlOfDevicesLogout: boolean,
userHasOtherDevices: boolean,
): void {
const authDict = {
type: 'm.login.password',
identifier: {
@ -140,15 +154,21 @@ export default class ChangePassword extends React.Component<IProps, IState> {
phase: Phase.Uploading,
});
cli.setPassword(authDict, newPassword).then(() => {
const logoutDevices = serverSupportsControlOfDevicesLogout ? false : undefined;
// undefined or true mean all devices signed out
const didLogoutOutOtherDevices = !serverSupportsControlOfDevicesLogout && userHasOtherDevices;
cli.setPassword(authDict, newPassword, logoutDevices).then(() => {
if (this.props.shouldAskForEmail) {
return this.optionallySetEmail().then((confirmed) => {
this.props.onFinished({
didSetEmail: confirmed,
didLogoutOutOtherDevices,
});
});
} else {
this.props.onFinished();
this.props.onFinished({ didLogoutOutOtherDevices });
}
}, (err) => {
this.props.onError(err);
@ -279,7 +299,7 @@ export default class ChangePassword extends React.Component<IProps, IState> {
if (err) {
this.props.onError(err);
} else {
this.onChangePassword(oldPassword, newPassword);
return this.onChangePassword(oldPassword, newPassword);
}
};

View file

@ -260,14 +260,17 @@ export default class GeneralUserSettingsTab extends React.Component<IProps, ISta
});
};
private onPasswordChanged = (): void => {
private onPasswordChanged = ({ didLogoutOutOtherDevices }: { didLogoutOutOtherDevices: boolean }): void => {
let description = _t("Your password was successfully changed.");
if (didLogoutOutOtherDevices) {
description += " " + _t(
"You will not receive push notifications on other devices until you sign back in to them.",
);
}
// TODO: Figure out a design that doesn't involve replacing the current dialog
Modal.createTrackedDialog('Password changed', '', ErrorDialog, {
title: _t("Success"),
description: _t(
"Your password was successfully changed. You will not receive " +
"push notifications on other sessions until you log back in to them",
) + ".",
description,
});
};