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

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

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

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

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

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

【问题讨论】:

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


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

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

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

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

  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。

更新: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 出色地解释了您遇到的问题背后的“原因”。您可以采取两种方法来解决此问题:

  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 覆盖令牌检查。

【讨论】:

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

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

          【讨论】:

            猜你喜欢
            • 1970-01-01
            • 1970-01-01
            • 2011-06-29
            • 1970-01-01
            • 1970-01-01
            • 1970-01-01
            • 1970-01-01
            • 2013-05-29
            • 2011-10-07
            相关资源
            最近更新 更多