43

我正在使用 Rails 做一个单页应用程序。登录和注销时,使用 ajax 调用 Devise 控制器。我遇到的问题是,当我 1)登录 2)注销然后再次登录不起作用。

我认为它与 CSRF 令牌有关,当我退出时它会被重置(尽管它不应该 afaik),并且由于它是单页,旧的 CSRF 令牌是在 xhr 请求中发送的,因此会重置会话。

更具体地说,这是工作流程:

  1. 登入
  2. 登出
  3. 登录(成功 201。但是WARNING: Can't verify CSRF token authenticity在服务器日志中打印)
  4. 后续ajax请求失败401未授权
  5. 刷新网站(此时页眉中的CSRF变成了别的东西)
  6. 我可以登录,它可以工作,直到我尝试退出并再次登录。

任何线索都非常感谢!让我知道是否可以添加更多详细信息。

4

10 回答 10

40

Jimbo 出色地解释了您遇到的问题背后的“原因”。您可以采取两种方法来解决此问题:

  1. (由 Jimbo 推荐)覆盖 Devise::SessionsController 以返回新的 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
    

    并在客户端为您的 sign_out 请求创建一个成功处理程序(可能需要根据您的设置进行一些调整,例如 GET 与 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");
      });
    }
    

    这还假设您将 CSRF 令牌自动包含在所有 AJAX 请求中,如下所示:

    $(document).ajaxSend(function (e, xhr, options) {
      xhr.setRequestHeader("X-CSRF-Token", MyApp.session.get("csrf-token"));
    });
    
  2. 更简单地说,如果它适合您的应用程序,您可以简单地覆盖Devise::SessionsController并使用 覆盖令牌检查skip_before_filter :verify_authenticity_token

于 2012-09-27T14:45:37.783 回答
34

我也遇到了这个问题。这里发生了很多事情。

TL;DR - 失败的原因是 CSRF 令牌与您的服务器会话相关联(无论您是登录还是注销,您都有一个服务器会话)。CSRF 令牌包含在每次页面加载时页面的 DOM 中。注销时,您的会话被重置并且没有 csrf 令牌。通常,注销会重定向到不同的页面/操作,这会为您提供一个新的 CSRF 令牌,但由于您使用的是 ajax,因此您需要手动执行此操作。

  • 您需要覆盖 Devise SessionController::destroy 方法以返回您的新 CSRF 令牌。
  • 然后在客户端,您需要为注销 XMLHttpRequest 设置成功处理程序。在该处理程序中,您需要从响应中获取这个新的 CSRF 令牌并将其设置在您的 dom 中: $('meta[name="csrf-token"]').attr('content', <NEW_CSRF_TOKEN>)

更详细的说明您很可能protect_from_forgery在 ApplicationController.rb 文件中进行了设置,所有其他控制器都从该文件继承(我认为这很常见)。protect_from_forgery对所有非 GET HTML/Javascript 请求执行 CSRF 检查。由于 Devise Login 是一个 POST,它执行一个 CSRF 检查。如果 CSRF 检查失败,则清除用户的当前会话,即将用户注销,因为服务器假定这是一次攻击(这是正确/期望的行为)。

因此,假设您从注销状态开始,您会重新加载页面,并且永远不会再次重新加载页面:

  1. 在呈现页面时:服务器将与您的服务器会话关联的 CSRF 令牌插入页面。您可以通过在浏览器中的 javascript 控制台运行以下命令来查看此令牌$('meta[name="csrf-token"]').attr('content')

  2. 然后您通过 XMLHttpRequest 登录:此时您的 CSRF 令牌保持不变,因此您的 Session 中的 CSRF 令牌仍然与插入页面的那个相匹配。在后台,在客户端,jquery-ujs 正在侦听 xhr 并$('meta[name="csrf-token"]').attr('content')自动为您设置一个值为 'X-CSRF-Token' 的标头(请记住,这是服务器在步骤 1 中设置的 CSRF 令牌)。服务器将 jquery-ujs 在标头中设置的 Token 与存储在会话信息中的 Token 进行比较,它们匹配,因此请求成功。

  3. 然后您通过 XMLHttpRequest 注销:这会重置会话,为您提供一个没有 CSRF 令牌的新会话。

  4. 然后通过 XMLHttpRequest 再次登录: jquery-ujs 从$('meta[name="csrf-token"]').attr('content'). 这个值仍然是你的CSRF 令牌。它采用这个旧令牌并使用它来设置“X-CSRF-Token”。服务器将此标头值与它添加到会话中的新 CSRF 令牌进行比较,这是不同的。这种差异导致protect_form_forgery失败,这会引发WARNING: Can't verify CSRF token authenticity并重置您的会话,从而将用户注销。

  5. 然后,您创建另一个需要登录用户的 XMLHttpRequest:当前会话没有登录用户,因此设计返回 401。

更新:8/14设计注销不会给你一个新的 CSRF 令牌,通常在注销后发生的重定向会给你一个新的 CSRF 令牌。

于 2012-08-13T22:32:21.363 回答
9

我的回答大量借鉴了@Jimbo 和@Sija,但是我使用的是Rails CSRF Protection + Angular.js 建议的设计/angularjs 约定:protect_from_forgery 让我在 POST 上注销,并在我的博客上详细说明了一点原来是这样做的。这在应用程序控制器上有一个为 csrf 设置 cookie 的方法:

after_filter  :set_csrf_cookie_for_ng

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

所以我使用@Sija的格式,但使用早期SO解决方案中的代码,给我:

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

为了完整起见,由于我花了几分钟的时间来解决它,我还注意到需要修改您的 config/routes.rb 以声明您已经覆盖了会话控制器。就像是:

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

这也是我在我的应用程序上完成的大型 CSRF 清理的一部分,其他人可能会对此感兴趣。博客文章在这里,其他更改包括:

从 ActionController::InvalidAuthenticityToken 中救援,这意味着如果事情不同步,应用程序将自行修复,而不是用户需要清除 cookie。由于事情在rails中,我认为您的应用程序控制器将默认为:

protect_from_forgery with: :exception

在这种情况下,您需要:

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

我也对竞争条件和与 Devise 中的超时模块的一些交互感到有些遗憾,我在博客文章中对此进行了进一步评论 - 简而言之,您应该考虑使用 active_record_store 而不是 cookie_store,并小心发出并行靠近 sign_in 和 sign_out 操作的请求。

于 2014-04-16T13:58:07.580 回答
8

这是我的看法:

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

在客户端:

$(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);
  }
});

每次您通过 ajax 请求返回X-CSRF-Token或标头时,这将使您的 CSRF 元标记保持更新。X-CSRF-Param

于 2013-11-21T01:45:12.307 回答
6

在挖掘 Warden 源之后,我注意到设置sign_out_all_scopesfalse阻止 Warden 清除整个会话,因此 CSRF 令牌在退出之间保留。

有关设计问题跟踪器的相关讨论:https ://github.com/plataformatec/devise/issues/2200

于 2013-01-05T20:35:35.133 回答
1

我刚刚在我的布局文件中添加了它并且它有效

    <%= 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 %>
于 2014-04-18T11:47:49.817 回答
0

检查您是否已将其包含在 application.js 文件中

//= 需要 jquery

//= 需要 jquery_ujs

原因是 jquery-rails gem 默认情况下会在所有 Ajax 请求上自动设置 CSRF 令牌,需要这两个

于 2012-08-07T12:13:55.827 回答
0

就我而言,在用户登录后,我需要重新绘制用户的菜单。那行得通,但是我在同一部分对服务器的每个请求都收到了 CSRF 真实性错误(当然,没有刷新页面)。上面的解决方案不起作用,因为我需要渲染一个 js 视图。

我所做的是,使用设计:

应用程序/控制器/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

之后,我向重绘菜单的控制器#action 发出请求。在 javascript 中,我修改了 X-CSRF-Param 和 X-CSRF-Token:

应用程序/视图/实用程序/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 %>');

我希望它对处于相同 js 情况的人有用:)

于 2016-07-15T05:22:05.703 回答
0

我的情况更简单。就我而言,我想做的就是:如果一个人坐在屏幕上,拿着表格,并且他们的会话超时(设计可超时的会话超时),通常如果他们在那个时候点击提交,设计会反弹他们到登录屏幕。好吧,我不想这样,因为他们丢失了所有的表单数据。我使用 JavaScript 来捕获表单提交,Ajax 调用一个控制器来确定用户是否不再登录,如果是这种情况,我提出一个表单,让他们重新输入密码,然后重新验证它们(bypass_sign_in 在控制器中)使用 Ajax 调用。然后允许原始表单提交继续。

在我添加protect_from_forgery 之前一直运行良好。

所以,多亏了上面的答案,我真正需要的只是在我的控制器中让用户重新登录(bypass_sign_in),我只是为新的 CSRF 令牌设置了一个实例变量:

@new_csrf_token = form_authenticity_token

然后在渲染的 .js.erb 中(再一次,这是一个 XHR 调用):

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

瞧。我的表单页面没有刷新,因此被旧令牌卡住了,现在我从我的用户登录获得的新会话中获得了新令牌。

于 2019-07-17T19:59:13.363 回答
-1

回复@sixty4bit 的评论;如果您遇到此错误:

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

代替

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

response.headers['X-CSRF-Param'] = request_forgery_protection_token.to_s
于 2015-02-27T13:54:51.963 回答