Merge remote-tracking branch 'origin/develop' into aviraldg-babelrc

pull/21833/head
David Baker 2016-10-13 09:49:26 +01:00
commit 74b443f0d3
9 changed files with 678 additions and 79 deletions

View File

@ -54,6 +54,9 @@ class Signup {
* This exists for the lifetime of a user's attempt to register an account,
* so if their registration attempt fails for whatever reason and they
* try again, call register() on the same instance again.
*
* TODO: parts of this overlap heavily with InteractiveAuth in the js-sdk. It
* would be nice to make use of that rather than rolling our own version of it.
*/
class Register extends Signup {
constructor(hsUrl, isUrl, opts) {
@ -130,6 +133,18 @@ class Register extends Signup {
this.password = password;
const client = this._createTemporaryClient();
this.activeStage = null;
// If there hasn't been a client secret set by this point,
// generate one for this session. It will only be used if
// we do email verification, but far simpler to just make
// sure we have one.
// We re-use this same secret over multiple calls to register
// so that the identity server can honour the sendAttempt
// parameter and not re-send email unless we actually want
// another mail to be sent.
if (!this.params.clientSecret) {
this.params.clientSecret = client.generateClientSecret();
}
return this._tryRegister(client);
}

View File

@ -53,66 +53,24 @@ 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 <div> so we can
// render to it.
// called when the recaptcha has been completed.
onReceiveData(data) {
if (!data || !data.divId) {
if (!data || !data.response) {
return;
}
this.divId = data.divId;
this._attemptRender();
this.defer.resolve({
auth: {
type: 'm.login.recaptcha',
response: data.response,
}
});
}
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";
@ -158,7 +116,11 @@ class EmailIdentityStage extends Stage {
return this._completeVerify();
}
this.clientSecret = this.client.generateClientSecret();
this.clientSecret = this.signupInstance.params.clientSecret;
if (!this.clientSecret) {
return q.reject(new Error("No client secret specified by Signup class!"));
}
var nextLink = this.signupInstance.params.registrationUrl +
'?client_secret=' +
encodeURIComponent(this.clientSecret) +

View File

@ -77,6 +77,8 @@ import views$dialogs$EncryptedEventDialog from './components/views/dialogs/Encry
module.exports.components['views.dialogs.EncryptedEventDialog'] = views$dialogs$EncryptedEventDialog;
import views$dialogs$ErrorDialog from './components/views/dialogs/ErrorDialog';
module.exports.components['views.dialogs.ErrorDialog'] = views$dialogs$ErrorDialog;
import views$dialogs$InteractiveAuthDialog from './components/views/dialogs/InteractiveAuthDialog';
module.exports.components['views.dialogs.InteractiveAuthDialog'] = views$dialogs$InteractiveAuthDialog;
import views$dialogs$LogoutPrompt from './components/views/dialogs/LogoutPrompt';
module.exports.components['views.dialogs.LogoutPrompt'] = views$dialogs$LogoutPrompt;
import views$dialogs$NeedToRegisterDialog from './components/views/dialogs/NeedToRegisterDialog';
@ -117,6 +119,8 @@ import views$login$CasLogin from './components/views/login/CasLogin';
module.exports.components['views.login.CasLogin'] = views$login$CasLogin;
import views$login$CustomServerDialog from './components/views/login/CustomServerDialog';
module.exports.components['views.login.CustomServerDialog'] = views$login$CustomServerDialog;
import views$login$InteractiveAuthEntryComponents from './components/views/login/InteractiveAuthEntryComponents';
module.exports.components['views.login.InteractiveAuthEntryComponents'] = views$login$InteractiveAuthEntryComponents;
import views$login$LoginFooter from './components/views/login/LoginFooter';
module.exports.components['views.login.LoginFooter'] = views$login$LoginFooter;
import views$login$LoginHeader from './components/views/login/LoginHeader';

View File

@ -28,6 +28,10 @@ var CaptchaForm = require("../../views/login/CaptchaForm");
var MIN_PASSWORD_LENGTH = 6;
/**
* TODO: It would be nice to make use of the InteractiveAuthEntryComponents
* here, rather than inventing our own.
*/
module.exports = React.createClass({
displayName: 'Registration',
@ -228,12 +232,9 @@ module.exports = React.createClass({
});
},
onCaptchaLoaded: function(divIdName) {
onCaptchaResponse: function(response) {
this.registerLogic.tellStage("m.login.recaptcha", {
divId: divIdName
});
this.setState({
busy: false // requires user input
response: response
});
},
@ -267,8 +268,15 @@ module.exports = React.createClass({
);
break;
case "Register.STEP_m.login.recaptcha":
var publicKey;
var serverParams = this.registerLogic.getServerData().params;
if (serverParams && serverParams["m.login.recaptcha"]) {
publicKey = serverParams["m.login.recaptcha"].public_key;
}
registerStep = (
<CaptchaForm onCaptchaLoaded={this.onCaptchaLoaded} />
<CaptchaForm sitePublicKey={publicKey}
onCaptchaResponse={this.onCaptchaResponse}
/>
);
break;
default:

View File

@ -0,0 +1,219 @@
/*
Copyright 2016 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.
*/
import Matrix from 'matrix-js-sdk';
const InteractiveAuth = Matrix.InteractiveAuth;
import React from 'react';
import sdk from '../../../index';
import {getEntryComponentForLoginType} from '../login/InteractiveAuthEntryComponents';
export default React.createClass({
displayName: 'InteractiveAuthDialog',
propTypes: {
// response from initial request. If not supplied, will do a request on
// mount.
authData: React.PropTypes.shape({
flows: React.PropTypes.array,
params: React.PropTypes.object,
session: React.PropTypes.string,
}),
// callback
makeRequest: React.PropTypes.func.isRequired,
onFinished: React.PropTypes.func.isRequired,
title: React.PropTypes.string,
submitButtonLabel: React.PropTypes.string,
},
getDefaultProps: function() {
return {
title: "Authentication",
submitButtonLabel: "Submit",
};
},
getInitialState: function() {
return {
authStage: null,
busy: false,
errorText: null,
stageErrorText: null,
submitButtonEnabled: false,
};
},
componentWillMount: function() {
this._unmounted = false;
this._authLogic = new InteractiveAuth({
authData: this.props.authData,
doRequest: this._requestCallback,
startAuthStage: this._startAuthStage,
});
this._authLogic.attemptAuth().then((result) => {
this.props.onFinished(true, result);
}).catch((error) => {
console.error("Error during user-interactive auth:", error);
if (this._unmounted) {
return;
}
const msg = error.message || error.toString();
this.setState({
errorText: msg
});
}).done();
},
componentWillUnmount: function() {
this._unmounted = true;
},
_startAuthStage: function(stageType, error) {
this.setState({
authStage: stageType,
errorText: error ? error.error : null,
}, this._setFocus);
},
_requestCallback: function(auth) {
this.setState({
busy: true,
errorText: null,
stageErrorText: null,
});
return this.props.makeRequest(auth).finally(() => {
if (this._unmounted) {
return;
}
this.setState({
busy: false,
});
});
},
_onKeyDown: function(e) {
if (e.keyCode === 27) { // escape
e.stopPropagation();
e.preventDefault();
if (!this.state.busy) {
this._onCancel();
}
}
else if (e.keyCode === 13) { // enter
e.stopPropagation();
e.preventDefault();
if (this.state.submitButtonEnabled && !this.state.busy) {
this._onSubmit();
}
}
},
_onSubmit: function() {
if (this.refs.stageComponent && this.refs.stageComponent.onSubmitClick) {
this.refs.stageComponent.onSubmitClick();
}
},
_setFocus: function() {
if (this.refs.stageComponent && this.refs.stageComponent.focus) {
this.refs.stageComponent.focus();
}
},
_onCancel: function() {
this.props.onFinished(false);
},
_setSubmitButtonEnabled: function(enabled) {
this.setState({
submitButtonEnabled: enabled,
});
},
_submitAuthDict: function(authData) {
this._authLogic.submitAuthDict(authData);
},
_renderCurrentStage: function() {
const stage = this.state.authStage;
var StageComponent = getEntryComponentForLoginType(stage);
return (
<StageComponent ref="stageComponent"
loginType={stage}
authSessionId={this._authLogic.getSessionId()}
stageParams={this._authLogic.getStageParams(stage)}
submitAuthDict={this._submitAuthDict}
setSubmitButtonEnabled={this._setSubmitButtonEnabled}
errorText={this.state.stageErrorText}
/>
);
},
render: function() {
const Loader = sdk.getComponent("elements.Spinner");
let error = null;
if (this.state.errorText) {
error = (
<div className="error">
{this.state.errorText}
</div>
);
}
const submitLabel = this.state.busy ? <Loader /> : this.props.submitButtonLabel;
const submitEnabled = this.state.submitButtonEnabled && !this.state.busy;
const submitButton = (
<button className="mx_Dialog_primary"
onClick={this._onSubmit}
disabled={!submitEnabled}
>
{submitLabel}
</button>
);
const cancelButton = (
<button onClick={this._onCancel}>
Cancel
</button>
);
return (
<div className="mx_InteractiveAuthDialog" onKeyDown={this._onKeyDown}>
<div className="mx_Dialog_title">
{this.props.title}
</div>
<div className="mx_Dialog_content">
<p>This operation requires additional authentication.</p>
{this._renderCurrentStage()}
{error}
</div>
<div className="mx_Dialog_buttons">
{submitButton}
{cancelButton}
</div>
</div>
);
},
});

View File

@ -26,28 +26,34 @@ module.exports = React.createClass({
displayName: 'CaptchaForm',
propTypes: {
onCaptchaLoaded: React.PropTypes.func.isRequired // called with div id name
sitePublicKey: React.PropTypes.string,
// called with the captcha response
onCaptchaResponse: React.PropTypes.func,
},
getDefaultProps: function() {
return {
onCaptchaLoaded: function() {
console.error("Unhandled onCaptchaLoaded");
}
onCaptchaResponse: () => {},
};
},
getInitialState: function() {
return {
errorText: null,
};
},
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) {
if (global.grecaptcha) {
// already loaded
this._onCaptchaLoaded();
} else {
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);
};
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"
);
@ -55,12 +61,53 @@ module.exports = React.createClass({
}
},
_renderRecaptcha: function(divId) {
if (!global.grecaptcha) {
console.error("grecaptcha not loaded!");
throw new Error("Recaptcha did not load successfully");
}
var publicKey = this.props.sitePublicKey;
if (!publicKey) {
console.error("No public key for recaptcha!");
throw new Error(
"This server has not supplied enough information for Recaptcha "
+ "authentication");
}
console.log("Rendering to %s", divId);
global.grecaptcha.render(divId, {
sitekey: publicKey,
callback: this.props.onCaptchaResponse,
});
},
_onCaptchaLoaded: function() {
console.log("Loaded recaptcha script.");
try {
this._renderRecaptcha(DIV_ID);
} catch (e) {
this.setState({
errorText: e.toString(),
})
}
},
render: function() {
// FIXME: Tight coupling with the div id and SignupStages.js
let error = null;
if (this.state.errorText) {
error = (
<div className="error">
{this.state.errorText}
</div>
);
}
return (
<div ref="recaptchaContainer">
This Home Server would like to make sure you are not a robot
<div id={DIV_ID}></div>
{error}
</div>
);
}

View File

@ -0,0 +1,212 @@
/*
Copyright 2016 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.
*/
import React from 'react';
import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg';
/* This file contains a collection of components which are used by the
* InteractiveAuthDialog to prompt the user to enter the information needed
* for an auth stage. (The intention is that they could also be used for other
* components, such as the registration flow).
*
* Call getEntryComponentForLoginType() to get a component suitable for a
* particular login type. Each component requires the same properties:
*
* loginType: the login type of the auth stage being attempted
* authSessionId: session id from the server
* stageParams: params from the server for the stage being attempted
* errorText: error message from a previous attempt to authenticate
* submitAuthDict: a function which will be called with the new auth dict
* setSubmitButtonEnabled: a function which will enable/disable the 'submit' button
*
* Each component may also provide the following functions (beyond the standard React ones):
* onSubmitClick: handle a 'submit' button click
* focus: set the input focus appropriately in the form.
*/
export const PasswordAuthEntry = React.createClass({
displayName: 'PasswordAuthEntry',
statics: {
LOGIN_TYPE: "m.login.password",
},
propTypes: {
submitAuthDict: React.PropTypes.func.isRequired,
setSubmitButtonEnabled: React.PropTypes.func.isRequired,
errorText: React.PropTypes.string,
},
componentWillMount: function() {
this.props.setSubmitButtonEnabled(false);
},
focus: function() {
if (this.refs.passwordField) {
this.refs.passwordField.focus();
}
},
onSubmitClick: function() {
this.props.submitAuthDict({
type: PasswordAuthEntry.LOGIN_TYPE,
user: MatrixClientPeg.get().credentials.userId,
password: this.refs.passwordField.value,
});
},
_onPasswordFieldChange: function (ev) {
// enable the submit button iff the password is non-empty
this.props.setSubmitButtonEnabled(Boolean(ev.target.value));
},
render: function() {
let passwordBoxClass = null;
if (this.props.errorText) {
passwordBoxClass = 'error';
}
return (
<div>
<p>To continue, please enter your password.</p>
<p>Password:</p>
<input
ref="passwordField"
className={passwordBoxClass}
onChange={this._onPasswordFieldChange}
type="password"
/>
<div className="error">
{this.props.errorText}
</div>
</div>
);
},
});
export const RecaptchaAuthEntry = React.createClass({
displayName: 'RecaptchaAuthEntry',
statics: {
LOGIN_TYPE: "m.login.recaptcha",
},
propTypes: {
submitAuthDict: React.PropTypes.func.isRequired,
stageParams: React.PropTypes.object.isRequired,
setSubmitButtonEnabled: React.PropTypes.func.isRequired,
errorText: React.PropTypes.string,
},
componentWillMount: function() {
this.props.setSubmitButtonEnabled(false);
},
_onCaptchaResponse: function(response) {
this.props.submitAuthDict({
type: RecaptchaAuthEntry.LOGIN_TYPE,
response: response,
});
},
render: function() {
const CaptchaForm = sdk.getComponent("views.login.CaptchaForm");
var sitePublicKey = this.props.stageParams.public_key;
return (
<div>
<CaptchaForm sitePublicKey={sitePublicKey}
onCaptchaResponse={this._onCaptchaResponse}
/>
<div className="error">
{this.props.errorText}
</div>
</div>
);
},
});
export const FallbackAuthEntry = React.createClass({
displayName: 'FallbackAuthEntry',
propTypes: {
authSessionId: React.PropTypes.string.isRequired,
loginType: React.PropTypes.string.isRequired,
submitAuthDict: React.PropTypes.func.isRequired,
setSubmitButtonEnabled: React.PropTypes.func.isRequired,
errorText: React.PropTypes.string,
},
componentWillMount: function() {
// we have to make the user click a button, as browsers will block
// the popup if we open it immediately.
this._popupWindow = null;
this.props.setSubmitButtonEnabled(true);
window.addEventListener("message", this._onReceiveMessage);
},
componentWillUnmount: function() {
window.removeEventListener("message", this._onReceiveMessage);
if (this._popupWindow) {
this._popupWindow.close();
}
},
onSubmitClick: function() {
var url = MatrixClientPeg.get().getFallbackAuthUrl(
this.props.loginType,
this.props.authSessionId
);
this._popupWindow = window.open(url);
this.props.setSubmitButtonEnabled(false);
},
_onReceiveMessage: function(event) {
if (
event.data === "authDone" &&
event.origin === MatrixClientPeg.get().getHomeserverUrl()
) {
this.props.submitAuthDict({});
}
},
render: function() {
return (
<div>
Click "Submit" to authenticate
<div className="error">
{this.props.errorText}
</div>
</div>
);
},
});
const AuthEntryComponents = [
PasswordAuthEntry,
RecaptchaAuthEntry,
];
export function getEntryComponentForLoginType(loginType) {
for (var c of AuthEntryComponents) {
if (c.LOGIN_TYPE == loginType) {
return c;
}
}
return FallbackAuthEntry;
};

View File

@ -21,6 +21,7 @@ import q from 'q';
import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg';
import DateUtils from '../../../DateUtils';
import Modal from '../../../Modal';
export default class DevicesPanelEntry extends React.Component {
constructor(props, context) {
@ -35,6 +36,7 @@ export default class DevicesPanelEntry extends React.Component {
this._onDeleteClick = this._onDeleteClick.bind(this);
this._onDisplayNameChanged = this._onDisplayNameChanged.bind(this);
this._makeDeleteRequest = this._makeDeleteRequest.bind(this);
}
componentWillUnmount() {
@ -52,22 +54,44 @@ export default class DevicesPanelEntry extends React.Component {
}
_onDeleteClick() {
const device = this.props.device;
this.setState({deleting: true});
MatrixClientPeg.get().deleteDevice(device.device_id).done(
() => {
this.props.onDeleted();
// try without interactive auth to start off
this._makeDeleteRequest(null).catch((error) => {
if (this._unmounted) { return; }
this.setState({ deleting: false });
},
(e) => {
if (error.httpStatus !== 401 || !error.data || !error.data.flows) {
// doesn't look like an interactive-auth failure
throw e;
}
// pop up an interactive auth dialog
var InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog");
Modal.createDialog(InteractiveAuthDialog, {
authData: error.data,
makeRequest: this._makeDeleteRequest,
});
this.setState({
deleting: false,
});
}).catch((e) => {
console.error("Error deleting device", e);
if (this._unmounted) { return; }
this.setState({
deleting: false,
deleteError: "Failed to delete device",
});
}).done();
}
_makeDeleteRequest(auth) {
const device = this.props.device;
return MatrixClientPeg.get().deleteDevice(device.device_id, auth).then(
() => {
this.props.onDeleted();
if (this._unmounted) { return; }
this.setState({ deleting: false });
}
);
}

View File

@ -0,0 +1,108 @@
/*
Copyright 2016 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.
*/
import expect from 'expect';
import q from 'q';
import React from 'react';
import ReactDOM from 'react-dom';
import ReactTestUtils from 'react-addons-test-utils';
import sinon from 'sinon';
import sdk from 'matrix-react-sdk';
import MatrixClientPeg from 'MatrixClientPeg';
import * as test_utils from '../../../test-utils';
const InteractiveAuthDialog = sdk.getComponent(
'views.dialogs.InteractiveAuthDialog'
);
describe('InteractiveAuthDialog', function () {
var parentDiv;
var sandbox;
beforeEach(function() {
test_utils.beforeEach(this);
sandbox = test_utils.stubClient(sandbox);
parentDiv = document.createElement('div');
document.body.appendChild(parentDiv);
});
afterEach(function() {
ReactDOM.unmountComponentAtNode(parentDiv);
parentDiv.remove();
sandbox.restore();
});
it('Should successfully complete a password flow', function(done) {
const onFinished = sinon.spy();
const doRequest = sinon.stub().returns(q({a:1}));
// tell the stub matrixclient to return a real userid
var client = MatrixClientPeg.get();
client.credentials = {userId: "@user:id"};
const dlg = ReactDOM.render(
<InteractiveAuthDialog
authData={{
session: "sess",
flows: [
{"stages":["m.login.password"]}
]
}}
makeRequest={doRequest}
onFinished={onFinished}
/>, parentDiv);
// at this point there should be a password box
const passwordNode = ReactTestUtils.findRenderedDOMComponentWithTag(
dlg, "input"
);
expect(passwordNode.type).toEqual("password");
// submit should be disabled
const submitNode = ReactTestUtils.findRenderedDOMComponentWithClass(
dlg, "mx_Dialog_primary"
);
expect(submitNode.disabled).toBe(true);
// put something in the password box, and hit enter; that should
// trigger a request
passwordNode.value = "s3kr3t";
ReactTestUtils.Simulate.change(passwordNode);
expect(submitNode.disabled).toBe(false);
ReactTestUtils.Simulate.keyDown(passwordNode, {
key: "Enter", keyCode: 13, which: 13,
});
expect(doRequest.callCount).toEqual(1);
expect(doRequest.calledWithExactly({
session: "sess",
type: "m.login.password",
password: "s3kr3t",
user: "@user:id",
})).toBe(true);
// the submit button should now be disabled (and be a spinner)
expect(submitNode.disabled).toBe(true);
// let the request complete
q.delay(1).then(() => {
expect(onFinished.callCount).toEqual(1);
expect(onFinished.calledWithExactly(true, {a:1})).toBe(true);
}).done(done, done);
});
});