diff --git a/Gruntfile.js b/Gruntfile.js index c75022f41..4ea4c59ea 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -467,6 +467,7 @@ module.exports = function(grunt) { "page_managers/three_column_view.js":60, "mixins/widget_utility.js":40, "components/query_builder/rules_translator.js":45, + "components/csrf_manager.js": 25, "widgets/base/tree_view.js":50, "widgets/facet/factory.js":50, "widgets/list_of_things/item_view.js":50, @@ -486,6 +487,7 @@ module.exports = function(grunt) { "components/api_feedback.js":77, "components/transition.js":77, "components/recaptcha_manager.js":49, + "components/user.js": 76, "widgets/dropdown-menu/widget.js":78, "widgets/list_of_things/paginated_view.js":78, "wraps/paper_network.js": 77, // some tests don't run properly in phantomjs, diff --git a/src/404.html b/src/404.html index 99c87c981..dccb40c34 100644 --- a/src/404.html +++ b/src/404.html @@ -27,6 +27,6 @@

404 Not Found

two men with jetpacks -

Oops, not found. Would you like to go back to the homepage?

+

We couldn't find that page. Would you like to go back to the homepage?

diff --git a/src/500.html b/src/500.html index 97f764b0f..a8fcf3e92 100644 --- a/src/500.html +++ b/src/500.html @@ -27,6 +27,6 @@

500 Error

-

Oh no! Did you break, Bumblebee?

+

We've made an error. Please try again later.

diff --git a/src/discovery.config.js b/src/discovery.config.js index 20f1325e0..a189bf39a 100644 --- a/src/discovery.config.js +++ b/src/discovery.config.js @@ -46,7 +46,8 @@ require.config({ HistoryManager: 'js/components/history_manager', MasterPageManager: 'js/page_managers/master', AppStorage: 'js/components/app_storage', - RecaptchaManager : 'js/components/recaptcha_manager' + RecaptchaManager : 'js/components/recaptcha_manager', + CSRFManager : "js/components/csrf_manager", }, modules: { FacetFactory: 'js/widgets/facet/factory' diff --git a/src/js/components/api_targets.js b/src/js/components/api_targets.js index 86ab1a148..bb378bcdd 100644 --- a/src/js/components/api_targets.js +++ b/src/js/components/api_targets.js @@ -22,7 +22,7 @@ define([ SERVICE_METRICS: 'metrics', MYADS_STORAGE: 'http://localhost:5000', - + CSRF : 'accounts/csrf', USER: 'accounts/user', LOGOUT: 'accounts/logout', REGISTER: 'accounts/register', diff --git a/src/js/components/csrf_manager.js b/src/js/components/csrf_manager.js new file mode 100644 index 000000000..111a2444b --- /dev/null +++ b/src/js/components/csrf_manager.js @@ -0,0 +1,59 @@ +/* + widgets can attach callbacks to a deferred that waits until + * a new csrf token has been requested + * + * */ +define([ + 'backbone', + 'js/components/generic_module', + 'js/mixins/hardened', + "js/components/api_request", + "js/components/api_targets" + ], + function( + Backbone, + GenericModule, + Hardened, + ApiRequest, + ApiTargets + ) { + + + var CSRFManager = GenericModule.extend({ + + activate: function (beehive) { + this.beehive = beehive; + this.pubsub = beehive.Services.get('PubSub'); + this.key = this.pubsub.getPubSubKey(); + _.bindAll(this, ["resolvePromiseWithNewKey"]); + this.pubsub.subscribe(this.key, this.pubsub.DELIVERING_RESPONSE, this.resolvePromiseWithNewKey); + }, + + getCSRF : function(){ + this.deferred = $.Deferred(); + + var request = new ApiRequest({ + target : ApiTargets.CSRF + }); + + this.pubsub.publish(this.key, this.pubsub.EXECUTE_REQUEST, request); + return this.deferred.promise(); + }, + + resolvePromiseWithNewKey : function(response){ + //get csrf here + var csrf = response.toJSON().csrf; + this.deferred.resolve(csrf); + }, + + hardenedInterface: { + getCSRF : "getCSRF" + } + + }); + + _.extend(CSRFManager.prototype, Hardened); + + return CSRFManager; + + }); \ No newline at end of file diff --git a/src/js/components/recaptcha_manager.js b/src/js/components/recaptcha_manager.js index 67b81296c..25b5285ee 100644 --- a/src/js/components/recaptcha_manager.js +++ b/src/js/components/recaptcha_manager.js @@ -1,8 +1,8 @@ /* -widgets can attach callbacks to a deferred that waits until -* grecaptcha is loaded from google, and sitekey info is loaded from discovery.vars.js -* -* */ + widgets can attach callbacks to a deferred that waits until + * grecaptcha is loaded from google, and sitekey info is loaded from discovery.vars.js + * + * */ define([ 'backbone', 'js/components/generic_module', @@ -53,14 +53,14 @@ define([ }, renderRecaptcha : function(view, siteKey, undefined){ - grecaptcha.render(view.$(".g-recaptcha")[0], - { - sitekey: siteKey, callback: function (response) { - view.model.set("g-recaptcha-response", response); - } + grecaptcha.render(view.$(".g-recaptcha")[0], + { + sitekey: siteKey, callback: function (response) { + view.model.set("g-recaptcha-response", response); + } }); - }, + }, hardenedInterface: { activateRecaptcha : "activateRecaptcha" diff --git a/src/js/components/session.js b/src/js/components/session.js index 57ed5335a..08b7ce0ea 100644 --- a/src/js/components/session.js +++ b/src/js/components/session.js @@ -73,8 +73,7 @@ define([ login: function (data) { - var csrfToken = this.getBeeHive().getObject("AppStorage").get("csrf"); - + this.sendRequestWithNewCSRF(function(csrfToken){ var request = new ApiRequest({ target : ApiTargets.USER, query: new ApiQuery({}), @@ -90,25 +89,38 @@ define([ } } }); - return this.getBeeHive().getService("Api").request(request); + return this.getBeeHive().getService("Api").request(request); + + }); + }, + + /* + * every time a csrf token is required, csrf manager will request a new token, + * and it allows you to attach callbacks to the promise it returns + * */ + sendRequestWithNewCSRF : function(callback){ + callback = _.bind(callback, this); + this.getBeeHive().getObject("CSRFManager").getCSRF().done(callback); }, logout: function () { - var csrfToken = this.getBeeHive().getObject("AppStorage").get("csrf"); + this.sendRequestWithNewCSRF(function(csrfToken){ + + var request = new ApiRequest({ + target : ApiTargets.LOGOUT, + query : new ApiQuery({}), + options : { + context : this, + type : "GET", + headers : {'X-CSRFToken' : csrfToken }, + contentType : "application/json", + done : this.logoutSuccess + } + }); + return this.getBeeHive().getService("Api").request(request); - var request = new ApiRequest({ - target : ApiTargets.LOGOUT, - query : new ApiQuery({}), - options : { - context : this, - type : "GET", - headers : {'X-CSRFToken' : csrfToken }, - contentType : "application/json", - done : this.logoutSuccess - } }); - return this.getBeeHive().getService("Api").request(request); }, register: function (data) { @@ -123,22 +135,23 @@ define([ } _.extend(data, {verify_url : base_url + "/#user/account/verify/register" }); - var csrfToken = this.getBeeHive().getObject("AppStorage").get("csrf"); + this.sendRequestWithNewCSRF(function(csrfToken) { + + var request = new ApiRequest({ + target: ApiTargets.REGISTER, + query: new ApiQuery({}), + options: { + type: "POST", + data: JSON.stringify(data), + contentType: "application/json", + headers: {'X-CSRFToken': csrfToken }, + done: this.registerSuccess, + fail: this.registerFail + } + }); + return this.getBeeHive().getService("Api").request(request); - var request = new ApiRequest({ - target : ApiTargets.REGISTER, - query : new ApiQuery({}), - options : { - type : "POST", - data : JSON.stringify(data), - contentType : "application/json", - headers : {'X-CSRFToken' : csrfToken }, - done : this.registerSuccess, - fail : this.registerFail - } }); - return this.getBeeHive().getService("Api").request(request); - }, resetPassword1: function(data){ @@ -154,39 +167,43 @@ define([ var email = data.email; var data = _.omit(data, "email"); - var csrfToken = this.getBeeHive().getObject("AppStorage").get("csrf"); - var request = new ApiRequest({ - target : ApiTargets.RESET_PASSWORD + "/" + email, - query : new ApiQuery({}), - options : { - type : "POST", - data : JSON.stringify(data), - headers : {'X-CSRFToken' : csrfToken }, - contentType : "application/json", - done : this.resetPassword1Success, - fail : this.resetPassword1Fail - } - }); - return this.getBeeHive().getService("Api").request(request); + this.sendRequestWithNewCSRF(function(csrfToken){ + var request = new ApiRequest({ + target : ApiTargets.RESET_PASSWORD + "/" + email, + query : new ApiQuery({}), + options : { + type : "POST", + data : JSON.stringify(data), + headers : {'X-CSRFToken' : csrfToken }, + contentType : "application/json", + done : this.resetPassword1Success, + fail : this.resetPassword1Fail + } + }); + return this.getBeeHive().getService("Api").request(request); + }); + }, resetPassword2: function(data){ - var csrfToken = this.getBeeHive().getObject("AppStorage").get("csrf"); - var request = new ApiRequest({ - target : ApiTargets.RESET_PASSWORD + "/" + this.model.get("resetPasswordToken"), - query : new ApiQuery({}), - options : { - type : "PUT", - data : JSON.stringify(data), - contentType : "application/json", - headers : {'X-CSRFToken' : csrfToken }, - done : this.resetPassword2Success, - fail : this.resetPassword2Fail - } + this.sendRequestWithNewCSRF(function(csrfToken){ + var request = new ApiRequest({ + target : ApiTargets.RESET_PASSWORD + "/" + this.model.get("resetPasswordToken"), + query : new ApiQuery({}), + options : { + type : "PUT", + data : JSON.stringify(data), + contentType : "application/json", + headers : {'X-CSRFToken' : csrfToken }, + done : this.resetPassword2Success, + fail : this.resetPassword2Fail + } + }); + return this.getBeeHive().getService("Api").request(request); }); - return this.getBeeHive().getService("Api").request(request); + }, setChangeToken : function(token){ diff --git a/src/js/components/user.js b/src/js/components/user.js index b613ae55c..6aa52b27d 100644 --- a/src/js/components/user.js +++ b/src/js/components/user.js @@ -195,7 +195,7 @@ define([ }, /*post data to endpoint: accessible through facade*/ - postData: function (target, data) { + postData: function (target, data, options) { //make sure it has a callback to access later if (!this.callbacks[target]){ throw new Error("a POST request was made that doesn't have a success callback"); @@ -203,11 +203,11 @@ define([ if (this.additionalParameters[target]){ _.extend(data, this.additionalParameters[target]); } - return this.composeRequest(target, "POST", data); + return this.composeRequest(target, "POST", data, options); }, /*PUT data to pre-existing endpoint: accessible through facade */ - putData: function (target, data) { + putData: function (target, data, options) { //make sure it has a callback to access later if (!this.callbacks[target]){ throw new Error("a PUT request was made that doesn't have a success callback"); @@ -215,7 +215,7 @@ define([ if (this.additionalParameters[target]){ _.extend(data, this.additionalParameters[target]); } - return this.composeRequest(target, "PUT", data); + return this.composeRequest(target, "PUT", data, options); }, /*return read-only copy of user model(s) for widgets: accessible through facade */ @@ -246,36 +246,74 @@ define([ return !!this.collection.get("USER").get("user"); }, - composeRequest : function (target, method, data, done, fail) { + /* + * every time a csrf token is required, app storage will request a new token, + * so it allows you to attach callbacks to the promise it returns + * */ + sendRequestWithNewCSRF : function(callback){ + callback = _.bind(callback, this); + this.getBeeHive().getObject("CSRFManager").getCSRF().done(callback); + }, + + + composeRequest : function (target, method, data, options) { var request, endpoint; //using "endpoint" to mean the actual url string endpoint = ApiTargets[target]; //get data from the relevant model based on the endpoint data = data || undefined; + options = options || {}; //allow caller to provide a done method if desired, otherwise go with the standard ones - done = done || (method == "GET" ? this.handleSuccessfulGET : this.handleSuccessfulPOST); - fail = fail || (method == "GET" ? this.handleFailedGET : this.handleFailedPOST); - - var csrfToken = this.getBeeHive().getObject("AppStorage").get("csrf"); - - request = new ApiRequest({ - target : endpoint, - options : { - context : this, - type: method, - data: JSON.stringify(data), - contentType : "application/json", - headers : {'X-CSRFToken' : csrfToken }, - done: done, - fail : fail, - //record the endpoint & data - beforeSend: function(jqXHR, settings) { - jqXHR.target = target; - jqXHR.data = data; + //handleSuccessFulPost is currently also being called for "put" method calls; I should change the name + var done = options.done || (method == "GET" ? this.handleSuccessfulGET : this.handleSuccessfulPOST); + var fail = options.fail || (method == "GET" ? this.handleFailedGET : this.handleFailedPOST); + + //it came from a form, needs to have a csrf token + if (options.csrf){ + + this.sendRequestWithNewCSRF(function(csrfToken){ + + request = new ApiRequest({ + target : endpoint, + options : { + context : this, + type: method, + data: JSON.stringify(data), + contentType : "application/json", + headers : {'X-CSRFToken' : csrfToken }, + done: done, + fail : fail, + //record the endpoint & data + beforeSend: function(jqXHR, settings) { + jqXHR.target = target; + jqXHR.data = data; + } + } + }); + this.getBeeHive().getService("Api").request(request); + }); + } + + else { + request = new ApiRequest({ + target : endpoint, + options : { + context : this, + type: method, + data: JSON.stringify(data), + contentType : "application/json", + done: done, + fail : fail, + //record the endpoint & data + beforeSend: function(jqXHR, settings) { + jqXHR.target = target; + jqXHR.data = data; + } } - } - }); - this.getBeeHive().getService("Api").request(request); + }); + this.getBeeHive().getService("Api").request(request); + } + }, //check if logged in/logged out state has changed diff --git a/src/js/mixins/api_access.js b/src/js/mixins/api_access.js index 7d46cda96..13a3ddc17 100644 --- a/src/js/mixins/api_access.js +++ b/src/js/mixins/api_access.js @@ -33,12 +33,6 @@ define([ api.expires_in = data.expires_in; } - //set csrf token into AppStorage - var appStorage = this.getBeeHive().getObject("AppStorage"); - if (appStorage && data.csrf) { - appStorage.set("csrf", data.csrf); - } - var user = this.getBeeHive().getObject("User"); if (user && !data.anonymous) { //it's a logged in user diff --git a/src/js/services/api.js b/src/js/services/api.js index cf4c0ff65..be25fc306 100644 --- a/src/js/services/api.js +++ b/src/js/services/api.js @@ -150,9 +150,6 @@ define([ .done(opts.done || this.done) .fail(opts.fail || this.fail); - jqXhr.target = request.get('target'); - - jqXhr = jqXhr.promise(jqXhr); return jqXhr; diff --git a/src/js/widgets/user_settings/widget.js b/src/js/widgets/user_settings/widget.js index b9b42cb25..f36fcd582 100644 --- a/src/js/widgets/user_settings/widget.js +++ b/src/js/widgets/user_settings/widget.js @@ -2,7 +2,6 @@ define([ 'marionette', 'js/widgets/base/base_widget', 'js/mixins/form_view_functions', - 'js/components/api_targets', 'js/widgets/success/view', 'js/components/api_feedback', 'hbs!./templates/api_key', @@ -21,7 +20,6 @@ define([ Marionette, BaseWidget, FormFunctions, - ApiTargets, SuccessView, ApiFeedback, TokenTemplate, @@ -548,10 +546,10 @@ define([ var user = this.beehive.getObject("User"); var target = model.target; if (model.PUT){ - user.putData(target, model.toJSON()); + user.putData(target, model.toJSON(), {csrf : true}); } else { - user.postData(target, model.toJSON()); + user.postData(target, model.toJSON(), {csrf : true}); } }, diff --git a/test/mocha/js/components/csrf_manager.spec.js b/test/mocha/js/components/csrf_manager.spec.js new file mode 100644 index 000000000..e7110f195 --- /dev/null +++ b/test/mocha/js/components/csrf_manager.spec.js @@ -0,0 +1,58 @@ +define([ + "js/components/csrf_manager", + 'js/bugutils/minimal_pubsub', + 'js/components/json_response' + +], function( + CSRFManager, + MinSub, + JSONResponse + ){ + + describe("CSRF Manager (Object)", function(){ + + + it("should have a getCSRF function exposed through the hardenedInterface that sends an execute_request to pubsub", function(){ + + var manager = new CSRFManager(); + + var minsub = new (MinSub.extend({ + request: function(apiRequest) { + return {some: 'foo'} + } + }))({verbose: false}); + + manager.activate(minsub.beehive.getHardenedInstance()); + expect(manager.getHardenedInstance().getCSRF).to.be.instanceof(Function); + manager.pubsub.publish = sinon.spy(); + //it's a promise + expect(manager.getCSRF().then).to.be.instanceof(Function); + expect(manager.pubsub.publish.args[0][1]).to.eql("[PubSub]-Execute-Request"); + expect((manager.pubsub.publish.args[0][2]).get("target")).to.eql("accounts/csrf"); + + }); + + it("should resolve the deferred when it receives a response from pubsub", function(){ + + var manager = new CSRFManager(); + + manager.pubsub = {publish : function(){}} + + var promise = manager.getCSRF(); + + var p; + + promise.done(function(csrf){ p = csrf;}) + manager.resolvePromiseWithNewKey(new JSONResponse({csrf : "foo"})); + expect(p).to.eql("foo"); + + + }) + + + }); + + + + +}) \ No newline at end of file diff --git a/test/mocha/js/components/recaptcha_manager.spec.js b/test/mocha/js/components/recaptcha_manager.spec.js index 7a140a4b2..e552a721d 100644 --- a/test/mocha/js/components/recaptcha_manager.spec.js +++ b/test/mocha/js/components/recaptcha_manager.spec.js @@ -2,41 +2,29 @@ define([ "js/components/recaptcha_manager" ], function(RecaptchaManager){ -describe("Recaptcha Manager", function(){ + describe("Recaptcha Manager", function(){ + it("should have a deferred that is resolved when the sitekey is obtained and the google recaptcha global is loaded", function(){ - it("should have a deferred that is resolved when the sitekey is obtained and the google recaptcha global is loaded", function(){ + var testView = new Backbone.View(); + var r = new RecaptchaManager(); - var testView = new Backbone.View(); - - var r = new RecaptchaManager(); - - r.renderRecaptcha = sinon.spy(); - - r.activateRecaptcha(testView); - - expect(r.renderRecaptcha.callCount).to.eql(0); - r.siteKeyDeferred.resolve("siteKey"); - expect(r.renderRecaptcha.callCount).to.eql(0); - r.grecaptchaDeferred.resolve(); - - expect(r.renderRecaptcha.callCount).to.eql(1); + r.renderRecaptcha = sinon.spy(); + r.activateRecaptcha(testView); + expect(r.renderRecaptcha.callCount).to.eql(0); + r.siteKeyDeferred.resolve("siteKey"); +// expect(r.renderRecaptcha.callCount).to.eql(0); + r.grecaptchaDeferred.resolve(); + expect(r.renderRecaptcha.callCount).to.eql(1); + }) }) - - - - - -}) - - }) \ No newline at end of file diff --git a/test/mocha/js/components/session.spec.js b/test/mocha/js/components/session.spec.js index b19ff90eb..1226a11fa 100644 --- a/test/mocha/js/components/session.spec.js +++ b/test/mocha/js/components/session.spec.js @@ -4,7 +4,7 @@ define([ 'js/services/api', 'js/components/api_request', 'js/components/user', - 'js/components/app_storage' + 'js/components/csrf_manager' ], function( @@ -13,7 +13,7 @@ define([ Api, ApiRequest, User, - AppStorage + CSRFManager ){ @@ -40,15 +40,22 @@ define([ }))({verbose: false}); var api = new Api(); - var appStorage = new AppStorage({csrf : "fake"}); + var csrfManager = new CSRFManager(); + csrfManager.getCSRF = function(){this.deferred = $.Deferred(); return this.deferred.promise()}; + csrfManager.resolvePromiseWithNewKey = function(){ + this.deferred.resolve("foo") + }; + var requestStub = sinon.stub(Api.prototype, "request"); minsub.beehive.removeService("Api"); minsub.beehive.addService("Api", api); - minsub.beehive.addObject("AppStorage", appStorage); + minsub.beehive.addObject("CSRFManager", csrfManager); s.activate(minsub.beehive); s.login({username: "goo", password : "foo", "g-recaptcha-response" : "boo"}); + csrfManager.resolvePromiseWithNewKey(); + expect(requestStub.args[0][0]).to.be.instanceof(ApiRequest) expect(requestStub.args[0][0].toJSON().target).to.eql("accounts/user"); expect(requestStub.args[0][0].toJSON().options.type).to.eql("POST"); @@ -58,6 +65,8 @@ define([ s.logout(); + csrfManager.resolvePromiseWithNewKey(); + expect(requestStub.args[1][0]).to.be.instanceof(ApiRequest) expect(requestStub.args[1][0].toJSON().target).to.eql("accounts/logout"); expect(requestStub.args[1][0].toJSON().options.type).to.eql("GET"); @@ -65,6 +74,8 @@ define([ s.register({email: "goo@goo.com", password1 : "foo", password2 : "foo", "g-recaptcha-response" : "boo"}); + csrfManager.resolvePromiseWithNewKey(); + expect(requestStub.args[2][0]).to.be.instanceof(ApiRequest) expect(requestStub.args[2][0].toJSON().target).to.eql("accounts/register"); expect(requestStub.args[2][0].toJSON().options.type).to.eql("POST"); @@ -75,6 +86,9 @@ define([ s.resetPassword1({email: "goo@goo.com", "g-recaptcha-response" : "boo"}); + csrfManager.resolvePromiseWithNewKey(); + + expect(requestStub.args[3][0]).to.be.instanceof(ApiRequest) expect(requestStub.args[3][0].toJSON().target).to.eql('accounts/reset-password/goo@goo.com'); expect(requestStub.args[3][0].toJSON().options.type).to.eql("POST"); @@ -88,6 +102,8 @@ define([ s.resetPassword2({password1 : "1Aaaaa", password2 : "1Aaaaa"}); + csrfManager.resolvePromiseWithNewKey(); + expect(requestStub.args[4][0]).to.be.instanceof(ApiRequest) //test version just uses string "location.origin", real version will use the actual origin expect(requestStub.args[4][0].toJSON().target).to.eql('accounts/reset-password/fakeToken'); diff --git a/test/mocha/js/widgets/user_settings_widget.spec.js b/test/mocha/js/widgets/user_settings_widget.spec.js index a5bd4f041..5b16cfd9a 100644 --- a/test/mocha/js/widgets/user_settings_widget.spec.js +++ b/test/mocha/js/widgets/user_settings_widget.spec.js @@ -197,7 +197,7 @@ define([ $("#test").find("button[type=submit]").click(); expect(postDataSpy.callCount).to.eql(1); - expect(JSON.stringify(postDataSpy.args[0])).to.eql('["CHANGE_PASSWORD",{"old_password":"Foooo5","new_password1":"Boooo3","new_password2":"Boooo3"}]'); + expect(JSON.stringify(postDataSpy.args[0])).to.eql('["CHANGE_PASSWORD",{"old_password":"Foooo5","new_password1":"Boooo3","new_password2":"Boooo3"},{"csrf":true}]'); });