在 Phoenix 中校验 Webhook 请求

Dev

Feb 1, 2024

在 Phoenix 中校验 Webhook 请求

问题背景

在使用 Webhook 时,一般都会对传入的的请求进行某种方式的的校验。常见的校验方式为将传入的请求的请求体结合一个密钥通过 HMAC 生成一个哈希值连带着请求传入,因此在服务端这边需要获取到原始请求体的值。

这个本来应该很容易做到的需求在 Phoenix 框架中需要一些额外的工作来实现。原因是所有传入的请求都会经过 Plug.Parsers 这个 Plug,然后请求体的内容则会根据 content-type header 被解析称对应的数据结果,同时原始的请求体内容也会被丢弃。

为了解决这个问题,Plug.Parsers 额外提供了一个 body_reader 选项供用户传入自定义的 Body Reader 实现,以便在请求体被解析并丢弃之前读取到原始内容。

代码实现

Plug 提供了一个 Plug.Conn.read_body/2 函数来读取请求体,我们只需要在自己的 Body Reader 实现中调用该函数并将请求体内容缓存下来即可。

我们自行实现的 read_body 函数签名和返回值需要和 Plug.Conn.read_body/2 保持一致,其返回值类型为 {:ok, binary(), Plug.Conn.t()} | {:more, binary(), Plug.Conn.t()} | {:error, term()}, 对于内容过长的请求体,调用 read_body/2 会返回 {:more, binary(), Plug.Conn.t()},需要多次调用才能获取到完整内容。

另外我们只需要对特定路由的请求缓存请求体内容,Plug.Conn 当中提供了一个 path_info 字段,可以获取到当前请求的路径信息,基于此来判断是否需要缓存。

完整代码实现如下所示:

defmodule MyWeb.BodyReader do
  # 用于存储原始请求体的 key
  @store_key :raw_req_body

  # 从 conn 中读取缓存的请求体的函数接口
  def get_raw_body(%Plug.Conn{} = conn) do
    case conn.private[@store_key] do
      nil -> nil
      chunks -> chunks |> Enum.reverse() |> Enum.join("")
    end
  end

  # 自定义的 read_body/2 实现
  def read_body(%Plug.Conn{} = conn, opts \\ []) do
    case Plug.Conn.read_body(conn, opts) do
      {:ok, binary, conn} ->
        {:ok, binary, maybe_put_body_chunk(conn, binary)}

      {:more, binary, conn} ->
        {:more, binary, maybe_put_body_chunk(conn, binary)}

      {:error, reason} ->
        {:error, reason}
    end
  end

  # 根据 conn.path_info 判断是否需要缓存
  defp enabled?(conn) do
    case conn.path_info do
      ["webhooks" | _rest] -> true # 仅当路径为 /webhooks/* 时启用缓存
      _ -> false
    end
  end

  defp maybe_put_body_chunk(conn, chunk) do
    if enabled?(conn) do
      put_body_chunk(conn, chunk)
    else
      conn
    end
  end

  # 将缓存的请求体内容存入 conn.private 私有信息中
  defp put_body_chunk(%Plug.Conn{} = conn, chunk) when is_binary(chunk) do
    chunks = conn.private[@store_key] || []
    Plug.Conn.put_private(conn, @store_key, [chunk | chunks])
  end
end

最后只需要在启用 Plug.Parsers 时传入 body_reader 选项即可,如果使用 Phoenix 框架,需要在 endpoint.ex 文件中修改如下:

plug Plug.Parsers,
  parsers: [:urlencoded, :multipart, :json],
  pass: ["*/*"],
  body_reader: {MyWeb.BodyReader, :read_body, []}, # <- 添加该行内容
  json_decoder: Phoenix.json_library()

在 Controller 中调用 MyWeb.BodyReader.read_body/2 来获取缓存的原始请求体内容:

alias MyWeb.BodyReader
def index(conn, _params) do
  payload = BodyReader.get_raw_body(conn)
end

此外,计算请求体内容的 HMAC 可以通过 Erlang 标准库的 :crypto 模块实现:

:crypto.mac(:hmac, :sha256, "secret", "request body")

参考

上述代码实现参考自下列内容: