diff --git a/src/Signup.js b/src/Signup.js index ba91ff60b0..02ddaacc6d 100644 --- a/src/Signup.js +++ b/src/Signup.js @@ -1,16 +1,272 @@ "use strict"; var MatrixClientPeg = require("./MatrixClientPeg"); +var SignupStages = require("./SignupStages"); var dis = require("./dispatcher"); var q = require("q"); -class Register { +const EMAIL_STAGE_TYPE = "m.login.email.identity"; -} - -class Login { +/** + * A base class for common functionality between Registration and Login e.g. + * storage of HS/IS URLs. + */ +class Signup { constructor(hsUrl, isUrl) { this._hsUrl = hsUrl; this._isUrl = isUrl; + } + + getHomeserverUrl() { + return this._hsUrl; + } + + getIdentityServerUrl() { + return this._isUrl; + } + + setHomeserverUrl(hsUrl) { + this._hsUrl = hsUrl; + } + + setIdentityServerUrl(isUrl) { + this._isUrl = isUrl; + } +} + +/** + * Registration logic class + */ +class Register extends Signup { + constructor(hsUrl, isUrl) { + super(hsUrl, isUrl); + this.setStep("START"); + this.data = null; // from the server + // random other stuff (e.g. query params, NOT params from the server) + this.params = {}; + this.credentials = null; + this.activeStage = null; + this.registrationPromise = null; + // These values MUST be undefined else we'll send "username: null" which + // will error on Synapse rather than having the key absent. + this.username = undefined; // desired + this.email = undefined; // desired + this.password = undefined; // desired + } + + setClientSecret(secret) { + this.params.clientSecret = secret; + } + + setSessionId(sessionId) { + this.params.sessionId = sessionId; + } + + setRegistrationUrl(regUrl) { + this.params.registrationUrl = regUrl; + } + + setIdSid(idSid) { + this.params.idSid = idSid; + } + + getStep() { + return this._step; + } + + getCredentials() { + return this.credentials; + } + + getServerData() { + return this.data || {}; + } + + getPromise() { + return this.registrationPromise; + } + + setStep(step) { + this._step = 'Register.' + step; + // TODO: + // It's a shame this is going to the global dispatcher, we only really + // want things which have an instance of this class to be able to add + // listeners... + console.log("Dispatching 'registration_step_update' for step %s", this._step); + dis.dispatch({ + action: "registration_step_update" + }); + } + + register(formVals) { + var {username, password, email} = formVals; + this.email = email; + this.username = username; + this.password = password; + + // feels a bit wrong to be clobbering the global client for something we + // don't even know if it'll work, but we'll leave this here for now to + // not complicate matters further. It would be nicer to isolate this + // logic entirely from the rest of the app though. + MatrixClientPeg.replaceUsingUrls( + this._hsUrl, + this._isUrl + ); + return this._tryRegister(); + } + + _tryRegister(authDict) { + var self = this; + return MatrixClientPeg.get().register( + this.username, this.password, this.params.sessionId, authDict + ).then(function(result) { + self.credentials = result; + self.setStep("COMPLETE"); + return result; // contains the credentials + }, function(error) { + if (error.httpStatus === 401 && error.data && error.data.flows) { + self.data = error.data || {}; + var flow = self.chooseFlow(error.data.flows); + + if (flow) { + console.log("Active flow => %s", JSON.stringify(flow)); + var flowStage = self.firstUncompletedStage(flow); + return self.startStage(flowStage); + } + else { + throw new Error("Unable to register - missing email address?"); + } + } else { + if (error.errcode === 'M_USER_IN_USE') { + throw new Error("Username in use"); + } else if (error.httpStatus == 401) { + throw new Error("Authorisation failed!"); + } else if (error.httpStatus >= 400 && error.httpStatus < 500) { + throw new Error(`Registration failed! (${error.httpStatus})`); + } else if (error.httpStatus >= 500 && error.httpStatus < 600) { + throw new Error( + `Server error during registration! (${error.httpStatus})` + ); + } else if (error.name == "M_MISSING_PARAM") { + // The HS hasn't remembered the login params from + // the first try when the login email was sent. + throw new Error( + "This home server does not support resuming registration." + ); + } + } + }); + } + + firstUncompletedStage(flow) { + for (var i = 0; i < flow.stages.length; ++i) { + if (!this.hasCompletedStage(flow.stages[i])) { + return flow.stages[i]; + } + } + } + + hasCompletedStage(stageType) { + var completed = (this.data || {}).completed || []; + return completed.indexOf(stageType) !== -1; + } + + startStage(stageName) { + var self = this; + this.setStep(`STEP_${stageName}`); + var StageClass = SignupStages[stageName]; + if (!StageClass) { + // no idea how to handle this! + throw new Error("Unknown stage: " + stageName); + } + + var stage = new StageClass(MatrixClientPeg.get(), this); + this.activeStage = stage; + return stage.complete().then(function(request) { + if (request.auth) { + console.log("Stage %s is returning an auth dict", stageName); + return self._tryRegister(request.auth); + } + else { + // never resolve the promise chain. This is for things like email auth + // which display a "check your email" message and relies on the + // link in the email to actually register you. + console.log("Waiting for external action."); + return q.defer().promise; + } + }); + } + + chooseFlow(flows) { + // If the user gave us an email then we want to pick an email + // flow we can do, else any other flow. + var emailFlow = null; + var otherFlow = null; + flows.forEach(function(flow) { + var flowHasEmail = false; + for (var stageI = 0; stageI < flow.stages.length; ++stageI) { + var stage = flow.stages[stageI]; + + if (!SignupStages[stage]) { + // we can't do this flow, don't have a Stage impl. + return; + } + + if (stage === EMAIL_STAGE_TYPE) { + flowHasEmail = true; + } + } + + if (flowHasEmail) { + emailFlow = flow; + } else { + otherFlow = flow; + } + }); + + if (this.email || this.hasCompletedStage(EMAIL_STAGE_TYPE)) { + // we've been given an email or we've already done an email part + return emailFlow; + } else { + return otherFlow; + } + } + + recheckState() { + // feels a bit wrong to be clobbering the global client for something we + // don't even know if it'll work, but we'll leave this here for now to + // not complicate matters further. It would be nicer to isolate this + // logic entirely from the rest of the app though. + MatrixClientPeg.replaceUsingUrls( + this._hsUrl, + this._isUrl + ); + // We've been given a bunch of data from a previous register step, + // this only happens for email auth currently. It's kinda ming we need + // to know this though. A better solution would be to ask the stages if + // they are ready to do something rather than accepting that we know about + // email auth and its internals. + this.params.hasEmailInfo = ( + this.params.clientSecret && this.params.sessionId && this.params.idSid + ); + + if (this.params.hasEmailInfo) { + this.registrationPromise = this.startStage(EMAIL_STAGE_TYPE); + } + return this.registrationPromise; + } + + tellStage(stageName, data) { + if (this.activeStage && this.activeStage.type === stageName) { + console.log("Telling stage %s about something..", stageName); + this.activeStage.onReceiveData(data); + } + } +} + + +class Login extends Signup { + constructor(hsUrl, isUrl) { + super(hsUrl, isUrl); this._currentFlowIndex = 0; this._flows = []; } diff --git a/src/SignupStages.js b/src/SignupStages.js new file mode 100644 index 0000000000..272a955d95 --- /dev/null +++ b/src/SignupStages.js @@ -0,0 +1,196 @@ +"use strict"; +var q = require("q"); + +/** + * An interface class which login types should abide by. + */ +class Stage { + constructor(type, matrixClient, signupInstance) { + this.type = type; + this.client = matrixClient; + this.signupInstance = signupInstance; + } + + complete() { + // Return a promise which is: + // RESOLVED => With an Object which has an 'auth' key which is the auth dict + // to submit. + // REJECTED => With an Error if there was a problem with this stage. + // Has a "message" string and an "isFatal" flag. + return q.reject("NOT IMPLEMENTED"); + } + + onReceiveData() { + // NOP + } +} +Stage.TYPE = "NOT IMPLEMENTED"; + + +/** + * This stage requires no auth. + */ +class DummyStage extends Stage { + constructor(matrixClient, signupInstance) { + super(DummyStage.TYPE, matrixClient, signupInstance); + } + + complete() { + return q({ + auth: { + type: DummyStage.TYPE + } + }); + } +} +DummyStage.TYPE = "m.login.dummy"; + + +/** + * This stage uses Google's Recaptcha to do auth. + */ +class RecaptchaStage extends Stage { + constructor(matrixClient, signupInstance) { + super(RecaptchaStage.TYPE, matrixClient, signupInstance); + this.defer = q.defer(); // resolved with the captcha response + this.publicKey = null; // from the HS + this.divId = null; // from the UI component + } + + // called when the UI component has loaded the recaptcha
so we can + // render to it. + onReceiveData(data) { + if (!data || !data.divId) { + return; + } + this.divId = data.divId; + this._attemptRender(); + } + + complete() { + var publicKey; + var serverParams = this.signupInstance.getServerData().params; + if (serverParams && serverParams["m.login.recaptcha"]) { + publicKey = serverParams["m.login.recaptcha"].public_key; + } + if (!publicKey) { + return q.reject({ + message: "This server has not supplied enough information for Recaptcha " + + "authentication", + isFatal: true + }); + } + this.publicKey = publicKey; + this._attemptRender(); + return this.defer.promise; + } + + _attemptRender() { + if (!global.grecaptcha) { + console.error("grecaptcha not loaded!"); + return; + } + if (!this.publicKey) { + console.error("No public key for recaptcha!"); + return; + } + if (!this.divId) { + console.error("No div ID specified!"); + return; + } + console.log("Rendering to %s", this.divId); + var self = this; + global.grecaptcha.render(this.divId, { + sitekey: this.publicKey, + callback: function(response) { + console.log("Received captcha response"); + self.defer.resolve({ + auth: { + type: 'm.login.recaptcha', + response: response + } + }); + } + }); + } +} +RecaptchaStage.TYPE = "m.login.recaptcha"; + + +/** + * This state uses the IS to verify email addresses. + */ +class EmailIdentityStage extends Stage { + constructor(matrixClient, signupInstance) { + super(EmailIdentityStage.TYPE, matrixClient, signupInstance); + } + + _completeVerify() { + // pull out the host of the IS URL by creating an anchor element + var isLocation = document.createElement('a'); + isLocation.href = this.signupInstance.getIdentityServerUrl(); + + return q({ + auth: { + type: 'm.login.email.identity', + threepid_creds: { + sid: this.signupInstance.params.idSid, + client_secret: this.signupInstance.params.clientSecret, + id_server: isLocation.host + } + } + }); + } + + /** + * Complete the email stage. + * + * This is called twice under different circumstances: + * 1) When requesting an email token from the IS + * 2) When validating query parameters received from the link in the email + */ + complete() { + // TODO: The Registration class shouldn't really know this info. + if (this.signupInstance.params.hasEmailInfo) { + return this._completeVerify(); + } + + var clientSecret = this.client.generateClientSecret(); + var nextLink = this.signupInstance.params.registrationUrl + + '?client_secret=' + + encodeURIComponent(clientSecret) + + "&hs_url=" + + encodeURIComponent(this.signupInstance.getHomeserverUrl()) + + "&is_url=" + + encodeURIComponent(this.signupInstance.getIdentityServerUrl()) + + "&session_id=" + + encodeURIComponent(this.signupInstance.getServerData().session); + + return this.client.requestEmailToken( + this.signupInstance.email, + clientSecret, + 1, // TODO: Multiple send attempts? + nextLink + ).then(function(response) { + return {}; // don't want to make a request + }, function(error) { + console.error(error); + var e = { + isFatal: true + }; + if (error.errcode == 'THREEPID_IN_USE') { + e.message = "Email in use"; + } else { + e.message = 'Unable to contact the given identity server'; + } + return e; + }); + } +} +EmailIdentityStage.TYPE = "m.login.email.identity"; + +module.exports = { + [DummyStage.TYPE]: DummyStage, + [RecaptchaStage.TYPE]: RecaptchaStage, + [EmailIdentityStage.TYPE]: EmailIdentityStage +}; \ No newline at end of file diff --git a/src/components/login/CaptchaForm.js b/src/components/login/CaptchaForm.js new file mode 100644 index 0000000000..9b722f463b --- /dev/null +++ b/src/components/login/CaptchaForm.js @@ -0,0 +1,67 @@ +/* +Copyright 2015 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +'use strict'; + +var React = require('react'); +var DIV_ID = 'mx_recaptcha'; + +/** + * A pure UI component which displays a captcha form. + */ +module.exports = React.createClass({ + displayName: 'CaptchaForm', + + propTypes: { + onCaptchaLoaded: React.PropTypes.func.isRequired // called with div id name + }, + + getDefaultProps: function() { + return { + onCaptchaLoaded: function() { + console.error("Unhandled onCaptchaLoaded"); + } + }; + }, + + componentDidMount: function() { + // Just putting a script tag into the returned jsx doesn't work, annoyingly, + // so we do this instead. + var self = this; + if (this.refs.recaptchaContainer) { + console.log("Loading recaptcha script..."); + var scriptTag = document.createElement('script'); + window.mx_on_recaptcha_loaded = function() { + console.log("Loaded recaptcha script."); + self.props.onCaptchaLoaded(DIV_ID); + }; + scriptTag.setAttribute( + 'src', global.location.protocol+"//www.google.com/recaptcha/api.js?onload=mx_on_recaptcha_loaded&render=explicit" + ); + this.refs.recaptchaContainer.appendChild(scriptTag); + } + }, + + render: function() { + // FIXME: Tight coupling with the div id and SignupStages.js + return ( +
+ This Home Server would like to make sure you are not a robot +
+
+ ); + } +}); \ No newline at end of file diff --git a/src/controllers/templates/Register.js b/src/controllers/templates/Register.js deleted file mode 100644 index a3dac5b9d0..0000000000 --- a/src/controllers/templates/Register.js +++ /dev/null @@ -1,352 +0,0 @@ -/* -Copyright 2015 OpenMarket Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -var MatrixClientPeg = require("../../MatrixClientPeg"); -var dis = require("../../dispatcher"); - -module.exports = { - FieldErrors: { - PasswordMismatch: 'PasswordMismatch', - TooShort: 'TooShort', - Missing: 'Missing', - InUse: 'InUse', - Length: 'Length' - }, - - getInitialState: function() { - return { - step: 'initial', - busy: false, - currentStep: 0, - totalSteps: 1 - }; - }, - - componentWillMount: function() { - this.savedParams = { - email: '', - username: '', - password: '', - confirmPassword: '' - }; - this.readNewProps(); - }, - - componentWillReceiveProps: function() { - this.readNewProps(); - }, - - readNewProps: function() { - if (this.props.clientSecret && this.props.hsUrl && - this.props.isUrl && this.props.sessionId && - this.props.idSid) { - this.authSessionId = this.props.sessionId; - MatrixClientPeg.replaceUsingUrls( - this.props.hsUrl, - this.props.isUrl - ); - this.setState({ - hs_url: this.props.hsUrl, - is_url: this.props.isUrl - }); - this.savedParams = {client_secret: this.props.clientSecret}; - this.setState({busy: true}); - - var isLocation = document.createElement('a'); - isLocation.href = this.props.isUrl; - - var auth = { - type: 'm.login.email.identity', - threepid_creds: { - sid: this.props.idSid, - client_secret: this.savedParams.client_secret, - id_server: isLocation.host - } - }; - this.tryRegister(auth); - } - }, - - componentDidUpdate: function() { - // Just putting a script tag into the returned jsx doesn't work, annoyingly, - // so we do this instead. - if (this.refs.recaptchaContainer) { - var scriptTag = document.createElement('script'); - window.mx_on_recaptcha_loaded = this.onCaptchaLoaded; - scriptTag.setAttribute('src', global.location.protocol+"//www.google.com/recaptcha/api.js?onload=mx_on_recaptcha_loaded&render=explicit"); - this.refs.recaptchaContainer.appendChild(scriptTag); - } - }, - - setStep: function(step) { - this.setState({ step: step, errorText: '', busy: false }); - }, - - getSupportedStageTypes: function() { - return ['m.login.email.identity', 'm.login.recaptcha']; - }, - - chooseFlow: function(flows) { - // this is fairly simple right now - var supportedTypes = this.getSupportedStageTypes(); - - var emailFlow = null; - var otherFlow = null; - for (var flowI = 0; flowI < flows.length; ++flowI) { - var flow = flows[flowI]; - var flowHasEmail = false; - var flowSupported = true; - for (var stageI = 0; stageI < flow.stages.length; ++stageI) { - var stage = flow.stages[stageI]; - - if (supportedTypes.indexOf(stage) == -1) { - flowSupported = false; - } - - if (stage == 'm.login.email.identity') { - flowHasEmail = true; - } - } - if (flowSupported) { - if (flowHasEmail) { - emailFlow = flow; - } else { - otherFlow = flow; - } - } - } - - if ( - this.savedParams.email != '' || - this.completedStages.indexOf('m.login.email.identity') > -1 - ) { - return emailFlow; - } else { - return otherFlow; - } - }, - - firstUncompletedStageIndex: function(flow) { - if (this.completedStages === undefined) return 0; - for (var i = 0; i < flow.stages.length; ++i) { - if (this.completedStages.indexOf(flow.stages[i]) == -1) { - return i; - } - } - }, - - numCompletedStages: function(flow) { - if (this.completedStages === undefined) return 0; - var nCompleted = 0; - for (var i = 0; i < flow.stages.length; ++i) { - if (this.completedStages.indexOf(flow.stages[i]) > -1) { - ++nCompleted; - } - } - return nCompleted; - }, - - onInitialStageSubmit: function(ev) { - ev.preventDefault(); - - var formVals = this.getRegFormVals(); - this.savedParams = formVals; - - var badFields = {}; - if (formVals.password != formVals.confirmPassword) { - badFields.confirmPassword = this.FieldErrors.PasswordMismatch; - } - if (formVals.password == '') { - badFields.password = this.FieldErrors.Missing; - } else if (formVals.password.length < 6) { - badFields.password = this.FieldErrors.Length; - } - if (formVals.username == '') { - badFields.username = this.FieldErrors.Missing; - } - if (formVals.email == '') { - badFields.email = this.FieldErrors.Missing; - } - if (Object.keys(badFields).length > 0) { - this.onBadFields(badFields); - return; - } - - MatrixClientPeg.replaceUsingUrls( - this.getHsUrl(), - this.getIsUrl() - ); - this.setState({ - hs_url: this.getHsUrl(), - is_url: this.getIsUrl() - }); - this.setState({busy: true}); - - this.tryRegister(); - }, - - startStage: function(stageName) { - var self = this; - this.setStep('stage_'+stageName); - switch(stageName) { - case 'm.login.email.identity': - self.setState({ - busy: true - }); - var cli = MatrixClientPeg.get(); - this.savedParams.client_secret = cli.generateClientSecret(); - this.savedParams.send_attempt = 1; - - var nextLink = this.props.registrationUrl + - '?client_secret=' + - encodeURIComponent(this.savedParams.client_secret) + - "&hs_url=" + - encodeURIComponent(this.state.hs_url) + - "&is_url=" + - encodeURIComponent(this.state.is_url) + - "&session_id=" + - encodeURIComponent(this.authSessionId); - - cli.requestEmailToken( - this.savedParams.email, - this.savedParams.client_secret, - this.savedParams.send_attempt, - nextLink - ).done(function(response) { - self.setState({ - busy: false, - }); - self.setStep('stage_m.login.email.identity'); - }, function(error) { - console.error(error); - self.setStep('initial'); - var newState = {busy: false}; - if (error.errcode == 'THREEPID_IN_USE') { - self.onBadFields({email: self.FieldErrors.InUse}); - } else { - newState.errorText = 'Unable to contact the given identity server'; - } - self.setState(newState); - }); - break; - case 'm.login.recaptcha': - if (!this.authParams || !this.authParams['m.login.recaptcha'].public_key) { - this.setState({ - errorText: "This server has not supplied enough information for Recaptcha authentication" - }); - } - break; - } - }, - - onRegistered: function(user_id, access_token) { - MatrixClientPeg.replaceUsingAccessToken( - this.state.hs_url, this.state.is_url, user_id, access_token - ); - if (this.props.onLoggedIn) { - this.props.onLoggedIn(); - } - }, - - onCaptchaLoaded: function() { - if (this.refs.recaptchaContainer) { - var sitekey = this.authParams['m.login.recaptcha'].public_key; - global.grecaptcha.render('mx_recaptcha', { - 'sitekey': sitekey, - 'callback': this.onCaptchaDone - }); - } - }, - - onCaptchaDone: function(captcha_response) { - this.tryRegister({ - type: 'm.login.recaptcha', - response: captcha_response - }); - }, - - tryRegister: function(auth) { - var self = this; - MatrixClientPeg.get().register( - this.savedParams.username, - this.savedParams.password, - this.authSessionId, - auth - ).done(function(result) { - self.onRegistered(result.user_id, result.access_token); - }, function(error) { - if (error.httpStatus == 401 && error.data.flows) { - self.authParams = error.data.params; - self.authSessionId = error.data.session; - - self.completedStages = error.data.completed || []; - - var flow = self.chooseFlow(error.data.flows); - - if (flow) { - var flowStage = self.firstUncompletedStageIndex(flow); - var numDone = self.numCompletedStages(flow); - - self.setState({ - busy: false, - flows: flow, - currentStep: 1+numDone, - totalSteps: flow.stages.length+1, - flowStage: flowStage - }); - self.startStage(flow.stages[flowStage]); - } - else { - self.setState({ - busy: false, - errorText: "Unable to register - missing email address?" - }); - } - } else { - console.log(error); - self.setStep("initial"); - var newState = { - busy: false, - errorText: "Unable to contact the given Home Server" - }; - if (error.name == 'M_USER_IN_USE') { - delete newState.errorText; - self.onBadFields({ - username: self.FieldErrors.InUse - }); - } else if (error.httpStatus == 401) { - newState.errorText = "Authorisation failed!"; - } else if (error.httpStatus >= 400 && error.httpStatus < 500) { - newState.errorText = "Registration failed!"; - } else if (error.httpStatus >= 500 && error.httpStatus < 600) { - newState.errorText = "Server error during registration!"; - } else if (error.name == "M_MISSING_PARAM") { - // The HS hasn't remembered the login params from - // the first try when the login email was sent. - newState.errorText = "This home server does not support resuming registration."; - } - self.setState(newState); - } - }); - }, - - showLogin: function(ev) { - ev.preventDefault(); - dis.dispatch({ - action: 'start_login' - }); - } -};