Rails,设计认证,CSRF问题

时间:2012-08-07 12:00:42

标签: ruby-on-rails ajax authentication devise csrf

我正在使用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. 我可以登录,有效,直到我再次尝试退出。
  7. 非常感谢任何线索!如果我可以添加更多细节,请告诉我。

10 个答案:

答案 0 :(得分:36)

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 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");
      });
    }
    

    这也假设您自动包含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覆盖令牌检查。

答案 1 :(得分:31)

我也遇到了这个问题。这里有很多事情要做。

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

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

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

因此,假设您正处于已注销状态,则会执行新的页面加载,并且永远不会再次重新加载页面:

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

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

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

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

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

  6. 更新:8/14 设计注销不会为您提供新的CSRF令牌,通常在注销后发生的重定向会为您提供新的csrf令牌。

答案 2 :(得分:9)

我的回答大量借用了@Jimbo和@Sija,但是我使用了Rails CSRF Protection + Angular.js: protect_from_forgery makes me to log out on POST中建议的设计/ angularjs惯例,并在我最初做blog时对其进行了详细阐述这个。这在应用程序控制器上有一个为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清理的一部分,这可能对其他人有意义。 blog post is here,其他更改包括:

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

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操作附近发出并行请求。

答案 3 :(得分: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-TokenX-CSRF-Param标头时,都会更新CSRF元标记。

答案 4 :(得分:6)

在深入研究Warden源代码后,我注意到将sign_out_all_scopes设置为false会阻止Warden清除整个会话,因此在签出之间会保留CSRF令牌。

关于设计问题攻击者的相关讨论:https://github.com/plataformatec/devise/issues/2200

答案 5 :(得分: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 %>

答案 6 :(得分:0)

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

  

// = require jquery

     

// = require jquery_ujs

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

答案 7 :(得分:0)

就我而言,在登录用户后,我需要重新绘制用户的菜单。这有效,但我在服务器的每个请求上都得到了CSRF真实性错误,在同一部分(当然没有刷新页面)。由于我需要渲染js视图,因此上述解决方案无法正常工作。

我用的是Devise:

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

之后我向控制器#动作发出了重绘菜单的请求。在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 %>');

我希望它对同样情况下的某些人有用:)

答案 8 :(得分:0)

我的情况更加简单。就我而言,我要做的就是:如果某人正坐在带有表单的屏幕上,并且他们的会话超时(Devise超时会话超时),通常如果他们在那一刻点击Submit,Devise会将他们反弹到登录屏幕。好吧,我不想这么做,因为他们会丢失所有表格数据。我使用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 %>');

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

答案 9 :(得分:-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