Add rate limit to API requests
This commit is contained in:
parent
5ca39cf49a
commit
1a93542cd7
|
|
@ -3,10 +3,11 @@ defmodule Plausible.Auth.ApiKey do
|
||||||
import Ecto.Changeset
|
import Ecto.Changeset
|
||||||
|
|
||||||
@required [:user_id, :key, :name]
|
@required [:user_id, :key, :name]
|
||||||
@optional [:scopes]
|
@optional [:scopes, :hourly_request_limit]
|
||||||
schema "api_keys" do
|
schema "api_keys" do
|
||||||
field :name, :string
|
field :name, :string
|
||||||
field :scopes, {:array, :string}, default: ["stats:read:*"]
|
field :scopes, {:array, :string}, default: ["stats:read:*"]
|
||||||
|
field :hourly_request_limit, :integer
|
||||||
|
|
||||||
field :key, :string, virtual: true
|
field :key, :string, virtual: true
|
||||||
field :key_hash, :string
|
field :key_hash, :string
|
||||||
|
|
|
||||||
|
|
@ -21,4 +21,11 @@ defmodule PlausibleWeb.Api.Helpers do
|
||||||
|> Phoenix.Controller.json(%{error: msg})
|
|> Phoenix.Controller.json(%{error: msg})
|
||||||
|> halt()
|
|> halt()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def too_many_requests(conn, msg) do
|
||||||
|
conn
|
||||||
|
|> put_status(429)
|
||||||
|
|> Phoenix.Controller.json(%{error: msg})
|
||||||
|
|> halt()
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,9 @@ defmodule PlausibleWeb.AuthorizeStatsApiPlug do
|
||||||
end
|
end
|
||||||
|
|
||||||
def call(conn, _opts) do
|
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
|
{:ok, site} <- verify_access(api_key, conn.params["site_id"]) do
|
||||||
assign(conn, :site, site)
|
assign(conn, :site, site)
|
||||||
else
|
else
|
||||||
|
|
@ -25,6 +27,12 @@ defmodule PlausibleWeb.AuthorizeStatsApiPlug do
|
||||||
"Missing site ID. Please provide the required site_id parameter with your request."
|
"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} ->
|
{:error, :invalid_api_key} ->
|
||||||
H.unauthorized(
|
H.unauthorized(
|
||||||
conn,
|
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, nil), do: {:error, :missing_site_id}
|
||||||
|
|
||||||
defp verify_access(api_key, site_id) do
|
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)
|
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
|
cond do
|
||||||
found_key && site && is_owner -> {:ok, site}
|
site && is_owner -> {:ok, site}
|
||||||
true -> {:error, :invalid_api_key}
|
true -> {:error, :invalid_api_key}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -57,4 +63,18 @@ defmodule PlausibleWeb.AuthorizeStatsApiPlug do
|
||||||
_ -> {:error, :missing_api_key}
|
_ -> {:error, :missing_api_key}
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -51,4 +51,29 @@ defmodule PlausibleWeb.Api.ExternalStatsController.AuthTest do
|
||||||
"Missing site ID. Please provide the required site_id parameter with your request."
|
"Missing site ID. Please provide the required site_id parameter with your request."
|
||||||
}
|
}
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue