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

Commit

Permalink
Allow user to control if they are signed out of all devices when chan…
Browse files Browse the repository at this point in the history
…ging password (#8259)

Co-authored-by: Michael Telatynski <[email protected]>
  • Loading branch information
hughns and t3chguy authored Apr 22, 2022
1 parent ee2ee3c commit bb4064f
Show file tree
Hide file tree
Showing 5 changed files with 157 additions and 76 deletions.
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

0 comments on commit bb4064f

Please sign in to comment.