Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

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

Merged
merged 24 commits into from
Apr 22, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
9825e44
Allow user to control if they are signed out of all sessions when res…
hughns Apr 8, 2022
ac4b135
Don't sign out all devices when changing password from Settings tab
hughns Apr 8, 2022
e8eed9d
Use camelcase variable name
hughns Apr 8, 2022
49b12b0
Add argument type
hughns Apr 8, 2022
564f0fd
session => device
hughns Apr 8, 2022
5bebf72
Wording changes based on feedback in PR
hughns Apr 8, 2022
f360a64
Merge branch 'hughns/logout-devices-control' of https://github.com/ma…
hughns Apr 8, 2022
13e95bf
Merge branch 'develop' into hughns/logout-devices-control
hughns Apr 13, 2022
68f9551
UI to respect if server has capability to control device logout
hughns Apr 13, 2022
981813d
Revert "Don't sign out all devices when changing password from Settin…
hughns Apr 13, 2022
e6415b2
If homeserver supports it then don't sign out all devices when changi…
hughns Apr 13, 2022
5c48520
Wording revisions
hughns Apr 13, 2022
fd36f66
Whitespace fix
hughns Apr 13, 2022
c4380c9
Remove trailing whitespace from translations
hughns Apr 13, 2022
e8626ed
Wording and whitespace
hughns Apr 13, 2022
bd3a12c
Add proper capability check based on support spec version
hughns Apr 13, 2022
27a7bf1
Merge branch 'develop' into hughns/logout-devices-control
hughns Apr 13, 2022
36c6fde
Update src/components/views/settings/ChangePassword.tsx
hughns Apr 13, 2022
20b3316
Update src/components/structures/auth/ForgotPassword.tsx
hughns Apr 13, 2022
cff80ac
Refactor to use Modal promises
hughns Apr 13, 2022
fe384cd
Take account of whether devices where signed out in password change c…
hughns Apr 19, 2022
f3260a0
Update translations
hughns Apr 19, 2022
853e0e9
Merge branch 'develop' into hughns/logout-devices-control
hughns Apr 21, 2022
9038ea7
Only warn user when changing password in Settings if they have other …
hughns Apr 21, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions src/PasswordReset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export default class PasswordReset {
private clientSecret: string;
private password: string;
private sessionId: string;
private logoutDevices: boolean;

/**
* Configure the endpoints for password resetting.
Expand All @@ -50,10 +51,16 @@ export default class PasswordReset {
* sending an email to the provided email address.
* @param {string} emailAddress The email address
* @param {string} newPassword The new password for the account.
* @param {boolean} logoutDevices Should all devices be signed out after the reset? Defaults to `true`.
* @return {Promise} Resolves when the email has been sent. Then call checkEmailLinkClicked().
*/
public resetPassword(emailAddress: string, newPassword: string): Promise<IRequestTokenResponse> {
public resetPassword(
emailAddress: string,
newPassword: string,
logoutDevices = true,
): Promise<IRequestTokenResponse> {
this.password = newPassword;
this.logoutDevices = logoutDevices;
return this.client.requestPasswordEmailToken(emailAddress, this.clientSecret, 1).then((res) => {
this.sessionId = res.sid;
return res;
Expand Down Expand Up @@ -90,7 +97,7 @@ export default class PasswordReset {
// See https://github.com/matrix-org/matrix-doc/issues/2220
threepid_creds: creds,
threepidCreds: creds,
}, this.password);
}, this.password, this.logoutDevices);
} catch (err) {
if (err.httpStatus === 401) {
err.message = _t('Failed to verify email address: make sure you clicked the link in the email');
Expand Down
95 changes: 70 additions & 25 deletions src/components/structures/auth/ForgotPassword.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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
Expand Down Expand Up @@ -72,6 +74,9 @@ interface IState {
serverDeadError: string;

currentHttpRequest?: Promise<any>;

serverSupportsControlOfDevicesLogout: boolean;
logoutDevices: boolean;
}

enum ForgotPasswordField {
Expand All @@ -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
Expand All @@ -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> {
Expand All @@ -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,
});
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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.',
Expand Down Expand Up @@ -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"
Expand Down
100 changes: 60 additions & 40 deletions src/components/views/settings/ChangePassword.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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: {
Expand All @@ -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);
Expand Down Expand Up @@ -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);
}
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
};

Expand Down
Loading