Rails,设计认证,CSRF 问题

Posted

技术标签:

【中文标题】Rails,设计认证,CSRF 问题【英文标题】:Rails, Devise authentication, CSRF issue 【发布时间】:2012-08-04 10:34:18 【问题描述】:

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

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

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

    登录 退出 登录(成功 201。但是在服务器日志中打印 WARNING: Can't verify CSRF token authenticity) 后续 ajax 请求失败 401 unauthorized 刷新网站(此时页眉中的CSRF变为其他内容) 我可以登录,它可以正常工作,直到我尝试退出并重新登录。

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

【问题讨论】:

请问您能否回答这个非常相似的问题? ***.com/questions/50159847/… 【参考方案1】:

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

//= 需要 jquery

//= 需要 jquery_ujs

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

【讨论】:

是的,我有这些。我用开发工具检查了请求,它们都有 CSRF。 检查你得到的是相同的 CSRF 令牌还是不同的令牌【参考方案2】:

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

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 检查失败,则清除用户的当前会话,即将用户注销,因为服务器认为这是一次攻击(这是正确/期望的行为)。

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

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

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

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

    然后您通过 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 并重置您的会话,从而将用户注销。

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

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

【讨论】:

一旦我弄清楚如何在 TL 中实现这两个步骤;DR 我将发布代码。 我遇到了同样的问题,但在我的工作流程中我从不退出。通过 XHR 登录后,我根本无法完成 POST 操作。所以我的流程是通过 xhr 从新的小部件表单登录(控制台中没有 csrf 警告)-> 尝试提交新的小部件表单(控制台中的 csrf 警告)-> 注销并重定向到常规登录表单。根据控制台,在 xhr 请求登录和 html #create 请求期间,params[:authenticity_token] 是相同的。这里有什么帮助吗?? @sixty4bit 您是否扩展了Devise::SessionsController 以在:create:destroy 操作中返回新的当前csrf 令牌?然后,您的客户端代码需要从响应中读取和设置这些新值,并将它们与所有未来的请求一起发送。如果不清楚,请告诉我,稍后我会尝试发布我们当前工作的完整解决方案(可能需要一两天)。 抱歉,new_csrf_token 只是包含用户新 csrf 令牌的变量的占位符,所以是的,form_authenticity_token 是有道理的。这两条线看起来都不错。 @sixty4bit 关于您添加到 application.js 的 $(document).ajaxSend 行。您必须确保该行实际上在页面加载时运行。【参考方案3】:

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

    (根据 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"));
    );
    

    更简单地说,如果它适合您的应用程序,您可以简单地覆盖 Devise::SessionsController 并使用 skip_before_filter :verify_authenticity_token 覆盖令牌检查。

【讨论】:

有人知道这个问题是否已向 Devise 开发人员提出? #2 对我不起作用,因为我在通过 Devise/Ajax 登录并使用之后发出的任何 POST 请求后收到 CSRF 真实性错误。我也不确定如何渲染新的 csrf 令牌,因为我已经在 :create 操作的最后一步渲染模板。我在这里提出了一个问题 (***.com/questions/26640326/…),如果您有时间查看一下,我将不胜感激 @sixty4bit 我似乎遇到了和你一样的问题,但是你的问题被删除了。你有想过吗? 请问您能否回答这个非常相似的问题? ***.com/questions/50159847/…【参考方案4】:

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

Devise issue tacker 相关讨论:https://github.com/plataformatec/devise/issues/2200

【讨论】:

【参考方案5】:

这是我的看法:

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 元标记保持更新。

【讨论】:

谢谢,当我从我的控制器调用 request_forgery_protection_tokenform_authenticity_token 时,我不得不使用字符串插值,因为我收到一个错误,提示我无法在 :authenticity_token:String 上调用 split...但是这种技术真的很好用。谢谢。 这是正确的答案,尽管我确实想知道重新生成新 crsf 令牌的安全隐患。例如,您应该添加 if self.request.format.symbol == :json 或您没有重定向的任何 mime 我根据您的建议更新了答案。很好的收获,谢谢! @marflar 你能展示你在那里做了什么吗?我有个类似的问题。为 :authenticity_token:Symbol 获取 Unexpected error while processing request: undefined method each'【参考方案6】:

我的回答大量借鉴了@Jimbo 和@Sija,但是我使用的是Rails CSRF Protection + Angular.js: protect_from_forgery makes me to log out on POST 建议的devise/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。在 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 操作附近发出并行请求。

【讨论】:

【参考方案7】:

我刚刚在我的布局文件中添加了它,它起作用了

    <%= 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 %>

【讨论】:

【参考方案8】:

回复@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

【讨论】:

这可能会回答评论中的问题,但因此不应该是对这个问题的回答。我建议你创建一个链接到这个问题的新问题,并用这个答案自我回答。然后,您还可以为此问题添加评论,链接到您新创建的问题【参考方案9】:

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

我所做的是,使用设计:

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

之后,我向重绘菜单的控制器#action 发出请求。在 javascript 中,我修改了 X-CSRF-Param 和 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 %>');

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

【讨论】:

【参考方案10】:

我的情况更简单。就我而言,我想做的只是:如果一个人坐在屏幕上,拿着一个表格,并且他们的会话超时(设计可超时会话超时),通常如果他们此时点击提交,设计会将他们弹回到登录屏幕。好吧,我不想这样,因为他们丢失了所有的表单数据。我使用 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 %>');

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

【讨论】:

以上是关于Rails,设计认证,CSRF 问题的主要内容,如果未能解决你的问题,请参考以下文章

使用 rails 4 认证的根路由进行设计无法正常工作

根据 AJAX 请求设计注销用户。导轨 3.1

设计用户登录为 CSRF 令牌真实性令牌提供身份验证错误

ember简单auth用rails设计用户名而不是电子邮件?

Django框架进阶7 django请求生命周期流程图, django中间件, csrf跨站请求伪造, auth认证模块

Rails 3.1 - 忽略 CSRF?