/* 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 React, { ReactNode } from "react"; import classNames from "classnames"; import { logger } from "matrix-js-sdk/src/logger"; import { SSOFlow, SSOAction } from "matrix-js-sdk/src/matrix"; import { _t, UserFriendlyError } from "../../../languageHandler"; import Login, { ClientLoginFlow, OidcNativeFlow } from "../../../Login"; import { messageForConnectionError, messageForLoginError } from "../../../utils/ErrorUtils"; import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils"; import AuthPage from "../../views/auth/AuthPage"; import PlatformPeg from "../../../PlatformPeg"; import SettingsStore from "../../../settings/SettingsStore"; import { UIFeature } from "../../../settings/UIFeature"; import { IMatrixClientCreds } from "../../../MatrixClientPeg"; import PasswordLogin from "../../views/auth/PasswordLogin"; import InlineSpinner from "../../views/elements/InlineSpinner"; import Spinner from "../../views/elements/Spinner"; import SSOButtons from "../../views/elements/SSOButtons"; import ServerPicker from "../../views/elements/ServerPicker"; import AuthBody from "../../views/auth/AuthBody"; import AuthHeader from "../../views/auth/AuthHeader"; import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton"; import { ValidatedServerConfig } from "../../../utils/ValidatedServerConfig"; import { filterBoolean } from "../../../utils/arrays"; import { Features } from "../../../settings/Settings"; import { startOidcLogin } from "../../../utils/oidc/authorize"; interface IProps { serverConfig: ValidatedServerConfig; // If true, the component will consider itself busy. busy?: boolean; isSyncing?: boolean; // Secondary HS which we try to log into if the user is using // the default HS but login fails. Useful for migrating to a // different homeserver without confusing users. fallbackHsUrl?: string; defaultDeviceDisplayName?: string; fragmentAfterLogin?: string; defaultUsername?: string; // Called when the user has logged in. Params: // - The object returned by the login API onLoggedIn(data: IMatrixClientCreds): void; // login shouldn't know or care how registration, password recovery, etc is done. onRegisterClick(): void; onForgotPasswordClick?(): void; onServerConfigChange(config: ValidatedServerConfig): void; } interface IState { busy: boolean; busyLoggingIn?: boolean; errorText?: ReactNode; loginIncorrect: boolean; // can we attempt to log in or are there validation errors? canTryLogin: boolean; flows?: ClientLoginFlow[]; // used for preserving form values when changing homeserver username: string; phoneCountry: string; phoneNumber: string; // 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; } type OnPasswordLogin = { (username: string, phoneCountry: undefined, phoneNumber: undefined, password: string): Promise; (username: undefined, phoneCountry: string, phoneNumber: string, password: string): Promise; }; /* * A wire component which glues together login UI components and Login logic */ export default class LoginComponent extends React.PureComponent { private unmounted = false; private oidcNativeFlowEnabled = false; private loginLogic!: Login; private readonly stepRendererMap: Record ReactNode>; public constructor(props: IProps) { super(props); // only set on a config level, so we don't need to watch this.oidcNativeFlowEnabled = SettingsStore.getValue(Features.OidcNativeFlow); this.state = { busy: false, errorText: null, loginIncorrect: false, canTryLogin: true, username: props.defaultUsername ? props.defaultUsername : "", phoneCountry: "", phoneNumber: "", serverIsAlive: true, serverErrorIsFatal: false, serverDeadError: "", }; // map from login step type to a function which will render a control // letting you do that login type this.stepRendererMap = { "m.login.password": this.renderPasswordStep, // CAS and SSO are the same thing, modulo the url we link to // eslint-disable-next-line @typescript-eslint/naming-convention "m.login.cas": () => this.renderSsoStep("cas"), // eslint-disable-next-line @typescript-eslint/naming-convention "m.login.sso": () => this.renderSsoStep("sso"), "oidcNativeFlow": () => this.renderOidcNativeStep(), }; } public componentDidMount(): void { this.unmounted = false; this.initLoginLogic(this.props.serverConfig); } public componentWillUnmount(): void { this.unmounted = true; } public componentDidUpdate(prevProps: IProps): void { if ( prevProps.serverConfig.hsUrl !== this.props.serverConfig.hsUrl || prevProps.serverConfig.isUrl !== this.props.serverConfig.isUrl || // delegatedAuthentication is only set by buildValidatedConfigFromDiscovery and won't be modified // so shallow comparison is fine prevProps.serverConfig.delegatedAuthentication !== this.props.serverConfig.delegatedAuthentication ) { // Ensure that we end up actually logging in to the right place this.initLoginLogic(this.props.serverConfig); } } public isBusy = (): boolean => !!this.state.busy || !!this.props.busy; public onPasswordLogin: OnPasswordLogin = async ( username: string | undefined, phoneCountry: string | undefined, phoneNumber: string | undefined, password: string, ): Promise => { if (!this.state.serverIsAlive) { this.setState({ busy: true }); // Do a quick liveliness check on the URLs let aliveAgain = true; try { await AutoDiscoveryUtils.validateServerConfigWithStaticUrls( this.props.serverConfig.hsUrl, this.props.serverConfig.isUrl, ); this.setState({ serverIsAlive: true, errorText: "" }); } catch (e) { const componentState = AutoDiscoveryUtils.authComponentStateForError(e); this.setState({ busy: false, busyLoggingIn: false, ...componentState, }); aliveAgain = !componentState.serverErrorIsFatal; } // Prevent people from submitting their password when something isn't right. if (!aliveAgain) { return; } } this.setState({ busy: true, busyLoggingIn: true, errorText: null, loginIncorrect: false, }); this.loginLogic.loginViaPassword(username, phoneCountry, phoneNumber, password).then( (data) => { this.setState({ serverIsAlive: true }); // it must be, we logged in. this.props.onLoggedIn(data); }, (error) => { if (this.unmounted) return; let errorText: ReactNode; // Some error strings only apply for logging in if (error.httpStatus === 400 && username && username.indexOf("@") > 0) { errorText = _t("auth|unsupported_auth_email"); } else { errorText = messageForLoginError(error, this.props.serverConfig); } this.setState({ busy: false, busyLoggingIn: false, errorText, // 401 would be the sensible status code for 'incorrect password' // but the login API gives a 403 https://matrix.org/jira/browse/SYN-744 // mentions this (although the bug is for UI auth which is not this) // We treat both as an incorrect password loginIncorrect: error.httpStatus === 401 || error.httpStatus === 403, }); }, ); }; public onUsernameChanged = (username: string): void => { this.setState({ username }); }; public onUsernameBlur = async (username: string): Promise => { const doWellknownLookup = username[0] === "@"; this.setState({ username: username, busy: doWellknownLookup, errorText: null, canTryLogin: true, }); if (doWellknownLookup) { const serverName = username.split(":").slice(1).join(":"); try { const result = await AutoDiscoveryUtils.validateServerName(serverName); this.props.onServerConfigChange(result); // We'd like to rely on new props coming in via `onServerConfigChange` // so that we know the servers have definitely updated before clearing // the busy state. In the case of a full MXID that resolves to the same // HS as Element's default HS though, there may not be any server change. // To avoid this trap, we clear busy here. For cases where the server // actually has changed, `initLoginLogic` will be called and manages // busy state for its own liveness check. this.setState({ busy: false, }); } catch (e) { logger.error("Problem parsing URL or unhandled error doing .well-known discovery:", e); let message = _t("auth|failed_homeserver_discovery"); if (e instanceof UserFriendlyError && e.translatedMessage) { message = e.translatedMessage; } let errorText: ReactNode = message; let discoveryState = {}; if (AutoDiscoveryUtils.isLivelinessError(e)) { errorText = this.state.errorText; discoveryState = AutoDiscoveryUtils.authComponentStateForError(e); } this.setState({ busy: false, errorText, ...discoveryState, }); } } }; public onPhoneCountryChanged = (phoneCountry: string): void => { this.setState({ phoneCountry }); }; public onPhoneNumberChanged = (phoneNumber: string): void => { this.setState({ phoneNumber }); }; public onRegisterClick = (ev: ButtonEvent): void => { ev.preventDefault(); ev.stopPropagation(); this.props.onRegisterClick(); }; public onTryRegisterClick = (ev: ButtonEvent): void => { const hasPasswordFlow = this.state.flows?.find((flow) => flow.type === "m.login.password"); const ssoFlow = this.state.flows?.find((flow) => flow.type === "m.login.sso" || flow.type === "m.login.cas"); // If has no password flow but an SSO flow guess that the user wants to register with SSO. // TODO: instead hide the Register button if registration is disabled by checking with the server, // has no specific errCode currently and uses M_FORBIDDEN. if (ssoFlow && !hasPasswordFlow) { ev.preventDefault(); ev.stopPropagation(); const ssoKind = ssoFlow.type === "m.login.sso" ? "sso" : "cas"; PlatformPeg.get()?.startSingleSignOn( this.loginLogic.createTemporaryClient(), ssoKind, this.props.fragmentAfterLogin, undefined, SSOAction.REGISTER, ); } else { // Don't intercept - just go through to the register page this.onRegisterClick(ev); } }; private async checkServerLiveliness({ hsUrl, isUrl, }: Pick): Promise { // Do a quick liveliness check on the URLs try { const { warning } = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl); if (warning) { this.setState({ ...AutoDiscoveryUtils.authComponentStateForError(warning), errorText: "", }); } else { this.setState({ serverIsAlive: true, errorText: "", }); } } catch (e) { this.setState({ busy: false, ...AutoDiscoveryUtils.authComponentStateForError(e as Error), }); } } private async initLoginLogic({ hsUrl, isUrl }: ValidatedServerConfig): Promise { let isDefaultServer = false; if ( this.props.serverConfig.isDefault && hsUrl === this.props.serverConfig.hsUrl && isUrl === this.props.serverConfig.isUrl ) { isDefaultServer = true; } const fallbackHsUrl = isDefaultServer ? this.props.fallbackHsUrl! : null; this.setState({ busy: true, loginIncorrect: false, }); await this.checkServerLiveliness({ hsUrl, isUrl }); const loginLogic = new Login(hsUrl, isUrl, fallbackHsUrl, { defaultDeviceDisplayName: this.props.defaultDeviceDisplayName, // if native OIDC is enabled in the client pass the server's delegated auth settings delegatedAuthentication: this.oidcNativeFlowEnabled ? this.props.serverConfig.delegatedAuthentication : undefined, }); this.loginLogic = loginLogic; loginLogic .getFlows() .then( (flows) => { // look for a flow where we understand all of the steps. const supportedFlows = flows.filter(this.isSupportedFlow); this.setState({ flows: supportedFlows, }); if (supportedFlows.length === 0) { this.setState({ errorText: _t("auth|unsupported_auth"), }); } }, (err) => { this.setState({ errorText: messageForConnectionError(err, this.props.serverConfig), loginIncorrect: false, canTryLogin: false, }); }, ) .finally(() => { this.setState({ busy: false, }); }); } private isSupportedFlow = (flow: ClientLoginFlow): boolean => { // technically the flow can have multiple steps, but no one does this // for login and loginLogic doesn't support it so we can ignore it. if (!this.stepRendererMap[flow.type]) { logger.log("Skipping flow", flow, "due to unsupported login type", flow.type); return false; } return true; }; public renderLoginComponentForFlows(): ReactNode { if (!this.state.flows) return null; // this is the ideal order we want to show the flows in const order = ["oidcNativeFlow", "m.login.password", "m.login.sso"]; const flows = filterBoolean(order.map((type) => this.state.flows?.find((flow) => flow.type === type))); return ( {flows.map((flow) => { const stepRenderer = this.stepRendererMap[flow.type]; return {stepRenderer()}; })} ); } private renderPasswordStep = (): JSX.Element => { return ( ); }; private renderOidcNativeStep = (): React.ReactNode => { const flow = this.state.flows!.find((flow) => flow.type === "oidcNativeFlow")! as OidcNativeFlow; return ( { await startOidcLogin( this.props.serverConfig.delegatedAuthentication!, flow.clientId, this.props.serverConfig.hsUrl, this.props.serverConfig.isUrl, ); }} > {_t("action|continue")} ); }; private renderSsoStep = (loginType: "cas" | "sso"): JSX.Element => { const flow = this.state.flows?.find((flow) => flow.type === "m.login." + loginType) as SSOFlow; return ( flow.type === "m.login.password")} action={SSOAction.LOGIN} disabled={this.isBusy()} /> ); }; public render(): React.ReactNode { const loader = this.isBusy() && !this.state.busyLoggingIn ? (
) : null; const errorText = this.state.errorText; let errorTextSection; if (errorText) { errorTextSection =
{errorText}
; } 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}
; } let footer; if (this.props.isSyncing || this.state.busyLoggingIn) { footer = (
{this.props.isSyncing ? _t("auth|syncing") : _t("auth|signing_in")}
{this.props.isSyncing && (
{_t("auth|sync_footer_subtitle")}
)}
); } else if (SettingsStore.getValue(UIFeature.Registration)) { footer = ( {_t( "auth|create_account_prompt", {}, { a: (sub) => ( {sub} ), }, )} ); } return (

{_t("action|sign_in")} {loader}

{errorTextSection} {serverDeadSection} {this.renderLoginComponentForFlows()} {footer}
); } }