更多内容请访问 rubyonrails.org:

Action Controller 高级主题

在本指南中,您将了解与控制器相关的一些高级主题。阅读本指南后,您将知道如何:

  • 防止跨站请求伪造。
  • 使用 Action Controller 内置的 HTTP 认证。
  • 直接向用户浏览器传输数据。
  • 从应用程序日志中过滤敏感参数。
  • 处理请求处理过程中可能引发的异常。
  • 使用内置的健康检查端点,用于负载均衡器和正常运行时间监控器。

1. 简介

本指南涵盖了 Rails 应用程序中与控制器相关的许多高级主题。有关 Action Controller 的介绍,请参阅Action Controller 概述指南。

2. 真实性令牌和请求伪造保护

跨站请求伪造 (CSRF) 是一种恶意攻击,通过冒充 Web 应用程序信任的用户来提交未经授权的请求。

避免此类攻击的第一步是确保应用程序中的所有“破坏性”操作(创建、更新和销毁)都使用非 GET 请求(如 POST、PUT 和 DELETE)。

然而,恶意站点仍然可以向您的站点发送非 GET 请求,因此 Rails 默认将请求伪造保护内置到控制器中。

这是通过使用protect_from_forgery方法添加令牌来完成的。此令牌被添加到每个请求中,并且只有您的服务器知道。Rails 会将收到的令牌与会话中的令牌进行验证。如果传入请求没有正确的匹配令牌,服务器将拒绝访问。

config.action_controller.default_protect_from_forgery 设置为 true 时,CSRF 令牌会自动添加,这是新创建的 Rails 应用程序的默认设置。也可以像这样手动添加:

class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception
end

ActionController::Base 的所有子类默认受到保护,并且会在未经验证的请求上引发 ActionController::InvalidAuthenticityToken 错误。

2.1. 表单中的真实性令牌

当您像这样使用 form_with 生成表单时:

<%= form_with model: @user do |form| %>
  <%= form.text_field :username %>
  <%= form.text_field :password %>
<% end %>

一个名为 authenticity_token 的 CSRF 令牌会自动作为隐藏字段添加到生成的 HTML 中。

<form accept-charset="UTF-8" action="/users/1" method="post">
<input type="hidden"
       value="67250ab105eb5ad10851c00a5621854a23af5489"
       name="authenticity_token"/>
<!-- fields -->
</form>

Rails 会将此令牌添加到使用表单助手生成的每个 form 中,因此大多数情况下您无需执行任何操作。如果您手动编写表单或出于其他原因需要添加令牌,可以通过form_authenticity_token方法获取。

<!-- app/views/layouts/application.html.erb -->
<head>
  <meta name="csrf-token" content="<%= form_authenticity_token %>">
</head>

form_authenticity_token 方法会生成一个有效的认证令牌。这在 Rails 不会自动添加它的地方(例如在自定义 Ajax 调用中)可能很有用。

您可以在安全指南中了解有关 CSRF 攻击和 CSRF 对策的更多详细信息。

3. 控制允许的浏览器版本

从 7.2 版本开始,Rails 控制器默认在 ApplicationController 中使用allow_browser方法,只允许现代浏览器访问。

class ApplicationController < ActionController::Base
  # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
  allow_browser versions: :modern
end

现代浏览器包括 Safari 17.2+、Chrome 120+、Firefox 121+、Opera 106+。您可以使用caniuse.com检查浏览器版本是否支持您希望使用的功能。

除了默认的 :modern,您还可以手动指定浏览器版本:

class ApplicationController < ActionController::Base
  # All versions of Chrome and Opera will be allowed, but no versions of "internet explorer" (ie). Safari needs to be 16.4+ and Firefox 121+.
  allow_browser versions: { safari: 16.4, firefox: 121, ie: false }
end

如果传递给 versions: 的哈希中匹配的浏览器版本低于指定版本,它们将被阻止。这意味着 versions: 中未提及的所有其他浏览器(在上述示例中为 Chrome 和 Opera),以及未报告用户代理头的代理,*都将被允许访问*。

您还可以在给定的控制器中使用 allow_browser,并通过 onlyexcept 指定操作。例如:

class MessagesController < ApplicationController
  # In addition to the browsers blocked by ApplicationController, also block Opera below 104 and Chrome below 119 for the show action.
  allow_browser versions: { opera: 104, chrome: 119 }, only: :show
end

被阻止的浏览器默认情况下将获得 public/406-unsupported-browser.html 文件,HTTP 状态码为“406 Not Acceptable”。

4. HTTP 认证

Rails 内置了三种 HTTP 认证机制:

  • 基本认证
  • 摘要认证
  • 令牌认证

4.1. HTTP 基本认证

HTTP 基本认证是一种简单的认证方法,要求用户输入用户名和密码才能访问网站或网站的特定部分(例如,管理部分)。这些凭据输入到浏览器的 HTTP 基本对话框窗口中。然后,用户的凭据会被编码并随每个请求一起发送在 HTTP 头中。

HTTP 基本认证是一种大多数浏览器都支持的认证方案。在 Rails 控制器中使用 HTTP 基本认证可以通过http_basic_authenticate_with方法完成。

class AdminsController < ApplicationController
  http_basic_authenticate_with name: "Arthur", password: "42424242"
end

有了上述配置,您可以创建继承自 AdminsController 的控制器。这些控制器中的所有操作都将使用 HTTP 基本认证并需要用户凭据。

HTTP 基本认证易于实现,但本身不安全,因为它会在网络上发送未加密的凭据。使用基本认证时请务必使用 HTTPS。您也可以强制使用 HTTPS

4.2. HTTP 摘要认证

HTTP 摘要认证比基本认证更安全,因为它不需要客户端在网络上发送未加密的密码。凭据被散列,并发送一个摘要

在 Rails 中使用摘要认证可以通过authenticate_or_request_with_http_digest方法完成。

class AdminsController < ApplicationController
  USERS = { "admin" => "helloworld" }

  before_action :authenticate

  private
    def authenticate
      authenticate_or_request_with_http_digest do |username|
        USERS[username]
      end
    end
end

authenticate_or_request_with_http_digest 块只接受一个参数——用户名。如果找到密码,该块会返回密码。如果返回值为 falsenil,则视为认证失败。

4.3. HTTP 令牌认证

令牌认证(也称为“Bearer”认证)是一种认证方法,客户端在成功登录后会收到一个唯一的令牌,然后将其包含在未来请求的 Authorization 头部中。客户端不是随每个请求发送凭据,而是发送这个令牌(表示用户会话的字符串)作为认证的“持有者”。

这种方法通过将凭据与持续会话分离来提高安全性。您可以使用预先发布的认证令牌执行认证。

在 Rails 中实现令牌认证可以通过使用authenticate_or_request_with_http_token方法来完成。

class PostsController < ApplicationController
  TOKEN = "secret"

  before_action :authenticate

  private
    def authenticate
      authenticate_or_request_with_http_token do |token, options|
        ActiveSupport::SecurityUtils.secure_compare(token, TOKEN)
      end
    end
end

authenticate_or_request_with_http_token 块接受两个参数——令牌和包含从 HTTP Authorization 头部解析的选项的哈希。如果认证成功,该块应返回 true。返回 falsenil 将导致认证失败。

5. 流式传输和文件下载

Rails 控制器提供了一种向用户发送文件而不是渲染 HTML 页面的方法。这可以通过send_datasend_file方法完成,它们将数据流式传输到客户端。send_file 方法是一个便捷方法,它允许您提供文件名,并会流式传输该文件的内容。

以下是使用 send_data 的示例:

require "prawn"
class ClientsController < ApplicationController
  # Generates a PDF document with information on the client and
  # returns it. The user will get the PDF as a file download.
  def download_pdf
    client = Client.find(params[:id])
    send_data generate_pdf(client),
              filename: "#{client.name}.pdf",
              type: "application/pdf"
  end

  private
    def generate_pdf(client)
      Prawn::Document.new do
        text client.name, align: :center
        text "Address: #{client.address}"
        text "Email: #{client.email}"
      end.render
    end
end

上述示例中的 download_pdf 动作调用了一个私有方法,该方法生成 PDF 文档并将其作为字符串返回。然后,此字符串将作为文件下载流式传输到客户端。

有时在向用户流式传输文件时,您可能不希望他们下载文件。例如,图像可以嵌入到 HTML 页面中。要告知浏览器文件不应被下载,您可以将 :disposition 选项设置为“inline”。此选项的默认值为“attachment”。

5.1. 发送文件

如果您想发送磁盘上已存在的文件,请使用 send_file 方法。

class ClientsController < ApplicationController
  # Stream a file that has already been generated and stored on disk.
  def download_pdf
    client = Client.find(params[:id])
    send_file("#{Rails.root}/files/clients/#{client.id}.pdf",
              filename: "#{client.name}.pdf",
              type: "application/pdf")
  end
end

默认情况下,文件将以 4 kB 为单位读取和流式传输,以避免一次将整个文件加载到内存中。您可以通过 :stream 选项关闭流式传输,或通过 :buffer_size 选项调整块大小。

如果未指定 :type,则会根据 :filename 中指定的文件扩展名进行猜测。如果该扩展名的内容类型未注册,则将使用 application/octet-stream

在使用来自客户端的数据(params、cookies 等)来定位磁盘上的文件时要小心。这存在安全风险,因为它可能允许某人访问敏感文件。

如果您可以将静态文件保存在 Web 服务器的公共文件夹中,则不建议通过 Rails 流式传输它们。让用户直接使用 Apache 或其他 Web 服务器下载文件效率要高得多,避免请求不必要地通过整个 Rails 堆栈。

5.2. RESTful 下载

虽然 send_data 运行良好,但如果您正在创建一个 RESTful 应用程序,为文件下载设置单独的操作通常没有必要。在 REST 术语中,上面示例中的 PDF 文件可以被视为客户端资源的另一种表示形式。Rails 提供了一种巧妙的方法来执行“RESTful”下载。以下是如何重写示例,使 PDF 下载成为 show 操作的一部分,而无需任何流式传输:

class ClientsController < ApplicationController
  # The user can request to receive this resource as HTML or PDF.
  def show
    @client = Client.find(params[:id])

    respond_to do |format|
      format.html
      format.pdf { render pdf: generate_pdf(@client) }
    end
  end
end

现在用户只需在 URL 中添加“.pdf”即可请求获取客户端的 PDF 版本。

GET /clients/1.pdf

您可以调用 format 上的任何方法,只要该扩展名已由 Rails 注册为 MIME 类型。Rails 已经注册了常见的 MIME 类型,如 "text/html""application/pdf"

Mime::Type.lookup_by_extension(:pdf)
# => "application/pdf"

如果您需要额外的 MIME 类型,请在文件 config/initializers/mime_types.rb 中调用Mime::Type.register。例如,这就是您注册富文本格式 (RTF) 的方法:

Mime::Type.register("application/rtf", :rtf)

如果您修改了初始化文件,则必须重新启动服务器才能使更改生效。

5.3. 任意数据的实时流式传输

Rails 允许您流式传输的不仅仅是文件。实际上,您可以流式传输响应对象中您想要的任何内容。ActionController::Live 模块允许您与浏览器建立持久连接。通过将此模块包含在控制器中,您可以在特定的时间点向浏览器发送任意数据。

class MyController < ActionController::Base
  include ActionController::Live

  def stream
    response.headers["Content-Type"] = "text/event-stream"
    100.times {
      response.stream.write "hello world\n"
      sleep 1
    }
  ensure
    response.stream.close
  end
end

上述示例将与浏览器保持持久连接,并发送 100 条 "hello world\n" 消息,每条消息间隔一秒。

请注意,您必须确保关闭响应流,否则流将无限期地保持套接字打开。您还必须在对响应流调用 write 之前将内容类型设置为 text/event-stream。在响应通过 writecommit 提交(当 response.committed? 返回一个真值时)后,不能写入头信息。

5.3.1. 示例用例

假设您正在制作一个卡拉 OK 机,用户想获取某首歌曲的歌词。每首 Song 都有特定数量的行,每行需要 num_beats 时间才能唱完。

如果我们想以卡拉 OK 的方式返回歌词(只有当歌手唱完上一行后才发送该行),那么我们可以按如下方式使用 ActionController::Live

class LyricsController < ActionController::Base
  include ActionController::Live

  def show
    response.headers["Content-Type"] = "text/event-stream"
    response.headers["Cache-Control"] = "no-cache"

    song = Song.find(params[:id])

    song.each do |line|
      response.stream.write line.lyrics
      sleep line.num_beats
    end
  ensure
    response.stream.close
  end
end

5.3.2. 流式传输注意事项

流式传输任意数据是一个极其强大的工具。如前面的示例所示,您可以选择何时以及发送什么内容通过响应流。但是,您还应该注意以下事项:

  • 每个响应流都会创建一个新线程,并从原始线程复制线程局部变量。过多的线程局部变量可能会对性能产生负面影响。同样,大量的线程也可能阻碍性能。
  • 未能关闭响应流将导致相应的套接字永久打开。确保在使用响应流时调用 close
  • WEBrick 服务器会缓冲所有响应,因此使用 ActionController::Live 进行流式传输将不起作用。您必须使用不会自动缓冲响应的 Web 服务器。

6. 日志过滤

Rails 在应用程序根目录的 log 文件夹中为每个环境保留一个日志文件。日志文件在调试应用程序时非常有用,但在生产环境中,您可能不希望将所有信息都存储在日志文件中。Rails 允许您指定不应存储的参数。

6.1. 参数过滤

您可以通过将敏感请求参数添加到应用程序配置中的config.filter_parameters来将其从日志文件中过滤掉。

config.filter_parameters << :password

这些参数将在日志中标记为 [FILTERED]

filter_parameters 中指定的参数将通过部分匹配正则表达式进行过滤。例如,:passw 将过滤掉 passwordpassword_confirmation 等。

Rails 在相应的初始化文件(initializers/filter_parameter_logging.rb)中添加了一组默认过滤器,包括 :passw:secret:token,以处理常见的应用程序参数,如 passwordpassword_confirmationmy_token

6.2. 重定向过滤

有时,过滤掉应用程序重定向到的敏感位置是可取的。您可以使用 config.filter_redirect 配置选项来完成此操作:

config.filter_redirect << "s3.amazonaws.com"

您可以将其设置为字符串、正则表达式或两者的数组。

config.filter_redirect.concat ["s3.amazonaws.com", /private_path/]

匹配的 URL 将被替换为 [FILTERED]。但是,如果您只想过滤参数,而不是整个 URL,您可以使用参数过滤。

7. 强制使用 HTTPS 协议

如果您想确保只能通过 HTTPS 与控制器通信,您可以通过在环境配置中启用ActionDispatch::SSL中间件,通过config.force_ssl来实现。

8. 内置健康检查端点

Rails 提供了一个内置的健康检查端点,可在 /up 路径访问。如果应用程序在没有异常的情况下启动,此端点将返回 200 状态码;否则返回 500 状态码。

在生产环境中,许多应用程序需要报告其状态,无论是向在出现问题时会通知工程师的正常运行时间监视器,还是用于确定给定实例健康状况的负载均衡器或 Kubernetes 控制器。此健康检查旨在成为一种通用解决方案,适用于多种情况。

虽然所有新生成的 Rails 应用程序都将在 /up 处进行健康检查,但您可以在“config/routes.rb”中将路径配置为任何您想要的值:

Rails.application.routes.draw do
  get "health" => "rails/health#show", as: :rails_health_check
end

现在,通过 GETHEAD 请求访问 /health 路径即可进行健康检查。

此端点不反映应用程序所有依赖项的状态,例如数据库或 Redis。如果您有应用程序特定的需求,请将“rails/health#show”替换为您自己的控制器操作。

报告应用程序的健康状况需要一些考虑。您必须决定要在检查中包含哪些内容。例如,如果第三方服务中断并且您的应用程序报告由于依赖项导致其中断,则您的应用程序可能会不必要地重新启动。理想情况下,您的应用程序应优雅地处理第三方中断。

9. 错误处理

您的应用程序很可能包含错误并抛出需要处理的异常。例如,如果用户跟踪了一个数据库中不再存在的资源的链接,Active Record 将抛出 ActiveRecord::RecordNotFound 异常。

Rails 默认的异常处理会对所有异常显示“500 Server Error”消息。如果在开发环境中发出请求,则会显示一个漂亮的堆栈跟踪和附加信息,以帮助您找出问题所在。如果在生产环境中发出请求,Rails 将显示一个简单的“500 Server Error”消息,如果存在路由错误或找不到记录,则显示“404 Not Found”。

您可以自定义如何捕获这些错误以及如何将其显示给用户。Rails 应用程序中有几个级别的异常处理可用。您可以使用 config.action_dispatch.show_exceptions 配置来控制 Rails 如何处理响应请求时引发的异常。您可以在配置指南中了解有关异常级别的更多信息。

9.1. 默认错误模板

默认情况下,在生产环境中,应用程序将渲染错误页面。这些页面包含在公共文件夹中的静态 HTML 文件中,例如 404.html500.html 等。您可以自定义这些文件以添加一些额外的信息和样式。

错误模板是静态 HTML 文件,因此您不能使用 ERB、SCSS 或布局。

9.2. rescue_from

您可以使用rescue_from方法捕获特定错误并对其进行不同的处理。它可以在整个控制器及其子类中处理特定类型(或多种类型)的异常。

当发生被 rescue_from 指令捕获的异常时,异常对象会传递给处理器。

下面是一个示例,说明如何使用 rescue_from 拦截所有 ActiveRecord::RecordNotFound 错误并对其进行处理:

class ApplicationController < ActionController::Base
  rescue_from ActiveRecord::RecordNotFound, with: :record_not_found

  private
    def record_not_found
      render plain: "Record Not Found", status: 404
    end
end

处理程序可以是方法,也可以是传递给 :with 选项的 Proc 对象。您也可以直接使用块而不是显式的 Proc 对象。

上面的示例根本没有改进默认的异常处理,但它旨在展示一旦您捕获了特定的异常,您就可以自由地对它们做任何您想做的事情。例如,您可以创建自定义异常类,当用户无权访问应用程序的某个部分时抛出这些异常:

class ApplicationController < ActionController::Base
  rescue_from User::NotAuthorized, with: :user_not_authorized

  private
    def user_not_authorized
      flash[:error] = "You don't have access to this section."
      redirect_back(fallback_location: root_path)
    end
end

class ClientsController < ApplicationController
  # Check that the user has the right authorization to access clients.
  before_action :check_authorization

  def edit
    @client = Client.find(params[:id])
  end

  private
    # If the user is not authorized, throw the custom exception.
    def check_authorization
      raise User::NotAuthorized unless current_user.admin?
    end
end

rescue_fromExceptionStandardError 一起使用会导致严重的副作用,因为它会阻止 Rails 正确处理异常。因此,除非有充分的理由,否则不建议这样做。

某些异常只能从 ApplicationController 类中挽救,因为它们在控制器初始化和操作执行之前被引发。



回到顶部