Rails, Devise authentication, CSRF issue
Asked Answered
L

10

44

I'm doing a singe-page application using Rails. When signing in and out Devise controllers are invoked using ajax. The problem I'm getting is that when I 1) sign in 2) sign out then signing in again doesn't work.

I think it's related to CSRF token which gets reset when I sign out (though it shouldn't afaik) and since it's single page, the old CSRF token is being sent in xhr request thus resetting the session.

To be more concrete this is the workflow:

  1. Sign in
  2. Sign out
  3. Sign in (successful 201. However prints WARNING: Can't verify CSRF token authenticity in server logs)
  4. Subsequent ajax request fails 401 unauthorised
  5. Refresh the website (at this point, CSRF in the page header changes to something else)
  6. I can sign in, it works, until I try to sign out and in again.

Any clues very much appreciated! Let me know if I can add any more details.

Lewiss answered 7/8, 2012 at 12:0 Comment(1)
May I ask you if you can answer this very similar question? #50160347Ismaelisman
V
42

Jimbo did an awesome job explaining the "why" behind the issue you're running into. There are two approaches you can take to resolve the issue:

  1. (As recommended by Jimbo) Override Devise::SessionsController to return the new csrf-token:

    class SessionsController < Devise::SessionsController
      def destroy # Assumes only JSON requests
        signed_out = (Devise.sign_out_all_scopes ? sign_out : sign_out(resource_name))
        render :json => {
            'csrfParam' => request_forgery_protection_token,
            'csrfToken' => form_authenticity_token
        }
      end
    end
    

    And create a success handler for your sign_out request on the client side (likely needs some tweaks based on your setup, e.g. GET vs DELETE):

    signOut: function() {
      var params = {
        dataType: "json",
        type: "GET",
        url: this.urlRoot + "/sign_out.json"
      };
      var self = this;
      return $.ajax(params).done(function(data) {
        self.set("csrf-token", data.csrfToken);
        self.unset("user");
      });
    }
    

    This also assumes you're including the CSRF token automatically with all AJAX requests with something like this:

    $(document).ajaxSend(function (e, xhr, options) {
      xhr.setRequestHeader("X-CSRF-Token", MyApp.session.get("csrf-token"));
    });
    
  2. Much more simply, if it is appropriate for your application, you can simply override the Devise::SessionsController and override the token check with skip_before_filter :verify_authenticity_token.

Vezza answered 27/9, 2012 at 14:45 Comment(4)
anyone know if this issue has been raised with the Devise developers?Inextirpable
#2 doesn't work for me because I am getting the CSRF authenticity error after signing in via Devise/Ajax with any POST request that I make afterwards. I'm also unsure of how to render the new csrf tokens because I'm already rendering a template as the final step in my :create action. I've made a question about it here (#26640826) and would really appreciate it if you have a moment to check it outOmbudsman
@Ombudsman I seem to be having the same problem as you, but your question was deleted. Did you ever figure it out?Silsbye
May I ask you if you can answer this very similar question? #50160347Ismaelisman
D
35

I've just run into this problem as well. There's a lot going on here.

TL;DR - The reason for the failure is that the CSRF token is associated with your server session (you've got a server session whether you're logged in or logged out). The CSRF token is included in the DOM your page on every page load. On logout, your session is reset and has no csrf token. Normally, a logout redirects to a different page/action, which gives you a new CSRF token, but since you're using ajax, you need to do this manually.

  • You need to override the Devise SessionController::destroy method to return your new CSRF token.
  • Then on the client side you need to set a success handler for your logout XMLHttpRequest. In that handler you need to take this new CSRF token from the response and set it in your dom: $('meta[name="csrf-token"]').attr('content', <NEW_CSRF_TOKEN>)

More Detailed Explanation You've most likely got protect_from_forgery set in your ApplicationController.rb file from which all of your other controllers inherit (this is pretty common I think). protect_from_forgery performs CSRF checks on all non-GET HTML/Javascript requests. Since Devise Login is a POST, it performs a CSRF Check. If a CSRF Check fails then the user's current session is cleared, i.e., logs the user out, because the server assumes it's an attack (which is the correct/desired behavior).

So assuming you're starting in a logged out state, you do a fresh page load, and never reload the page again:

  1. On rendering the page: the server inserts the CSRF Token associated with your server session into the page. You can view this token by running the following from a javascript console in your browser$('meta[name="csrf-token"]').attr('content').

  2. You then Sign In via an XMLHttpRequest: Your CSRF Token remains unchanged at this point so the CSRF Token in your Session still matches the one that was inserted into the page. Behind the scenes, on the client side, jquery-ujs is listening for xhr's and setting a 'X-CSRF-Token' header with the value of $('meta[name="csrf-token"]').attr('content') for you automatically (remember this was the CSRF Token set in step 1 by the sever). The server compares the Token set in the header by jquery-ujs and the one that is stored in your session information and they match so the request succeeds.

  3. You then Log Out via an XMLHttpRequest: This resets session, gives you a new session without a CSRF Token.

  4. You then Sign In again via an XMLHttpRequest: jquery-ujs pulls the CSRF token from the value of $('meta[name="csrf-token"]').attr('content'). This value is still your OLD CSRF token. It takes this old token and uses it to set the 'X-CSRF-Token'. The server compares this header value with a new CSRF token that it adds to your session, which is different. This difference causes the protect_form_forgery to fail, which throws the WARNING: Can't verify CSRF token authenticity and resets your session, which logs the user out.

  5. You then make another XMLHttpRequest that requires a logged in user: The current session doesn't have a logged in user so devise returns a 401.

Update: 8/14 Devise logout does not give you a new CSRF token, the redirect that normally happens after a logout gives you a new csrf token.

Doorstone answered 13/8, 2012 at 22:32 Comment(10)
Once I figure out how to implement the two steps in TL;DR I'll post the code.Doorstone
CSRF-value from DOM is not a good solution. From Angular Doc: $http > Since only JavaScript that runs on your domain could read the cookie, your server can be assured that the XHR came from JavaScript running on your domain. The header will not be set for cross-domain requests.Spatterdash
I am having this same problem but in my workflow I'm never signing out. I can't complete POST actions at all after signing in via XHR. So my flow is sign in from new widget form via xhr (no csrf warning in console) -> try to submit new widget form (csrf warning in console) -> signed out and redirected to regular sign in form. According to the console, params[:authenticity_token] is the same during the xhr request to sign in and the html #create request. Any help here??Ombudsman
@Ombudsman did you extend the Devise::SessionsController to return new the current csrf tokens in the :create and :destroy actions? Your client code then needs to read and set these new values from the response, and send them up with all future requests. If that's not clear let me know and I'll try to post our full currently working solution later (it may take me a day or two).Doorstone
@Jimbo The issue I ran into there is that I need to change some things on the page after signin so I'm already rendering a .js.erb template as the final step of my :create action in my extended SessionsController. If I add another render to return that JSON, I'll get a double render error. Can I include the current csrf tokens in the template and still read and set them somehow? I've created my own question that shows the controller here: #26640826Ombudsman
@Ombudsman Oh, in that case if you add the following line to your .js.erb file $('meta[name="csrf-token"]').attr('content', <%= new_csrf_token %>) you can set the new csrf token directly. Then in your site javascript, on page load, do this $.ajaxPrefilter( function(options, originalOptions, xhr) { xhr.setRequestHeader('X-CSRF-Token', $('meta[name="csrf-token"]').attr('content');}. This is the last piece of @jredburn's first approach, which sends the token up with all ajax requests.Doorstone
@Jimbo I'm sort of following you but not entirely. In my application.js I added $(document).ajaxSend(function (e, xhr, options) { xhr.setRequestHeader('X-CSRF-Token', $('meta[name="csrf-token"]').attr('content')) }); and I added the other line to my .js.erb file but now the Ajax request to sign in gets a 500 error because new_csrf_token is undefined. I'm assuming I'm supposed to put something else there, but what?Ombudsman
@Jimbo sorry to stack posts but I was working on this and came up with this other solution. It seems to work but I don't know what issues it may have. I used form_authenticity_token in my controller and then added the following two lines to the .js.erb template being rendered: $('meta[name="csrf-token"]').attr('content', '<%= session["_csrf_token"] %>') $('input[name="authenticity_token"]').val('<%= session["_csrf_token"] %>')Ombudsman
sorry, new_csrf_token was just a placeholder for a variable that contains the users's new csrf token, so yeah, form_authenticity_token makes sense. Both of those lines look good.Doorstone
@Ombudsman Regarding your $(document).ajaxSend line that you added to your application.js. You have to make sure that line actually gets run on page load.Doorstone
A
10

My answer borrows heavily from both @Jimbo and @Sija, however I'm using the devise/angularjs convention suggested at Rails CSRF Protection + Angular.js: protect_from_forgery makes me to log out on POST, and elaborated a little on my blog when I originally did this. This has a method on the application controller to set cookies for csrf:

after_filter  :set_csrf_cookie_for_ng

def set_csrf_cookie_for_ng
  cookies['XSRF-TOKEN'] = form_authenticity_token if protect_against_forgery?
end

So I'm using @Sija's format, but using the code from that earlier SO solution, giving me:

class SessionsController < Devise::SessionsController
  after_filter :set_csrf_headers, only: [:create, :destroy]

  protected
  def set_csrf_headers
    cookies['XSRF-TOKEN'] = form_authenticity_token if protect_against_forgery?  
  end
end

For completeness, since it took me a couple of minutes to work it out, I also note the need to modify your config/routes.rb to declare that you've overridden the sessions controller. Something like:

devise_for :users, :controllers => {sessions: 'sessions'}

This was also part of a large CSRF cleanup that I've done on my application, which might be interesting to others. The blog post is here, the other changes include:

Rescuing from ActionController::InvalidAuthenticityToken, which means that if things get out of synch the application will fix itself, rather than the user needing to clear cookies. As things stand in rails I think your application controller will be defaulted with:

protect_from_forgery with: :exception

In that situation, you then need:

rescue_from ActionController::InvalidAuthenticityToken do |exception|
  cookies['XSRF-TOKEN'] = form_authenticity_token if protect_against_forgery?
  render :error => 'invalid token', {:status => :unprocessable_entity}
end

I've also had some grief with race conditions and some interactions with the timeoutable module in Devise, which I've commented on further in the blog post - in short you should consider using the active_record_store rather than cookie_store, and be careful about issuing parallel requests near to sign_in and sign_out actions.

Aureomycin answered 16/4, 2014 at 13:58 Comment(1)
Won't setting CSRF token after rescuing from the InvalidAuthenticityToken exception validates an invalid request that does not possess the token and allows the request to continue processing?Reiners
L
7

After digging on the Warden source, I noticed that setting sign_out_all_scopes to false stops Warden from clearing the entire session, so the CSRF token is preserved between sign outs.

Related discussion on Devise issue tacker: https://github.com/plataformatec/devise/issues/2200

Lilley answered 5/1, 2013 at 20:35 Comment(0)
H
7

This is my take:

class SessionsController < Devise::SessionsController
  after_filter :set_csrf_headers, only: [:create, :destroy]
  respond_to :json

  protected
  def set_csrf_headers
    if request.xhr?
      response.headers['X-CSRF-Param'] = request_forgery_protection_token
      response.headers['X-CSRF-Token'] = form_authenticity_token
    end
  end
end

And on the client side:

$(document).ajaxComplete(function(event, xhr, settings) {
  var csrf_param = xhr.getResponseHeader('X-CSRF-Param');
  var csrf_token = xhr.getResponseHeader('X-CSRF-Token');

  if (csrf_param) {
    $('meta[name="csrf-param"]').attr('content', csrf_param);
  }
  if (csrf_token) {
    $('meta[name="csrf-token"]').attr('content', csrf_token);
  }
});

Which will keep your CSRF meta tags updated every time you return X-CSRF-Token or X-CSRF-Param header via ajax request.

Humane answered 21/11, 2013 at 1:45 Comment(4)
Thanks, I had to use string interpolation when calling request_forgery_protection_token and form_authenticity_token from my controller as I was getting an error about not being able to call split on :authenticity_token:String... But this technique works really well. Thanks.Tomlinson
This is the right answer, although I do wonder about security implications of regenerating a new crsf token. You should add if self.request.format.symbol == :json for instance or for any mime for which you're not redirectingStirps
I updated the answer according to your suggestion. Good catch, thanks!Humane
@marflar could you show what you did there? I am having a similar problem. Getting Unexpected error while processing request: undefined method each' for :authenticity_token:Symbol`Ombudsman
S
1

I just added this in my layout file and it worked

    <%= csrf_meta_tag %>

    <%= javascript_tag do %>
      jQuery(document).ajaxSend(function(e, xhr, options) {
       var token = jQuery("meta[name='csrf-token']").attr("content");
        xhr.setRequestHeader("X-CSRF-Token", token);
      });
    <% end %>
Swill answered 18/4, 2014 at 11:47 Comment(0)
B
0

Check whether you have included this in your application.js file

//= require jquery

//= require jquery_ujs

The reason being is jquery-rails gem which automatically sets the CSRF token on all Ajax requests by default, needs those two

Biosynthesis answered 7/8, 2012 at 12:13 Comment(2)
Yes, I've got those. I checked the requests with development tools, they all have CSRFs.Lewiss
Check whether you are getting same CSRF token or different tokensBiosynthesis
F
0

In my case, after login the user in, i needed to redraw the user's menu. That worked, but i got CSRF authenticity errors on every request to the server, in that same section (without refreshing the page, of course). Above solutions wasn't working since i needed to render a js view.

What i did is this, using Devise:

app/controllers/sessions_controller.rb

   class SessionsController < Devise::SessionsController
      respond_to :json

      # GET /resource/sign_in
      def new
        self.resource = resource_class.new(sign_in_params)
        clean_up_passwords(resource)
        yield resource if block_given?
        if request.format.json?
          markup = render_to_string :template => "devise/sessions/popup_login", :layout => false
          render :json => { :data => markup }.to_json
        else
          respond_with(resource, serialize_options(resource))
        end
      end

      # POST /resource/sign_in
      def create
        if request.format.json?
          self.resource = warden.authenticate(auth_options)
          if resource.nil?
            return render json: {status: 'error', message: 'invalid username or password'}
          end
          sign_in(resource_name, resource)
          render json: {status: 'success', message: '¡User authenticated!'}
        else
          self.resource = warden.authenticate!(auth_options)
          set_flash_message(:notice, :signed_in)
          sign_in(resource_name, resource)
          yield resource if block_given?
          respond_with resource, location: after_sign_in_path_for(resource)
        end
      end

    end

After that i made a request to the controller#action that redraw the menu. And in the javascript, i modified the X-CSRF-Param and X-CSRF-Token:

app/views/utilities/redraw_user_menu.js.erb

  $('.js-user-menu').html('');
  $('.js-user-menu').append('<%= escape_javascript(render partial: 'shared/user_name_and_icon') %>');
  $('meta[name="csrf-param"]').attr('content', '<%= request_forgery_protection_token.to_s %>');
  $('meta[name="csrf-token"]').attr('content', '<%= form_authenticity_token %>');

I hope it's useful for someone on the same js situation :)

Frizzle answered 15/7, 2016 at 5:22 Comment(0)
L
0

My situation was even simpler. In my case, all I wanted to do was this: if a person is sitting on a screen with a form, and their session times out (Devise timeoutable session timeout), normally if they hit Submit at that point, Devise would bounce them back to the login screen. Well, I didn't want that, because they lose all their form data. I use JavaScript to catch the form submit, Ajax call a controller which determines if the user is no longer signed in, and if that's the case I put up a form where they retype their password, and I reauthenticate them (bypass_sign_in in a controller) using an Ajax call. Then the original form submit is allowed to continue.

Was working perfectly until I added protect_from_forgery.

So, thanks to the above answers all I needed really was in my controller where I sign the user back in (the bypass_sign_in) I just set an instance variable to the new CSRF token:

@new_csrf_token = form_authenticity_token

and then in the .js.erb that was rendered (since again, this was an XHR call):

$('meta[name="csrf-token"]').attr('content', '<%= @new_csrf_token %>');
$('input[type="hidden"][name="authenticity_token"]').val('<%= @new_csrf_token %>');

Voila. My form page, which was not refreshed and therefore was stuck with the old token, now has the new token from the new session I got from signing in my user.

Leathery answered 17/7, 2019 at 19:59 Comment(0)
P
-1

in reply to a comment of @sixty4bit; if you run into this error:

Unexpected error while processing request: undefined method each for :authenticity_token:Symbol` 

replace

response.headers['X-CSRF-Param'] = request_forgery_protection_token

with

response.headers['X-CSRF-Param'] = request_forgery_protection_token.to_s
Pestiferous answered 27/2, 2015 at 13:54 Comment(1)
This may answer the question in the comment, but therefore should not be an answer to this question. I'd suggest you create a new question which links to this question and self answer it with this answer. You can then also add a comment to this question linking to your newly created oneRemembrancer

© 2022 - 2024 — McMap. All rights reserved.