可以将文章内容翻译成中文,广告屏蔽插件可能会导致该功能失效(如失效,请关闭广告屏蔽插件后再试):
问题:
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:
- Sign in
- Sign out
- Sign in (successful 201. However prints
WARNING: Can't verify CSRF token authenticity
in server logs)
- Subsequent ajax request fails 401 unauthorised
- Refresh the website (at this point, CSRF in the page header changes to something else)
- 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.
回答1:
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:
(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"));
});
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
.
回答2:
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:
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')
.
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.
You then Log Out via an XMLHttpRequest: This resets session, gives you a new session without a CSRF Token.
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.
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.
回答3:
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.
回答4:
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.
回答5:
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
回答6:
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 %>
回答7:
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
回答8:
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 :)
回答9:
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