diff --git a/src/components/structures/login/Login.js b/src/components/structures/login/Login.js index 92cddb0dc1..bd18699dd1 100644 --- a/src/components/structures/login/Login.js +++ b/src/components/structures/login/Login.js @@ -26,6 +26,7 @@ import Login from '../../../Login'; import SdkConfig from '../../../SdkConfig'; import SettingsStore from "../../../settings/SettingsStore"; import { messageForResourceLimitError } from '../../../utils/ErrorUtils'; +import request from 'browser-request'; // For validating phone numbers without country codes const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/; @@ -74,6 +75,11 @@ module.exports = React.createClass({ phoneCountry: null, phoneNumber: "", currentFlow: "m.login.password", + + // .well-known discovery + discoveredHsUrl: "", + discoveredIsUrl: "", + discoveryError: "", }; }, @@ -105,6 +111,10 @@ module.exports = React.createClass({ }, onPasswordLogin: function(username, phoneCountry, phoneNumber, password) { + // Prevent people from submitting their password when homeserver + // discovery went wrong + if (this.state.discoveryError) return; + this.setState({ busy: true, errorText: null, @@ -221,6 +231,22 @@ module.exports = React.createClass({ this.setState({ username: username }); }, + onUsernameBlur: function(username) { + this.setState({ username: username }); + if (username[0] === "@") { + const serverName = username.split(':').slice(1).join(':'); + try { + // we have to append 'https://' to make the URL constructor happy + // otherwise we get things like 'protocol: matrix.org, pathname: 8448' + const url = new URL("https://" + serverName); + this._tryWellKnownDiscovery(url.hostname); + } catch (e) { + console.error("Problem parsing URL or unhandled error doing .well-known discovery:", e); + this.setState({discoveryError: _t("Failed to perform homeserver discovery")}); + } + } + }, + onPhoneCountryChanged: function(phoneCountry) { this.setState({ phoneCountry: phoneCountry }); }, @@ -256,6 +282,122 @@ module.exports = React.createClass({ }); }, + _tryWellKnownDiscovery: async function(serverName) { + if (!serverName.trim()) { + // Nothing to discover + this.setState({discoveryError: "", discoveredHsUrl: "", discoveredIsUrl: ""}); + return; + } + + try { + const wellknown = await this._getWellKnownObject(`https://${serverName}/.well-known/matrix/client`); + if (!wellknown["m.homeserver"]) { + console.error("No m.homeserver key in well-known response"); + this.setState({discoveryError: _t("Invalid homeserver discovery response")}); + return; + } + + const hsUrl = this._sanitizeWellKnownUrl(wellknown["m.homeserver"]["base_url"]); + if (!hsUrl) { + console.error("Invalid base_url for m.homeserver"); + this.setState({discoveryError: _t("Invalid homeserver discovery response")}); + return; + } + + console.log("Verifying homeserver URL: " + hsUrl); + const hsVersions = await this._getWellKnownObject(`${hsUrl}/_matrix/client/versions`); + if (!hsVersions["versions"]) { + console.error("Invalid /versions response"); + this.setState({discoveryError: _t("Invalid homeserver discovery response")}); + return; + } + + let isUrl = ""; + if (wellknown["m.identity_server"]) { + isUrl = this._sanitizeWellKnownUrl(wellknown["m.identity_server"]["base_url"]); + if (!isUrl) { + console.error("Invalid base_url for m.identity_server"); + this.setState({discoveryError: _t("Invalid homeserver discovery response")}); + return; + } + + console.log("Verifying identity server URL: " + isUrl); + const isResponse = await this._getWellKnownObject(`${isUrl}/_matrix/identity/api/v1`); + if (!isResponse) { + console.error("Invalid /api/v1 response"); + this.setState({discoveryError: _t("Invalid homeserver discovery response")}); + return; + } + } + + this.setState({discoveredHsUrl: hsUrl, discoveredIsUrl: isUrl, discoveryError: ""}); + } catch (e) { + console.error(e); + if (e.wkAction) { + if (e.wkAction === "FAIL_ERROR" || e.wkAction === "FAIL_PROMPT") { + // We treat FAIL_ERROR and FAIL_PROMPT the same to avoid having the user + // submit their details to the wrong homeserver. In practice, the custom + // server options will show up to try and guide the user into entering + // the required information. + this.setState({discoveryError: _t("Cannot find homeserver")}); + return; + } else if (e.wkAction === "IGNORE") { + // Nothing to discover + this.setState({discoveryError: "", discoveredHsUrl: "", discoveredIsUrl: ""}); + return; + } + } + + throw e; + } + }, + + _sanitizeWellKnownUrl: function(url) { + if (!url) return false; + + const parser = document.createElement('a'); + parser.href = url; + + if (parser.protocol !== "http:" && parser.protocol !== "https:") return false; + if (!parser.hostname) return false; + + const port = parser.port ? `:${parser.port}` : ""; + const path = parser.pathname ? parser.pathname : ""; + let saferUrl = `${parser.protocol}//${parser.hostname}${port}${path}`; + if (saferUrl.endsWith("/")) saferUrl = saferUrl.substring(0, saferUrl.length - 1); + return saferUrl; + }, + + _getWellKnownObject: function(url) { + return new Promise(function(resolve, reject) { + request( + { method: "GET", url: url }, + (err, response, body) => { + if (err || response.status < 200 || response.status >= 300) { + let action = "FAIL_ERROR"; + if (response.status === 404) { + // We could just resolve with an empty object, but that + // causes a different series of branches when the m.homeserver + // bit of the JSON is missing. + action = "IGNORE"; + } + reject({err: err, response: response, wkAction: action}); + return; + } + + try { + resolve(JSON.parse(body)); + } catch (e) { + console.error(e); + if (e.name === "SyntaxError") { + reject({wkAction: "FAIL_PROMPT", wkError: "Invalid JSON"}); + } else throw e; + } + }, + ); + }); + }, + _initLoginLogic: function(hsUrl, isUrl) { const self = this; hsUrl = hsUrl || this.state.enteredHomeserverUrl; @@ -393,6 +535,7 @@ module.exports = React.createClass({ initialPhoneCountry={this.state.phoneCountry} initialPhoneNumber={this.state.phoneNumber} onUsernameChanged={this.onUsernameChanged} + onUsernameBlur={this.onUsernameBlur} onPhoneCountryChanged={this.onPhoneCountryChanged} onPhoneNumberChanged={this.onPhoneNumberChanged} onForgotPasswordClick={this.props.onForgotPasswordClick} @@ -416,6 +559,8 @@ module.exports = React.createClass({ const ServerConfig = sdk.getComponent("login.ServerConfig"); const loader = this.state.busy ?
: null; + const errorText = this.state.discoveryError || this.state.errorText; + let loginAsGuestJsx; if (this.props.enableGuest) { loginAsGuestJsx = @@ -430,8 +575,8 @@ module.exports = React.createClass({ if (!SdkConfig.get()['disable_custom_urls']) { serverConfig = { _t('Sign in') } { loader }; } else { - if (!this.state.errorText) { + if (!errorText) { header =

{ _t('Sign in to get started') } { loader }

; } } let errorTextSection; - if (this.state.errorText) { + if (errorText) { errorTextSection = (
- { this.state.errorText } + { errorText }
); } diff --git a/src/components/views/login/PasswordLogin.js b/src/components/views/login/PasswordLogin.js index a0e5ab0ddb..6a5577fb62 100644 --- a/src/components/views/login/PasswordLogin.js +++ b/src/components/views/login/PasswordLogin.js @@ -30,6 +30,7 @@ class PasswordLogin extends React.Component { static defaultProps = { onError: function() {}, onUsernameChanged: function() {}, + onUsernameBlur: function() {}, onPasswordChanged: function() {}, onPhoneCountryChanged: function() {}, onPhoneNumberChanged: function() {}, @@ -53,6 +54,7 @@ class PasswordLogin extends React.Component { this.onSubmitForm = this.onSubmitForm.bind(this); this.onUsernameChanged = this.onUsernameChanged.bind(this); + this.onUsernameBlur = this.onUsernameBlur.bind(this); this.onLoginTypeChange = this.onLoginTypeChange.bind(this); this.onPhoneCountryChanged = this.onPhoneCountryChanged.bind(this); this.onPhoneNumberChanged = this.onPhoneNumberChanged.bind(this); @@ -124,6 +126,10 @@ class PasswordLogin extends React.Component { this.props.onUsernameChanged(ev.target.value); } + onUsernameBlur(ev) { + this.props.onUsernameBlur(this.state.username); + } + onLoginTypeChange(loginType) { this.props.onError(null); // send a null error to clear any error messages this.setState({ @@ -167,6 +173,7 @@ class PasswordLogin extends React.Component { type="text" name="username" // make it a little easier for browser's remember-password onChange={this.onUsernameChanged} + onBlur={this.onUsernameBlur} placeholder="joe@example.com" value={this.state.username} autoFocus @@ -182,6 +189,7 @@ class PasswordLogin extends React.Component { type="text" name="username" // make it a little easier for browser's remember-password onChange={this.onUsernameChanged} + onBlur={this.onUsernameBlur} placeholder={SdkConfig.get().disable_custom_urls ? _t("Username on %(hs)s", { hs: this.props.hsUrl.replace(/^https?:\/\//, ''), diff --git a/src/components/views/login/ServerConfig.js b/src/components/views/login/ServerConfig.js index a6944ec20a..2f04011273 100644 --- a/src/components/views/login/ServerConfig.js +++ b/src/components/views/login/ServerConfig.js @@ -70,6 +70,23 @@ module.exports = React.createClass({ }; }, + componentWillReceiveProps: function(newProps) { + if (newProps.customHsUrl === this.state.hs_url && + newProps.customIsUrl === this.state.is_url) return; + + this.setState({ + hs_url: newProps.customHsUrl, + is_url: newProps.customIsUrl, + configVisible: !newProps.withToggleButton || + (newProps.customHsUrl !== newProps.defaultHsUrl) || + (newProps.customIsUrl !== newProps.defaultIsUrl), + }); + this.props.onServerConfigChange({ + hsUrl: newProps.customHsUrl, + isUrl: newProps.customIsUrl, + }); + }, + onHomeserverChanged: function(ev) { this.setState({hs_url: ev.target.value}, function() { this._hsTimeoutId = this._waitThenInvoke(this._hsTimeoutId, function() { diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 557ef62edf..f9980f5645 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1288,7 +1288,10 @@ "Incorrect username and/or password.": "Incorrect username and/or password.", "Please note you are logging into the %(hs)s server, not matrix.org.": "Please note you are logging into the %(hs)s server, not matrix.org.", "Guest access is disabled on this Home Server.": "Guest access is disabled on this Home Server.", + "Failed to perform homeserver discovery": "Failed to perform homeserver discovery", "The phone number entered looks invalid": "The phone number entered looks invalid", + "Invalid homeserver discovery response": "Invalid homeserver discovery response", + "Cannot find homeserver": "Cannot find homeserver", "This homeserver doesn't offer any login flows which are supported by this client.": "This homeserver doesn't offer any login flows which are supported by this client.", "Error: Problem communicating with the given homeserver.": "Error: Problem communicating with the given homeserver.", "Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. Either use HTTPS or enable unsafe scripts.": "Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. Either use HTTPS or enable unsafe scripts.",