diff --git a/lib/plausible/auth/api_key.ex b/lib/plausible/auth/api_key.ex index 61cbf66f292d..594d70cb5b31 100644 --- a/lib/plausible/auth/api_key.ex +++ b/lib/plausible/auth/api_key.ex @@ -3,10 +3,11 @@ defmodule Plausible.Auth.ApiKey do import Ecto.Changeset @required [:user_id, :key, :name] - @optional [:scopes] + @optional [:scopes, :hourly_request_limit] schema "api_keys" do field :name, :string field :scopes, {:array, :string}, default: ["stats:read:*"] + field :hourly_request_limit, :integer field :key, :string, virtual: true field :key_hash, :string diff --git a/lib/plausible_web/controllers/api/helpers.ex b/lib/plausible_web/controllers/api/helpers.ex index 068c24cf245a..6229ed870742 100644 --- a/lib/plausible_web/controllers/api/helpers.ex +++ b/lib/plausible_web/controllers/api/helpers.ex @@ -21,4 +21,11 @@ defmodule PlausibleWeb.Api.Helpers do |> Phoenix.Controller.json(%{error: msg}) |> halt() end + + def too_many_requests(conn, msg) do + conn + |> put_status(429) + |> Phoenix.Controller.json(%{error: msg}) + |> halt() + end end diff --git a/lib/plausible_web/plugs/authorize_stats_api.ex b/lib/plausible_web/plugs/authorize_stats_api.ex index b41daadff689..6b76ec2e2dd4 100644 --- a/lib/plausible_web/plugs/authorize_stats_api.ex +++ b/lib/plausible_web/plugs/authorize_stats_api.ex @@ -9,7 +9,9 @@ defmodule PlausibleWeb.AuthorizeStatsApiPlug do end def call(conn, _opts) do - with {:ok, api_key} <- get_bearer_token(conn), + with {:ok, token} <- get_bearer_token(conn), + {:ok, api_key} <- find_api_key(token), + :ok <- check_api_key_rate_limit(api_key), {:ok, site} <- verify_access(api_key, conn.params["site_id"]) do assign(conn, :site, site) else @@ -25,6 +27,12 @@ defmodule PlausibleWeb.AuthorizeStatsApiPlug do "Missing site ID. Please provide the required site_id parameter with your request." ) + {:error, :rate_limit, limit} -> + H.too_many_requests( + conn, + "Too many API requests. Your API key is limited to #{limit} requests per hour." + ) + {:error, :invalid_api_key} -> H.unauthorized( conn, @@ -36,13 +44,11 @@ defmodule PlausibleWeb.AuthorizeStatsApiPlug do defp verify_access(_api_key, nil), do: {:error, :missing_site_id} defp verify_access(api_key, site_id) do - hashed_key = ApiKey.do_hash(api_key) - found_key = Repo.get_by(ApiKey, key_hash: hashed_key) site = Repo.get_by(Plausible.Site, domain: site_id) - is_owner = site && found_key && Plausible.Sites.is_owner?(found_key.user_id, site) + is_owner = site && Plausible.Sites.is_owner?(api_key.user_id, site) cond do - found_key && site && is_owner -> {:ok, site} + site && is_owner -> {:ok, site} true -> {:error, :invalid_api_key} end end @@ -57,4 +63,18 @@ defmodule PlausibleWeb.AuthorizeStatsApiPlug do _ -> {:error, :missing_api_key} end end + + defp find_api_key(token) do + hashed_key = ApiKey.do_hash(token) + found_key = Repo.get_by(ApiKey, key_hash: hashed_key) + if found_key, do: {:ok, found_key}, else: {:error, :invalid_api_key} + end + + @one_hour 60 * 60 * 1000 + defp check_api_key_rate_limit(api_key) do + case Hammer.check_rate("api_request:#{api_key.id}", @one_hour, api_key.hourly_request_limit) do + {:allow, _} -> :ok + {:deny, _} -> {:error, :rate_limit, api_key.hourly_request_limit} + end + end end diff --git a/priv/repo/migrations/20210525085655_add_rate_limit_to_api_keys.exs b/priv/repo/migrations/20210525085655_add_rate_limit_to_api_keys.exs new file mode 100644 index 000000000000..a0234faf9986 --- /dev/null +++ b/priv/repo/migrations/20210525085655_add_rate_limit_to_api_keys.exs @@ -0,0 +1,9 @@ +defmodule Plausible.Repo.Migrations.AddRateLimitToApiKeys do + use Ecto.Migration + + def change do + alter table(:api_keys) do + add :hourly_request_limit, :integer, null: false, default: 1000 + end + end +end diff --git a/test/plausible_web/controllers/api/external_stats_controller/auth_test.exs b/test/plausible_web/controllers/api/external_stats_controller/auth_test.exs index 8eea5be27f08..ce65e91b70a3 100644 --- a/test/plausible_web/controllers/api/external_stats_controller/auth_test.exs +++ b/test/plausible_web/controllers/api/external_stats_controller/auth_test.exs @@ -51,4 +51,29 @@ defmodule PlausibleWeb.Api.ExternalStatsController.AuthTest do "Missing site ID. Please provide the required site_id parameter with your request." } end + + test "limits the rate of API requests", %{user: user} do + api_key = insert(:api_key, user_id: user.id, hourly_request_limit: 3) + + build_conn() + |> Plug.Conn.put_req_header("authorization", "Bearer #{api_key.key}") + |> get("/api/v1/stats/aggregate") + + build_conn() + |> Plug.Conn.put_req_header("authorization", "Bearer #{api_key.key}") + |> get("/api/v1/stats/aggregate") + + build_conn() + |> Plug.Conn.put_req_header("authorization", "Bearer #{api_key.key}") + |> get("/api/v1/stats/aggregate") + + conn = + build_conn() + |> Plug.Conn.put_req_header("authorization", "Bearer #{api_key.key}") + |> get("/api/v1/stats/aggregate") + + assert json_response(conn, 429) == %{ + "error" => "Too many API requests. Your API key is limited to 3 requests per hour." + } + end end