New password reset flow (#9581)

This commit is contained in:
Michael Weimann 2022-11-22 07:58:37 +01:00 committed by GitHub
parent 3f74ac37e8
commit e5ce6d7800
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 1163 additions and 362 deletions

View file

@ -16,104 +16,102 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
import { logger } from "matrix-js-sdk/src/logger";
import React, { ReactNode } from 'react';
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";
import PasswordReset from "../../../PasswordReset";
import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils";
import AuthPage from "../../views/auth/AuthPage";
import ServerPicker from "../../views/elements/ServerPicker";
import EmailField from "../../views/auth/EmailField";
import PassphraseField from '../../views/auth/PassphraseField';
import { PASSWORD_MIN_SCORE } from '../../views/auth/RegistrationForm';
import InlineSpinner from '../../views/elements/InlineSpinner';
import Spinner from "../../views/elements/Spinner";
import QuestionDialog from "../../views/dialogs/QuestionDialog";
import ErrorDialog from "../../views/dialogs/ErrorDialog";
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';
import { ValidatedServerConfig } from '../../../utils/ValidatedServerConfig';
import { Icon as LockIcon } from "../../../../res/img/element-icons/lock.svg";
import QuestionDialog from '../../views/dialogs/QuestionDialog';
import { EnterEmail } from './forgot-password/EnterEmail';
import { CheckEmail } from './forgot-password/CheckEmail';
import Field from '../../views/elements/Field';
import { ErrorMessage } from '../ErrorMessage';
import { Icon as CheckboxIcon } from "../../../../res/img/element-icons/Checkbox.svg";
import { VerifyEmailModal } from './forgot-password/VerifyEmailModal';
import Spinner from '../../views/elements/Spinner';
import { formatSeconds } from '../../../DateUtils';
import AutoDiscoveryUtils from '../../../utils/AutoDiscoveryUtils';
enum Phase {
// Show the forgot password inputs
Forgot = 1,
// Show email input
EnterEmail = 1,
// Email is in the process of being sent
SendingEmail = 2,
// Email has been sent
EmailSent = 3,
// User has clicked the link in email and completed reset
Done = 4,
// Show new password input
PasswordInput = 4,
// Password is in the process of being reset
ResettingPassword = 5,
// All done
Done = 6,
}
interface IProps {
interface Props {
serverConfig: ValidatedServerConfig;
onServerConfigChange: (serverConfig: ValidatedServerConfig) => void;
onLoginClick?: () => void;
onComplete: () => void;
}
interface IState {
interface State {
phase: Phase;
email: string;
password: string;
password2: string;
errorText: string;
errorText: string | ReactNode | null;
// We perform liveliness checks later, but for now suppress the errors.
// We also track the server dead errors independently of the regular errors so
// that we can render it differently, and override any other error the user may
// be seeing.
serverIsAlive: boolean;
serverErrorIsFatal: boolean;
serverDeadError: string;
currentHttpRequest?: Promise<any>;
serverSupportsControlOfDevicesLogout: boolean;
logoutDevices: boolean;
}
enum ForgotPasswordField {
Email = 'field_email',
Password = 'field_password',
PasswordConfirm = 'field_password_confirm',
}
export default class ForgotPassword extends React.Component<IProps, IState> {
export default class ForgotPassword extends React.Component<Props, State> {
private reset: PasswordReset;
private fieldPassword: Field | null = null;
private fieldPasswordConfirm: Field | null = null;
state: IState = {
phase: Phase.Forgot,
email: "",
password: "",
password2: "",
errorText: null,
// We perform liveliness checks later, but for now suppress the errors.
// We also track the server dead errors independently of the regular errors so
// that we can render it differently, and override any other error the user may
// be seeing.
serverIsAlive: true,
serverErrorIsFatal: false,
serverDeadError: "",
serverSupportsControlOfDevicesLogout: false,
logoutDevices: false,
};
public constructor(props: Props) {
super(props);
this.state = {
phase: Phase.EnterEmail,
email: "",
password: "",
password2: "",
errorText: null,
// We perform liveliness checks later, but for now suppress the errors.
// We also track the server dead errors independently of the regular errors so
// that we can render it differently, and override any other error the user may
// be seeing.
serverIsAlive: true,
serverDeadError: "",
serverSupportsControlOfDevicesLogout: false,
logoutDevices: false,
};
this.reset = new PasswordReset(this.props.serverConfig.hsUrl, this.props.serverConfig.isUrl);
}
public componentDidMount() {
this.reset = null;
this.checkServerLiveliness(this.props.serverConfig);
this.checkServerCapabilities(this.props.serverConfig);
}
public componentDidUpdate(prevProps: Readonly<IProps>) {
public componentDidUpdate(prevProps: Readonly<Props>) {
if (prevProps.serverConfig.hsUrl !== this.props.serverConfig.hsUrl ||
prevProps.serverConfig.isUrl !== this.props.serverConfig.isUrl
) {
@ -125,7 +123,7 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
}
}
private async checkServerLiveliness(serverConfig): Promise<void> {
private async checkServerLiveliness(serverConfig: ValidatedServerConfig): Promise<void> {
try {
await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(
serverConfig.hsUrl,
@ -135,8 +133,15 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
this.setState({
serverIsAlive: true,
});
} catch (e) {
this.setState(AutoDiscoveryUtils.authComponentStateForError(e, "forgot_password") as IState);
} catch (e: any) {
const {
serverIsAlive,
serverDeadError,
} = AutoDiscoveryUtils.authComponentStateForError(e, "forgot_password");
this.setState({
serverIsAlive,
errorText: serverDeadError,
});
}
}
@ -153,94 +158,85 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
});
}
public submitPasswordReset(email: string, password: string, logoutDevices = true): void {
private async onPhaseEmailInputSubmit() {
this.phase = Phase.SendingEmail;
if (await this.sendVerificationMail()) {
this.phase = Phase.EmailSent;
return;
}
this.phase = Phase.EnterEmail;
}
private sendVerificationMail = async (): Promise<boolean> => {
try {
await this.reset.requestResetToken(this.state.email);
return true;
} catch (err: any) {
this.handleError(err);
}
return false;
};
private handleError(err: any): void {
if (err?.httpStatus === 429) {
// 429: rate limit
const retryAfterMs = parseInt(err?.data?.retry_after_ms, 10);
const errorText = isNaN(retryAfterMs)
? _t("Too many attempts in a short time. Wait some time before trying again.")
: _t(
"Too many attempts in a short time. Retry after %(timeout)s.",
{
timeout: formatSeconds(retryAfterMs / 1000),
},
);
this.setState({
errorText,
});
return;
}
if (err?.name === "ConnectionError") {
this.setState({
errorText: _t("Cannot reach homeserver") + ": "
+ _t("Ensure you have a stable internet connection, or get in touch with the server admin"),
});
return;
}
this.setState({
phase: Phase.SendingEmail,
});
this.reset = new PasswordReset(this.props.serverConfig.hsUrl, this.props.serverConfig.isUrl);
this.reset.resetPassword(email, password, logoutDevices).then(() => {
this.setState({
phase: Phase.EmailSent,
});
}, (err) => {
this.showErrorDialog(_t('Failed to send email') + ": " + err.message);
this.setState({
phase: Phase.Forgot,
});
errorText: err.message,
});
}
private onVerify = async (ev: React.MouseEvent): Promise<void> => {
ev.preventDefault();
if (!this.reset) {
logger.error("onVerify called before submitPasswordReset!");
return;
}
if (this.state.currentHttpRequest) return;
private async onPhaseEmailSentSubmit() {
this.setState({
phase: Phase.PasswordInput,
});
}
try {
await this.handleHttpRequest(this.reset.checkEmailLinkClicked());
this.setState({ phase: Phase.Done });
} catch (err) {
this.showErrorDialog(err.message);
}
};
private set phase(phase: Phase) {
this.setState({ phase });
}
private onSubmitForm = async (ev: React.FormEvent): Promise<void> => {
ev.preventDefault();
if (this.state.currentHttpRequest) return;
// refresh the server errors, just in case the server came back online
await this.handleHttpRequest(this.checkServerLiveliness(this.props.serverConfig));
const allFieldsValid = await this.verifyFieldsBeforeSubmit();
if (!allFieldsValid) {
return;
}
if (this.state.logoutDevices) {
const { finished } = Modal.createDialog<[boolean]>(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() {
private async verifyFieldsBeforeSubmit(): Promise<boolean> {
const fieldIdsInDisplayOrder = [
ForgotPasswordField.Email,
ForgotPasswordField.Password,
ForgotPasswordField.PasswordConfirm,
this.fieldPassword,
this.fieldPasswordConfirm,
];
const invalidFields = [];
for (const fieldId of fieldIdsInDisplayOrder) {
const valid = await this[fieldId].validate({ allowEmpty: false });
const invalidFields: Field[] = [];
for (const field of fieldIdsInDisplayOrder) {
if (!field) continue;
const valid = await field.validate({ allowEmpty: false });
if (!valid) {
invalidFields.push(this[fieldId]);
invalidFields.push(field);
}
}
@ -256,6 +252,78 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
return false;
}
private async onPhasePasswordInputSubmit(): Promise<void> {
if (!await this.verifyFieldsBeforeSubmit()) return;
if (this.state.logoutDevices) {
const logoutDevicesConfirmation = await this.renderConfirmLogoutDevicesDialog();
if (!logoutDevicesConfirmation) return;
}
this.phase = Phase.ResettingPassword;
try {
await this.reset.setNewPassword(this.state.password);
} catch (err: any) {
if (err.httpStatus !== 401) {
// 401 = waiting for email verification, else unknown error
this.handleError(err);
return;
}
}
const modal = Modal.createDialog(
VerifyEmailModal,
{
email: this.state.email,
errorText: this.state.errorText,
onResendClick: this.sendVerificationMail,
},
"mx_VerifyEMailDialog",
false,
false,
{
// this modal cannot be dismissed except reset is done or forced
onBeforeClose: async (reason?: string) => {
return this.state.phase === Phase.Done || reason === "force";
},
},
);
await this.reset.retrySetNewPassword(this.state.password);
this.phase = Phase.Done;
modal.close();
}
private onSubmitForm = async (ev: React.FormEvent): Promise<void> => {
ev.preventDefault();
// Should not happen because of disabled forms, but just return if currently doing an action.
if ([Phase.SendingEmail, Phase.ResettingPassword].includes(this.state.phase)) return;
this.setState({
errorText: "",
});
// Refresh the server errors. Just in case the server came back online of went offline.
await this.checkServerLiveliness(this.props.serverConfig);
// Server error
if (!this.state.serverIsAlive) return;
switch (this.state.phase) {
case Phase.EnterEmail:
this.onPhaseEmailInputSubmit();
break;
case Phase.EmailSent:
this.onPhaseEmailSentSubmit();
break;
case Phase.PasswordInput:
this.onPhasePasswordInputSubmit();
break;
}
};
private onInputChanged = (stateKey: string, ev: React.FormEvent<HTMLInputElement>) => {
let value = ev.currentTarget.value;
if (stateKey === "email") value = value.trim();
@ -264,139 +332,109 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
} as any);
};
private onLoginClick = (ev: React.MouseEvent): void => {
ev.preventDefault();
ev.stopPropagation();
this.props.onLoginClick();
};
public showErrorDialog(description: string, title?: string) {
Modal.createDialog(ErrorDialog, {
title,
description,
});
renderEnterEmail(): JSX.Element {
return <EnterEmail
email={this.state.email}
errorText={this.state.errorText}
homeserver={this.props.serverConfig.hsName}
loading={this.state.phase === Phase.SendingEmail}
onInputChanged={this.onInputChanged}
onSubmitForm={this.onSubmitForm}
/>;
}
private handleHttpRequest<T = unknown>(request: Promise<T>): Promise<T> {
this.setState({
currentHttpRequest: request,
});
return request.finally(() => {
this.setState({
currentHttpRequest: undefined,
});
async renderConfirmLogoutDevicesDialog(): Promise<boolean> {
const { finished } = Modal.createDialog<[boolean]>(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;
return confirmed;
}
renderForgot() {
let errorText = null;
const err = this.state.errorText;
if (err) {
errorText = <div className="mx_Login_error">{ err }</div>;
}
renderCheckEmail(): JSX.Element {
return <CheckEmail
email={this.state.email}
errorText={this.state.errorText}
onResendClick={this.sendVerificationMail}
onSubmitForm={this.onSubmitForm}
/>;
}
let serverDeadSection;
if (!this.state.serverIsAlive) {
const classes = classNames({
"mx_Login_error": true,
"mx_Login_serverError": true,
"mx_Login_serverErrorNonFatal": !this.state.serverErrorIsFatal,
});
serverDeadSection = (
<div className={classes}>
{ this.state.serverDeadError }
</div>
);
}
renderSetPassword(): JSX.Element {
const submitButtonChild = this.state.phase === Phase.ResettingPassword
? <Spinner w={16} h={16} />
: _t("Reset password");
return <div>
{ errorText }
{ serverDeadSection }
<ServerPicker
serverConfig={this.props.serverConfig}
onServerConfigChange={this.props.onServerConfigChange}
/>
return <>
<LockIcon className="mx_AuthBody_lockIcon" />
<h1>{ _t("Reset your password") }</h1>
<form onSubmit={this.onSubmitForm}>
<div className="mx_AuthBody_fieldRow">
<EmailField
name="reset_email" // define a name so browser's password autofill gets less confused
labelRequired={_td('The email address linked to your account must be entered.')}
labelInvalid={_td("The email address doesn't appear to be valid.")}
value={this.state.email}
fieldRef={field => this[ForgotPasswordField.Email] = field}
autoFocus={true}
onChange={this.onInputChanged.bind(this, "email")}
/>
</div>
<div className="mx_AuthBody_fieldRow">
<PassphraseField
name="reset_password"
type="password"
label={_td('New Password')}
value={this.state.password}
minScore={PASSWORD_MIN_SCORE}
fieldRef={field => this[ForgotPasswordField.Password] = field}
onChange={this.onInputChanged.bind(this, "password")}
autoComplete="new-password"
/>
<PassphraseConfirmField
name="reset_password_confirm"
label={_td('Confirm')}
labelRequired={_td("A new password must be entered.")}
labelInvalid={_td("New passwords must match each other.")}
value={this.state.password2}
password={this.state.password}
fieldRef={field => this[ForgotPasswordField.PasswordConfirm] = field}
onChange={this.onInputChanged.bind(this, "password2")}
autoComplete="new-password"
/>
</div>
{ this.state.serverSupportsControlOfDevicesLogout ?
<fieldset disabled={this.state.phase === Phase.ResettingPassword}>
<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.',
) }</span>
<input
className="mx_Login_submit"
type="submit"
value={_t('Send Reset Email')}
/>
<PassphraseField
name="reset_password"
type="password"
label={_td("New Password")}
value={this.state.password}
minScore={PASSWORD_MIN_SCORE}
fieldRef={field => this.fieldPassword = field}
onChange={this.onInputChanged.bind(this, "password")}
autoComplete="new-password"
/>
<PassphraseConfirmField
name="reset_password_confirm"
label={_td("Confirm new password")}
labelRequired={_td("A new password must be entered.")}
labelInvalid={_td("New passwords must match each other.")}
value={this.state.password2}
password={this.state.password}
fieldRef={field => this.fieldPasswordConfirm = field}
onChange={this.onInputChanged.bind(this, "password2")}
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 of all devices") }
</StyledCheckbox>
</div> : null
}
{ this.state.errorText && <ErrorMessage message={this.state.errorText} /> }
<button
type="submit"
className="mx_Login_submit"
>
{ submitButtonChild }
</button>
</fieldset>
</form>
<AccessibleButton kind='link' className="mx_AuthBody_changeFlow" onClick={this.onLoginClick}>
{ _t('Sign in instead') }
</AccessibleButton>
</div>;
}
renderSendingEmail() {
return <Spinner />;
}
renderEmailSent() {
return <div>
{ _t("An email has been sent to %(emailAddress)s. Once you've followed the " +
"link it contains, click below.", { emailAddress: this.state.email }) }
<br />
<input
className="mx_Login_submit"
type="button"
onClick={this.onVerify}
value={_t('I have verified my email address')} />
{ this.state.currentHttpRequest && (
<div className="mx_Login_spinner"><InlineSpinner w={64} h={64} /></div>)
}
</div>;
</>;
}
renderDone() {
return <div>
<p>{ _t("Your password has been reset.") }</p>
return <>
<CheckboxIcon className="mx_Icon mx_Icon_32 mx_Icon_accent" />
<h1>{ _t("Your password has been reset.") }</h1>
{ this.state.logoutDevices ?
<p>{ _t(
"You have been logged out of all devices and will no longer receive " +
@ -410,33 +448,40 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
type="button"
onClick={this.props.onComplete}
value={_t('Return to login screen')} />
</div>;
</>;
}
render() {
let resetPasswordJsx;
let resetPasswordJsx: JSX.Element;
switch (this.state.phase) {
case Phase.Forgot:
resetPasswordJsx = this.renderForgot();
break;
case Phase.EnterEmail:
case Phase.SendingEmail:
resetPasswordJsx = this.renderSendingEmail();
resetPasswordJsx = this.renderEnterEmail();
break;
case Phase.EmailSent:
resetPasswordJsx = this.renderEmailSent();
resetPasswordJsx = this.renderCheckEmail();
break;
case Phase.PasswordInput:
case Phase.ResettingPassword:
resetPasswordJsx = this.renderSetPassword();
break;
case Phase.Done:
resetPasswordJsx = this.renderDone();
break;
default:
resetPasswordJsx = <div className="mx_Login_spinner"><InlineSpinner w={64} h={64} /></div>;
// This should not happen. However, it is logged and the user is sent to the start.
logger.warn(`unknown forgot password phase ${this.state.phase}`);
this.setState({
phase: Phase.EnterEmail,
});
return;
}
return (
<AuthPage>
<AuthHeader />
<AuthBody>
<h1> { _t('Set a new password') } </h1>
{ resetPasswordJsx }
</AuthBody>
</AuthPage>