/* Copyright 2024 New Vector Ltd. Copyright 2015-2021 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ import { AuthType, createClient, IAuthData, AuthDict, IInputs, MatrixError, IRegisterRequestParams, IRequestTokenResponse, MatrixClient, SSOFlow, SSOAction, RegisterResponse, } from "matrix-js-sdk/src/matrix"; import React, { Fragment, ReactNode } from "react"; import classNames from "classnames"; import { logger } from "matrix-js-sdk/src/logger"; import { _t } from "../../../languageHandler"; import { adminContactStrings, messageForResourceLimitError, resourceLimitStrings } from "../../../utils/ErrorUtils"; import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils"; import * as Lifecycle from "../../../Lifecycle"; import { IMatrixClientCreds, MatrixClientPeg } from "../../../MatrixClientPeg"; import AuthPage from "../../views/auth/AuthPage"; import Login, { OidcNativeFlow } from "../../../Login"; import dis from "../../../dispatcher/dispatcher"; import SSOButtons from "../../views/elements/SSOButtons"; import ServerPicker from "../../views/elements/ServerPicker"; import RegistrationForm from "../../views/auth/RegistrationForm"; import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton"; import AuthBody from "../../views/auth/AuthBody"; import AuthHeader from "../../views/auth/AuthHeader"; import InteractiveAuth, { InteractiveAuthCallback } from "../InteractiveAuth"; import Spinner from "../../views/elements/Spinner"; import { AuthHeaderDisplay } from "./header/AuthHeaderDisplay"; import { AuthHeaderProvider } from "./header/AuthHeaderProvider"; import SettingsStore from "../../../settings/SettingsStore"; import { ValidatedServerConfig } from "../../../utils/ValidatedServerConfig"; import { Features } from "../../../settings/Settings"; import { startOidcLogin } from "../../../utils/oidc/authorize"; const debuglog = (...args: any[]): void => { if (SettingsStore.getValue("debug_registration")) { logger.log.call(console, "Registration debuglog:", ...args); } }; export interface MobileRegistrationResponse { user_id: string; home_server: string; access_token: string; device_id: string; } interface IProps { serverConfig: ValidatedServerConfig; defaultDeviceDisplayName?: string; email?: string; brand?: string; clientSecret?: string; sessionId?: string; idSid?: string; fragmentAfterLogin?: string; mobileRegister?: boolean; // Called when the user has logged in. Params: // - object with userId, deviceId, homeserverUrl, identityServerUrl, accessToken // - The user's password, if available and applicable (may be cached in memory // for a short time so the user is not required to re-enter their password // for operations like uploading cross-signing keys). onLoggedIn(params: IMatrixClientCreds, password: string): Promise; // registration shouldn't know or care how login is done. onLoginClick(): void; onServerConfigChange(config: ValidatedServerConfig): void; } interface IState { // true if we're waiting for the user to complete busy: boolean; errorText?: ReactNode; // We remember the values entered by the user because // the registration form will be unmounted during the // course of registration, but if there's an error we // want to bring back the registration form with the // values the user entered still in it. We can keep // them in this component's state since this component // persist for the duration of the registration process. formVals: Record; // user-interactive auth // If we've been given a session ID, we're resuming // straight back into UI auth doingUIAuth: boolean; // If set, we've registered but are not going to log // the user in to their new account automatically. completedNoSignin: boolean; flows: | { stages: string[]; }[] | 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?: ReactNode; // Our matrix client - part of state because we can't render the UI auth // component without it. matrixClient?: MatrixClient; // The user ID we've just registered registeredUsername?: string; // if a different user ID to the one we just registered is logged in, // this is the user ID that's logged in. differentLoggedInUserId?: string; // the SSO flow definition, this is fetched from /login as that's the only // place it is exposed. ssoFlow?: SSOFlow; // the OIDC native login flow, when supported and enabled // if present, must be used for registration oidcNativeFlow?: OidcNativeFlow; } export default class Registration extends React.Component { private readonly loginLogic: Login; // `replaceClient` tracks latest serverConfig to spot when it changes under the async method which fetches flows private latestServerConfig?: ValidatedServerConfig; // cache value from settings store private oidcNativeFlowEnabled = false; public constructor(props: IProps) { super(props); this.state = { busy: false, errorText: null, formVals: { email: this.props.email, }, doingUIAuth: Boolean(this.props.sessionId), flows: null, completedNoSignin: false, serverIsAlive: true, serverErrorIsFatal: false, serverDeadError: "", }; // only set on a config level, so we don't need to watch this.oidcNativeFlowEnabled = SettingsStore.getValue(Features.OidcNativeFlow); const { hsUrl, isUrl, delegatedAuthentication } = this.props.serverConfig; this.loginLogic = new Login(hsUrl, isUrl, null, { defaultDeviceDisplayName: "Element login check", // We shouldn't ever be used // if native OIDC is enabled in the client pass the server's delegated auth settings delegatedAuthentication: this.oidcNativeFlowEnabled ? delegatedAuthentication : undefined, }); } public componentDidMount(): void { this.replaceClient(this.props.serverConfig); //triggers a confirmation dialog for data loss before page unloads/refreshes window.addEventListener("beforeunload", this.unloadCallback); } public componentWillUnmount(): void { window.removeEventListener("beforeunload", this.unloadCallback); } private unloadCallback = (event: BeforeUnloadEvent): string | undefined => { if (this.state.doingUIAuth) { event.preventDefault(); event.returnValue = ""; return ""; } }; public componentDidUpdate(prevProps: IProps): void { if ( prevProps.serverConfig.hsUrl !== this.props.serverConfig.hsUrl || prevProps.serverConfig.isUrl !== this.props.serverConfig.isUrl ) { this.replaceClient(this.props.serverConfig); } } private async replaceClient(serverConfig: ValidatedServerConfig): Promise { this.latestServerConfig = serverConfig; const { hsUrl, isUrl } = serverConfig; this.setState({ errorText: null, serverDeadError: null, serverErrorIsFatal: false, // busy while we do live-ness check (we need to avoid trying to render // the UI auth component while we don't have a matrix client) busy: true, }); // Do a liveliness check on the URLs try { await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl); if (serverConfig !== this.latestServerConfig) return; // discard, serverConfig changed from under us this.setState({ serverIsAlive: true, serverErrorIsFatal: false, }); } catch (e) { if (serverConfig !== this.latestServerConfig) return; // discard, serverConfig changed from under us this.setState({ busy: false, ...AutoDiscoveryUtils.authComponentStateForError(e, "register"), }); if (this.state.serverErrorIsFatal) { return; // Server is dead - do not continue. } } const cli = createClient({ baseUrl: hsUrl, idBaseUrl: isUrl, }); this.loginLogic.setHomeserverUrl(hsUrl); this.loginLogic.setIdentityServerUrl(isUrl); // if native OIDC is enabled in the client pass the server's delegated auth settings const delegatedAuthentication = this.oidcNativeFlowEnabled ? serverConfig.delegatedAuthentication : undefined; this.loginLogic.setDelegatedAuthentication(delegatedAuthentication); let ssoFlow: SSOFlow | undefined; let oidcNativeFlow: OidcNativeFlow | undefined; try { const loginFlows = await this.loginLogic.getFlows(true); if (serverConfig !== this.latestServerConfig) return; // discard, serverConfig changed from under us ssoFlow = loginFlows.find((f) => f.type === "m.login.sso" || f.type === "m.login.cas") as SSOFlow; oidcNativeFlow = loginFlows.find((f) => f.type === "oidcNativeFlow") as OidcNativeFlow; } catch (e) { if (serverConfig !== this.latestServerConfig) return; // discard, serverConfig changed from under us logger.error("Failed to get login flows to check for SSO support", e); } await new Promise((resolve) => { this.setState( ({ flows }) => ({ matrixClient: cli, ssoFlow, oidcNativeFlow, // if we are using oidc native we won't continue with flow discovery on HS // so set an empty array to indicate flows are no longer loading flows: oidcNativeFlow ? [] : flows, busy: false, }), resolve, ); }); // don't need to check with homeserver for login flows // since we are going to use OIDC native flow if (oidcNativeFlow) { return; } try { // We do the first registration request ourselves to discover whether we need to // do SSO instead. If we've already started the UI Auth process though, we don't // need to. if (!this.state.doingUIAuth) { await this.makeRegisterRequest(null); if (serverConfig !== this.latestServerConfig) return; // discard, serverConfig changed from under us // This should never succeed since we specified no auth object. logger.log("Expecting 401 from register request but got success!"); } } catch (e) { if (serverConfig !== this.latestServerConfig) return; // discard, serverConfig changed from under us if (e instanceof MatrixError && e.httpStatus === 401) { this.setState({ flows: e.data.flows, }); } else if (e instanceof MatrixError && (e.httpStatus === 403 || e.errcode === "M_FORBIDDEN")) { // Check for 403 or M_FORBIDDEN, Synapse used to send 403 M_UNKNOWN but now sends 403 M_FORBIDDEN. // At this point registration is pretty much disabled, but before we do that let's // quickly check to see if the server supports SSO instead. If it does, we'll send // the user off to the login page to figure their account out. if (ssoFlow) { // Redirect to login page - server probably expects SSO only dis.dispatch({ action: "start_login" }); } else { this.setState({ serverErrorIsFatal: true, // fatal because user cannot continue on this server errorText: _t("auth|registration_disabled"), // add empty flows array to get rid of spinner flows: [], }); } } else { logger.log("Unable to query for supported registration methods.", e); this.setState({ errorText: _t("auth|failed_query_registration_methods"), // add empty flows array to get rid of spinner flows: [], }); } } } private onFormSubmit = async (formVals: Record): Promise => { this.setState({ errorText: "", busy: true, formVals, doingUIAuth: true, }); }; private requestEmailToken = ( emailAddress: string, clientSecret: string, sendAttempt: number, sessionId: string, ): Promise => { if (!this.state.matrixClient) throw new Error("Matrix client has not yet been loaded"); return this.state.matrixClient.requestRegisterEmailToken(emailAddress, clientSecret, sendAttempt); }; private onUIAuthFinished: InteractiveAuthCallback = async (success, response): Promise => { if (!this.state.matrixClient) throw new Error("Matrix client has not yet been loaded"); debuglog("Registration: ui authentication finished: ", { success, response }); if (!success) { let errorText: ReactNode = (response as Error).message || (response as Error).toString(); // can we give a better error message? if (response instanceof MatrixError && response.errcode === "M_RESOURCE_LIMIT_EXCEEDED") { const errorTop = messageForResourceLimitError( response.data.limit_type, response.data.admin_contact, resourceLimitStrings, ); const errorDetail = messageForResourceLimitError( response.data.limit_type, response.data.admin_contact, adminContactStrings, ); errorText = (

{errorTop}

{errorDetail}

); } else if ((response as IAuthData).flows?.some((flow) => flow.stages.includes(AuthType.Msisdn))) { const flows = (response as IAuthData).flows ?? []; const msisdnAvailable = flows.some((flow) => flow.stages.includes(AuthType.Msisdn)); if (!msisdnAvailable) { errorText = _t("auth|unsupported_auth_msisdn"); } } else if (response instanceof MatrixError && response.errcode === "M_USER_IN_USE") { errorText = _t("auth|username_in_use"); } else if (response instanceof MatrixError && response.errcode === "M_THREEPID_IN_USE") { errorText = _t("auth|3pid_in_use"); } this.setState({ busy: false, doingUIAuth: false, errorText, }); return; } const userId = (response as RegisterResponse).user_id; const accessToken = (response as RegisterResponse).access_token; if (!userId || !accessToken) throw new Error("Registration failed"); MatrixClientPeg.setJustRegisteredUserId(userId); const newState: Partial = { doingUIAuth: false, registeredUsername: userId, differentLoggedInUserId: undefined, completedNoSignin: false, // we're still busy until we get unmounted: don't show the registration form again busy: true, }; // The user came in through an email validation link. To avoid overwriting // their session, check to make sure the session isn't someone else, and // isn't a guest user since we'll usually have set a guest user session before // starting the registration process. This isn't perfect since it's possible // the user had a separate guest session they didn't actually mean to replace. const [sessionOwner, sessionIsGuest] = await Lifecycle.getStoredSessionOwner(); if (sessionOwner && !sessionIsGuest && sessionOwner !== userId) { logger.log(`Found a session for ${sessionOwner} but ${userId} has just registered.`); newState.differentLoggedInUserId = sessionOwner; } // if we don't have an email at all, only one client can be involved in this flow, and we can directly log in. // // if we've got an email, it needs to be verified. in that case, two clients can be involved in this flow, the // original client starting the process and the client that submitted the verification token. After the token // has been submitted, it can not be used again. // // we can distinguish them based on whether the client has form values saved (if so, it's the one that started // the registration), or whether it doesn't have any form values saved (in which case it's the client that // verified the email address) // // as the client that started registration may be gone by the time we've verified the email, and only the client // that verified the email is guaranteed to exist, we'll always do the login in that client. const hasEmail = Boolean(this.state.formVals.email); const hasAccessToken = Boolean(accessToken); debuglog("Registration: ui auth finished:", { hasEmail, hasAccessToken }); // don’t log in if we found a session for a different user if (hasAccessToken && !newState.differentLoggedInUserId) { if (this.props.mobileRegister) { const mobileResponse: MobileRegistrationResponse = { user_id: userId, home_server: this.state.matrixClient.getHomeserverUrl(), access_token: accessToken, device_id: (response as RegisterResponse).device_id!, }; const event = new CustomEvent("mobileregistrationresponse", { detail: mobileResponse, }); window.dispatchEvent(event); newState.busy = false; newState.completedNoSignin = true; } else { await this.props.onLoggedIn( { userId, deviceId: (response as RegisterResponse).device_id!, homeserverUrl: this.state.matrixClient.getHomeserverUrl(), identityServerUrl: this.state.matrixClient.getIdentityServerUrl(), accessToken, }, this.state.formVals.password!, ); this.setupPushers(); } } else { newState.busy = false; newState.completedNoSignin = true; } this.setState(newState as IState); }; private setupPushers(): Promise { if (!this.props.brand) { return Promise.resolve(); } const matrixClient = MatrixClientPeg.safeGet(); return matrixClient.getPushers().then( (resp) => { const pushers = resp.pushers; for (let i = 0; i < pushers.length; ++i) { if (pushers[i].kind === "email") { const emailPusher = pushers[i]; emailPusher.data = { brand: this.props.brand }; matrixClient.setPusher(emailPusher).then( () => { logger.log("Set email branding to " + this.props.brand); }, (error) => { logger.error("Couldn't set email branding: " + error); }, ); } } }, (error) => { logger.error("Couldn't get pushers: " + error); }, ); } private onLoginClick = (ev: ButtonEvent): void => { ev.preventDefault(); ev.stopPropagation(); this.props.onLoginClick(); }; private onGoToFormClicked = (ev: ButtonEvent): void => { ev.preventDefault(); ev.stopPropagation(); this.replaceClient(this.props.serverConfig); this.setState({ busy: false, doingUIAuth: false, }); }; private makeRegisterRequest = (auth: AuthDict | null): Promise => { if (!this.state.matrixClient) throw new Error("Matrix client has not yet been loaded"); const registerParams: IRegisterRequestParams = { username: this.state.formVals.username, password: this.state.formVals.password, initial_device_display_name: this.props.defaultDeviceDisplayName, auth: undefined, // we still want to avoid the race conditions involved with multiple clients handling registration, but // we'll handle these after we've received the access_token in onUIAuthFinished inhibit_login: undefined, }; if (auth) registerParams.auth = auth; debuglog("Registration: sending registration request:", auth); return this.state.matrixClient.registerRequest(registerParams); }; private getUIAuthInputs(): IInputs { return { emailAddress: this.state.formVals.email, phoneCountry: this.state.formVals.phoneCountry, phoneNumber: this.state.formVals.phoneNumber, }; } // Links to the login page shown after registration is completed are routed through this // which checks the user hasn't already logged in somewhere else (perhaps we should do // this more generally?) private onLoginClickWithCheck = async (ev: ButtonEvent): Promise => { ev.preventDefault(); const sessionLoaded = await Lifecycle.loadSession({ ignoreGuest: true }); if (!sessionLoaded) { // ok fine, there's still no session: really go to the login page this.props.onLoginClick(); } return sessionLoaded; }; private renderRegisterComponent(): ReactNode { if (this.state.matrixClient && this.state.doingUIAuth) { return ( ); } else if (!this.state.matrixClient && !this.state.busy) { return null; } else if (this.state.busy || !this.state.flows) { return (
); } else if (this.state.matrixClient && this.state.oidcNativeFlow) { return ( { await startOidcLogin( this.props.serverConfig.delegatedAuthentication!, this.state.oidcNativeFlow!.clientId, this.props.serverConfig.hsUrl, this.props.serverConfig.isUrl, true /* isRegistration */, ); }} > {_t("action|continue")} ); } else if (this.state.matrixClient && this.state.flows.length) { let ssoSection: JSX.Element | undefined; if (!this.props.mobileRegister && this.state.ssoFlow) { let continueWithSection; const providers = this.state.ssoFlow.identity_providers || []; // when there is only a single (or 0) providers we show a wide button with `Continue with X` text if (providers.length > 1) { // i18n: ssoButtons is a placeholder to help translators understand context continueWithSection = (

{_t("auth|continue_with_sso", { ssoButtons: "" }).trim()}

); } // i18n: ssoButtons & usernamePassword are placeholders to help translators understand context ssoSection = ( {continueWithSection}

{_t("auth|sso_or_username_password", { ssoButtons: "", usernamePassword: "", }).trim()}

); } return ( {ssoSection} ); } return null; } public render(): React.ReactNode { let errorText; const err = this.state.errorText; if (err) { errorText =
{err}
; } let serverDeadSection; if (!this.state.serverIsAlive) { const classes = classNames({ mx_Login_error: true, mx_Login_serverError: true, mx_Login_serverErrorNonFatal: !this.state.serverErrorIsFatal, }); serverDeadSection =
{this.state.serverDeadError}
; } const signIn = ( {_t( "auth|sign_in_instead_prompt", {}, { a: (sub) => ( {sub} ), }, )} ); // Only show the 'go back' button if you're not looking at the form let goBack; if (this.state.doingUIAuth) { goBack = ( {_t("action|go_back")} ); } let body; if (this.state.completedNoSignin) { let regDoneText; if (this.props.mobileRegister) { regDoneText = undefined; } else if (this.state.differentLoggedInUserId) { regDoneText = (

{_t("auth|account_clash", { newAccountId: this.state.registeredUsername, loggedInUserId: this.state.differentLoggedInUserId, })}

=> { const sessionLoaded = await this.onLoginClickWithCheck(event); if (sessionLoaded) { dis.dispatch({ action: "view_welcome_page" }); } }} > {_t("auth|account_clash_previous_account")}

); } else { // regardless of whether we're the client that started the registration or not, we should // try our credentials anyway regDoneText = (

{_t( "auth|log_in_new_account", {}, { a: (sub) => ( => { const sessionLoaded = await this.onLoginClickWithCheck(event); if (sessionLoaded) { dis.dispatch({ action: "view_home_page" }); } }} > {sub} ), }, )}

); } body = (

{_t("auth|registration_successful")}

{regDoneText}
); } else if (this.props.mobileRegister) { body = this.renderRegisterComponent(); } else { body = (
} > {errorText} {serverDeadSection} {this.renderRegisterComponent()}
{goBack} {signIn}
); } if (this.props.mobileRegister) { return (
{body}
); } return ( {body} ); } }