defmodule PhoenixApiToolkit.Security.HmacPlug do @moduledoc """ Checks HMAC authentication. Expects a HMAC- of the request body to be present in the "authorization" header. Supported algorithms are those supported by `:crypto.hmac/3`. Relies on `PhoenixApiToolkit.CacheBodyReader` being called by `Plug.Parsers`. To be considered a valid request by the plug, a request has to meet the following criteria: - the request path must match the `"path"` stated in the request body - the request HTTP method must match the `"method"` stated in the request body - the request `"timestamp"` must not be older than `max_age` If the token is invalid for any reason, `PhoenixApiToolkit.Security.HmacVerificationError` is raised, resulting in a 401 Unauthorized response. ## Configuration options - `hmac_secret` as a binary (mandatory): the secret used to create the HMAC - `hash_algorithm` as an atom (optional, defaults to `:sha256`) the hashing algorithm used to create the HMAC - `max_age` in seconds (optional, defaults to `120`) the maximum age of the request before it is considered invalid ## Request body format example ``` { "path": "/api/v2/accounts", "method": "POST", "timestamp": 1321321545, "contents": { "key1": "value1", "something": "else" } } ``` ## Examples use Plug.Test alias PhoenixApiToolkit.Security.HmacPlug import PhoenixApiToolkit.TestHelpers import PhoenixApiToolkit.CacheBodyReader @secret "supersecretkey" def opts, do: HmacPlug.init(lazy_hmac_secret: fn -> @secret end) def conn_for_hmac(method, path, raw_body) do conn(method, path, raw_body |> Jason.decode!()) |> application_json() |> put_raw_body(raw_body) end # a correctly signed request is passed through iex> body = create_hmac_plug_body("/", "POST", %{hello: "world"}) iex> {:ok, _raw_body, conn} = conn_for_hmac(:post, "/", body) |> put_hmac(body, @secret) |> cache_and_read_body() iex> conn = HmacPlug.call(conn, opts()) iex> conn.body_params["contents"] %{"hello" => "world"} # requests that are noncompliant result in a PhoenixApiToolkit.Security.HmacVerificationError iex> body = create_hmac_plug_body("/", "PUT", %{hello: "world"}) iex> {:ok, _raw_body, conn} = conn_for_hmac(:post, "/", body) |> put_hmac(body, @secret) |> cache_and_read_body() iex> HmacPlug.call(conn, opts()) ** (PhoenixApiToolkit.Security.HmacVerificationError) HMAC invalid: method mismatch iex> body = create_hmac_plug_body("/", "POST", %{hello: "world"}, 12345) iex> {:ok, _raw_body, conn} = conn_for_hmac(:post, "/", body) |> put_hmac(body, @secret) |> cache_and_read_body() iex> HmacPlug.call(conn, opts()) ** (PhoenixApiToolkit.Security.HmacVerificationError) HMAC invalid: expired """ import Plug.Conn alias PhoenixApiToolkit.Security.HmacVerificationError alias PhoenixApiToolkit.CacheBodyReader require Logger @doc false def init(opts) do opts = Keyword.new(opts) %{ lazy_hmac_secret: Keyword.fetch!(opts, :lazy_hmac_secret), max_age: Keyword.get(opts, :max_age, 120), hash_algorithm: Keyword.get(opts, :hash_algorithm, :sha256) } end @doc false def call(conn, %{ lazy_hmac_secret: lazy_hmac_secret, max_age: max_age, hash_algorithm: hash_algorithm }) do hmac_secret = lazy_hmac_secret.() with hmac <- parse_auth_header(conn), body = CacheBodyReader.get_raw_request_body(conn) || "", message_hmac = :crypto.hmac(hash_algorithm, hmac_secret, body) |> Base.encode64(), {:hmac_matches, true} <- {:hmac_matches, hmac == message_hmac}, :ok <- verify_method(conn), :ok <- verify_path(conn), :ok <- verify_timestamp(conn, max_age) do conn else {:hmac_matches, false} -> raise HmacVerificationError, "HMAC invalid: hash mismatch" error -> error |> inspect() |> Logger.error() raise HmacVerificationError, "HMAC invalid: unknown error" end end ############ # Privates # ############ defp parse_auth_header(conn) do case get_req_header(conn, "authorization") do [hmac] -> hmac _ -> raise HmacVerificationError, "HMAC invalid: missing authorization header" end end defp verify_timestamp(%{body_params: body_params}, max_age) do with timestamp when is_integer(timestamp) <- body_params["timestamp"], true <- timestamp + max_age >= DateTime.utc_now() |> DateTime.to_unix(:second) do :ok else false -> raise HmacVerificationError, "HMAC invalid: expired" _ -> raise HmacVerificationError, "HMAC invalid: timestamp missing" end end defp verify_method(%{method: exp_method, body_params: body_params}) do with method when not is_nil(method) <- body_params["method"], true <- method == exp_method do :ok else false -> raise HmacVerificationError, "HMAC invalid: method mismatch" nil -> raise HmacVerificationError, "HMAC invalid: method missing" end end defp verify_path(%{request_path: exp_path, body_params: body_params}) do with path when not is_nil(path) <- body_params["path"], true <- path == exp_path do :ok else false -> raise HmacVerificationError, "HMAC invalid: path mismatch" _ -> raise HmacVerificationError, "HMAC invalid: path missing" end end end