diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/account/api_key_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/account/api_key_controller.ex new file mode 100644 index 0000000000..8f49af2d2f --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/controllers/account/api_key_controller.ex @@ -0,0 +1,68 @@ +defmodule BlockScoutWeb.Account.ApiKeyController do + use BlockScoutWeb, :controller + + alias Explorer.Account.Api.Key, as: ApiKey + + import BlockScoutWeb.Account.AuthController, only: [authenticate!: 1] + + def new(conn, _params) do + authenticate!(conn) + + render(conn, "form.html", method: :create, api_key: empty_api_key()) + end + + def create(conn, %{"key" => api_key}) do + current_user = authenticate!(conn) + + case ApiKey.create_api_key_changeset_and_insert(%ApiKey{}, %{name: api_key["name"], identity_id: current_user.id}) do + {:ok, _} -> + redirect(conn, to: api_key_path(conn, :index)) + + {:error, invalid_api_key} -> + render(conn, "form.html", method: :create, api_key: invalid_api_key) + end + end + + def create(conn, _) do + redirect(conn, to: api_key_path(conn, :index)) + end + + def index(conn, _params) do + current_user = authenticate!(conn) + + render(conn, "index.html", api_keys: ApiKey.get_api_keys_by_user_id(current_user.id)) + end + + def edit(conn, %{"id" => api_key}) do + current_user = authenticate!(conn) + + case ApiKey.api_key_by_api_key_and_identity_id(api_key, current_user.id) do + nil -> + conn + |> put_status(:not_found) + |> put_view(BlockScoutWeb.PageNotFoundView) + |> render("index.html", token: nil) + + %ApiKey{} = api_key -> + render(conn, "form.html", method: :update, api_key: ApiKey.changeset(api_key)) + end + end + + def update(conn, %{"id" => api_key, "key" => %{"api_key" => api_key, "name" => name}}) do + current_user = authenticate!(conn) + + ApiKey.update_name_api_key(name, current_user.id, api_key) + + redirect(conn, to: api_key_path(conn, :index)) + end + + def delete(conn, %{"id" => api_key}) do + current_user = authenticate!(conn) + + ApiKey.delete_api_key(current_user.id, api_key) + + redirect(conn, to: api_key_path(conn, :index)) + end + + defp empty_api_key, do: ApiKey.changeset() +end diff --git a/apps/block_scout_web/lib/block_scout_web/templates/account/api_key/form.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/account/api_key/form.html.eex new file mode 100644 index 0000000000..f34f47cbc6 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/templates/account/api_key/form.html.eex @@ -0,0 +1,34 @@ +
+
+ <%= render BlockScoutWeb.Account.CommonView, "_nav.html", conn: @conn %> +
+
+
+

<%=if @method == :update, do: gettext("Update"), else: gettext("Add") %> <%= gettext "API key"%>

+ +
+
+
+
+
diff --git a/apps/block_scout_web/lib/block_scout_web/templates/account/api_key/index.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/account/api_key/index.html.eex new file mode 100644 index 0000000000..e31705f847 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/templates/account/api_key/index.html.eex @@ -0,0 +1,44 @@ +
+
+ <%= render BlockScoutWeb.Account.CommonView, "_nav.html", conn: @conn %> +
+
+
+

<%= gettext "Api keys" %>

+
+
+
+ <%= if @api_keys == [] do %> +
+
+ <%= gettext "You don't have API keys yet" %> +
+
+

+ <% else %> + + + + + + + + + + + <%= Enum.map(@api_keys, fn key -> + render("row.html", api_key: key, conn: @conn) + end) %> + +
<%= gettext "Name" %><%= gettext "API key" %>
+ <% end %> +
+
+ <%= if Enum.count(@api_keys) < 3 do %> + <%= gettext "Add API key" %> + <% end %> +
+
+
+
+
\ No newline at end of file diff --git a/apps/block_scout_web/lib/block_scout_web/templates/account/api_key/row.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/account/api_key/row.html.eex new file mode 100644 index 0000000000..3481209f09 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/templates/account/api_key/row.html.eex @@ -0,0 +1,14 @@ + + <%= @api_key.name %> + + <%= @api_key.api_key %> + <%= render BlockScoutWeb.CommonComponentsView, "_btn_copy.html", + additional_classes: ["btn-copy-icon-small", "btn-copy-icon-custom", "btn-copy-icon-no-borders"], clipboard_text: @api_key.api_key, aria_label: gettext("Copy API key"), title: gettext("Copy API key"), style: "display: inline-block; vertical-align: text-bottom; position: initial; margin-top: 1px;" %> + + + <%= link gettext("Remove"), to: api_key_path(@conn, :delete, @api_key.api_key), method: :delete %> + + + <%= link gettext("Edit"), to: api_key_path(@conn, :edit, @api_key.api_key) %> + + diff --git a/apps/block_scout_web/lib/block_scout_web/templates/account/common/_nav.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/account/common/_nav.html.eex index d632c0b169..0b4e839f9b 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/account/common/_nav.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/account/common/_nav.html.eex @@ -12,5 +12,8 @@ + diff --git a/apps/block_scout_web/lib/block_scout_web/templates/layout/_topnav.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/layout/_topnav.html.eex index 8709d502ec..26de812222 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/layout/_topnav.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/layout/_topnav.html.eex @@ -187,6 +187,7 @@ Watch list Address Tags Transaction Tags + <%= gettext "API keys" %> Sign out diff --git a/apps/block_scout_web/lib/block_scout_web/views/access_helpers.ex b/apps/block_scout_web/lib/block_scout_web/views/access_helpers.ex index c89ac10435..c3cbaeedef 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/access_helpers.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/access_helpers.ex @@ -8,6 +8,7 @@ defmodule BlockScoutWeb.AccessHelpers do alias BlockScoutWeb.API.APILogger alias BlockScoutWeb.API.RPC.RPCView alias BlockScoutWeb.WebRouter.Helpers + alias Explorer.Account.Api.Key, as: ApiKey alias Plug.Conn alias RemoteIp @@ -81,11 +82,18 @@ defmodule BlockScoutWeb.AccessHelpers do ip = remote_ip_from_headers || remote_ip ip_string = to_string(:inet_parse.ntoa(ip)) + plan = get_plan(conn.query_params) + cond do conn.query_params && Map.has_key?(conn.query_params, "apikey") && Map.get(conn.query_params, "apikey") == static_api_key -> rate_limit_by_key(static_api_key, api_rate_limit_by_key) + conn.query_params && Map.has_key?(conn.query_params, "apikey") && !is_nil(plan) -> + conn.query_params + |> Map.get("apikey") + |> rate_limit_by_key(plan.max_req_per_second) + Enum.member?(api_rate_limit_whitelisted_ips(), ip_string) -> rate_limit_by_ip(ip_string, api_rate_limit_by_ip) @@ -95,6 +103,18 @@ defmodule BlockScoutWeb.AccessHelpers do end end + defp get_plan(query_params) do + with true <- query_params && Map.has_key?(query_params, "apikey"), + {:ok, casted_api_key} <- ApiKey.cast_api_key(Map.get(query_params, "apikey")), + api_key <- ApiKey.api_key_with_plan_by_api_key(casted_api_key), + true <- !is_nil(api_key) do + api_key.identity.plan + else + _ -> + nil + end + end + defp rate_limit_by_key(api_key, api_rate_limit_by_key) do case Hammer.check_rate("api-#{api_key}", 1_000, api_rate_limit_by_key) do {:allow, _count} -> diff --git a/apps/block_scout_web/lib/block_scout_web/views/account/api_key_view.ex b/apps/block_scout_web/lib/block_scout_web/views/account/api_key_view.ex new file mode 100644 index 0000000000..14b112e96d --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/views/account/api_key_view.ex @@ -0,0 +1,3 @@ +defmodule BlockScoutWeb.Account.ApiKeyView do + use BlockScoutWeb, :view +end diff --git a/apps/block_scout_web/lib/block_scout_web/web_router.ex b/apps/block_scout_web/lib/block_scout_web/web_router.ex index c1db477448..9b3c1e23cb 100644 --- a/apps/block_scout_web/lib/block_scout_web/web_router.ex +++ b/apps/block_scout_web/lib/block_scout_web/web_router.ex @@ -50,6 +50,11 @@ defmodule BlockScoutWeb.WebRouter do only: [:new, :create, :edit, :update, :delete], as: :watchlist_address ) + + resources("/api_key", Account.ApiKeyController, + only: [:new, :create, :edit, :update, :delete, :index], + as: :api_key + ) end # Disallows Iframes (write routes) diff --git a/apps/block_scout_web/priv/gettext/default.pot b/apps/block_scout_web/priv/gettext/default.pot index ca936a1129..d3f83464aa 100644 --- a/apps/block_scout_web/priv/gettext/default.pot +++ b/apps/block_scout_web/priv/gettext/default.pot @@ -141,6 +141,11 @@ msgstr "" msgid "Add" msgstr "" +#: lib/block_scout_web/templates/account/api_key/index.html.eex:38 +#, elixir-autogen, elixir-format +msgid "Add API key" +msgstr "" + #: lib/block_scout_web/templates/address/_validator_metadata_modal.html.eex:16 #: lib/block_scout_web/templates/transaction_log/_logs.html.eex:20 #: lib/block_scout_web/views/address_view.ex:104 @@ -583,6 +588,12 @@ msgstr "" msgid "Copy ABI" msgstr "" +#: lib/block_scout_web/templates/account/api_key/row.html.eex:6 +#: lib/block_scout_web/templates/account/api_key/row.html.eex:6 +#, elixir-autogen, elixir-format +msgid "Copy API key" +msgstr "" + #: lib/block_scout_web/templates/account/tag_address/row.html.eex:7 #: lib/block_scout_web/templates/account/tag_address/row.html.eex:7 #: lib/block_scout_web/templates/account/tag_transaction/row.html.eex:10 @@ -889,6 +900,11 @@ msgstr "" msgid "Easy Cowboy! This block does not exist yet!" msgstr "" +#: lib/block_scout_web/templates/account/api_key/row.html.eex:12 +#, elixir-autogen, elixir-format +msgid "Edit" +msgstr "" + #: lib/block_scout_web/templates/transaction/_emission_reward_tile.html.eex:5 #, elixir-autogen, elixir-format msgid "Emission Contract" @@ -1447,6 +1463,8 @@ msgstr "" msgid "N/A bytes" msgstr "" +#: lib/block_scout_web/templates/account/api_key/form.html.eex:19 +#: lib/block_scout_web/templates/account/api_key/index.html.eex:22 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:52 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:59 #: lib/block_scout_web/templates/log/_data_decoded_view.html.eex:4 @@ -1455,6 +1473,11 @@ msgstr "" msgid "Name" msgstr "" +#: lib/block_scout_web/templates/account/api_key/form.html.eex:20 +#, elixir-autogen, elixir-format +msgid "Name this API key" +msgstr "" + #: lib/block_scout_web/templates/address_token/overview.html.eex:44 #, elixir-autogen, elixir-format msgid "Net Worth" @@ -1707,6 +1730,11 @@ msgstr "" msgid "Records" msgstr "" +#: lib/block_scout_web/templates/account/api_key/row.html.eex:9 +#, elixir-autogen, elixir-format +msgid "Remove" +msgstr "" + #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:155 #, elixir-autogen, elixir-format msgid "Request URL" @@ -1755,6 +1783,11 @@ msgstr "" msgid "Run" msgstr "" +#: lib/block_scout_web/templates/account/api_key/form.html.eex:26 +#, elixir-autogen, elixir-format +msgid "Save" +msgstr "" + #: lib/block_scout_web/templates/address_logs/index.html.eex:16 #: lib/block_scout_web/templates/layout/_search.html.eex:34 #, elixir-autogen, elixir-format @@ -2675,6 +2708,11 @@ msgstr "" msgid "Yes" msgstr "" +#: lib/block_scout_web/templates/account/api_key/index.html.eex:14 +#, elixir-autogen, elixir-format +msgid "You don't have API keys yet" +msgstr "" + #: lib/block_scout_web/templates/address/overview.html.eex:111 #, elixir-autogen, elixir-format msgid "at" diff --git a/apps/block_scout_web/priv/gettext/en/LC_MESSAGES/default.po b/apps/block_scout_web/priv/gettext/en/LC_MESSAGES/default.po index c979e80a7a..53d903f8e0 100644 --- a/apps/block_scout_web/priv/gettext/en/LC_MESSAGES/default.po +++ b/apps/block_scout_web/priv/gettext/en/LC_MESSAGES/default.po @@ -141,6 +141,11 @@ msgstr "" msgid "Add" msgstr "" +#: lib/block_scout_web/templates/account/api_key/index.html.eex:38 +#, elixir-autogen, elixir-format +msgid "Add API key" +msgstr "" + #: lib/block_scout_web/templates/address/_validator_metadata_modal.html.eex:16 #: lib/block_scout_web/templates/transaction_log/_logs.html.eex:20 #: lib/block_scout_web/views/address_view.ex:104 @@ -583,6 +588,12 @@ msgstr "" msgid "Copy ABI" msgstr "" +#: lib/block_scout_web/templates/account/api_key/row.html.eex:6 +#: lib/block_scout_web/templates/account/api_key/row.html.eex:6 +#, elixir-autogen, elixir-format +msgid "Copy API key" +msgstr "" + #: lib/block_scout_web/templates/account/tag_address/row.html.eex:7 #: lib/block_scout_web/templates/account/tag_address/row.html.eex:7 #: lib/block_scout_web/templates/account/tag_transaction/row.html.eex:10 @@ -889,6 +900,11 @@ msgstr "" msgid "Easy Cowboy! This block does not exist yet!" msgstr "" +#: lib/block_scout_web/templates/account/api_key/row.html.eex:12 +#, elixir-autogen, elixir-format +msgid "Edit" +msgstr "" + #: lib/block_scout_web/templates/transaction/_emission_reward_tile.html.eex:5 #, elixir-autogen, elixir-format msgid "Emission Contract" @@ -1447,6 +1463,8 @@ msgstr "" msgid "N/A bytes" msgstr "" +#: lib/block_scout_web/templates/account/api_key/form.html.eex:19 +#: lib/block_scout_web/templates/account/api_key/index.html.eex:22 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:52 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:59 #: lib/block_scout_web/templates/log/_data_decoded_view.html.eex:4 @@ -1455,6 +1473,11 @@ msgstr "" msgid "Name" msgstr "" +#: lib/block_scout_web/templates/account/api_key/form.html.eex:20 +#, elixir-autogen, elixir-format +msgid "Name this API key" +msgstr "" + #: lib/block_scout_web/templates/address_token/overview.html.eex:44 #, elixir-autogen, elixir-format msgid "Net Worth" @@ -1707,6 +1730,11 @@ msgstr "" msgid "Records" msgstr "" +#: lib/block_scout_web/templates/account/api_key/row.html.eex:9 +#, elixir-autogen, elixir-format +msgid "Remove" +msgstr "" + #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:155 #, elixir-autogen, elixir-format msgid "Request URL" @@ -1755,6 +1783,11 @@ msgstr "" msgid "Run" msgstr "" +#: lib/block_scout_web/templates/account/api_key/form.html.eex:26 +#, elixir-autogen, elixir-format +msgid "Save" +msgstr "" + #: lib/block_scout_web/templates/address_logs/index.html.eex:16 #: lib/block_scout_web/templates/layout/_search.html.eex:34 #, elixir-autogen, elixir-format @@ -2675,6 +2708,11 @@ msgstr "" msgid "Yes" msgstr "" +#: lib/block_scout_web/templates/account/api_key/index.html.eex:14 +#, elixir-autogen, elixir-format +msgid "You don't have API keys yet" +msgstr "" + #: lib/block_scout_web/templates/address/overview.html.eex:111 #, elixir-autogen, elixir-format msgid "at" diff --git a/apps/explorer/lib/explorer/accounts/api/key.ex b/apps/explorer/lib/explorer/accounts/api/key.ex new file mode 100644 index 0000000000..fdb789aa8d --- /dev/null +++ b/apps/explorer/lib/explorer/accounts/api/key.ex @@ -0,0 +1,113 @@ +defmodule Explorer.Account.Api.Key do + @moduledoc """ + Module is responsible for schema for API keys, keys is used to track number of requests to the API endpoints + """ + use Explorer.Schema + + alias Explorer.Accounts.Identity + alias Ecto.{Changeset, UUID} + alias Explorer.Repo + + import Ecto.Changeset + + @max_key_per_account 3 + + @primary_key false + schema "account_api_keys" do + field(:name, :string) + field(:api_key, UUID, primary_key: true) + belongs_to(:identity, Identity) + + timestamps() + end + + @attrs ~w(api_key name identity_id)a + + def changeset do + %__MODULE__{} + |> cast(%{}, @attrs) + end + + def changeset(%__MODULE__{} = api_key, attrs \\ %{}) do + api_key + |> cast(attrs, @attrs) + |> validate_required(@attrs) + end + + def create_api_key_changeset_and_insert(%__MODULE__{} = api_key \\ %__MODULE__{}, attrs \\ %{}) do + api_key + |> cast(attrs, @attrs) + |> put_change(:api_key, generate_api_key()) + |> validate_required(@attrs) + |> unique_constraint(:api_key) + |> api_key_count_constraint() + |> Repo.insert() + end + + def api_key_count_constraint(%Changeset{changes: %{identity_id: identity_id}} = api_key) do + if identity_id + |> api_keys_by_identity_id_query() + |> limit(@max_key_per_account) + |> Repo.aggregate(:count, :api_key) == @max_key_per_account do + api_key + |> add_error(:name, "Max #{@max_key_per_account} keys per account") + else + api_key + end + end + + def generate_api_key do + UUID.generate() + end + + def api_keys_by_identity_id_query(id) do + __MODULE__ + |> where([api_key], api_key.identity_id == ^id) + end + + def api_key_by_api_key_and_identity_id_query(api_key, identity_id) do + __MODULE__ + |> where([api_key], api_key.identity_id == ^identity_id and api_key.api_key == ^api_key) + end + + def api_key_by_api_key_and_identity_id(api_key, identity_id) do + api_key + |> api_key_by_api_key_and_identity_id_query(identity_id) + |> Repo.one() + end + + def update_name_api_key(new_name, identity_id, api_key) do + api_key + |> api_key_by_api_key_and_identity_id_query(identity_id) + |> update([key], set: [name: ^new_name, updated_at: fragment("NOW()")]) + |> Repo.update_all([]) + end + + def delete_api_key(identity_id, api_key) do + api_key + |> api_key_by_api_key_and_identity_id_query(identity_id) + |> Repo.delete_all() + end + + def to_string(api_key_value) do + api_key_value + |> Base.encode64() + end + + def get_api_keys_by_user_id(id) do + id + |> api_keys_by_identity_id_query() + |> Repo.all() + end + + def api_key_with_plan_by_api_key(api_key) do + __MODULE__ + |> where([api_key], api_key.api_key == ^api_key) + |> Repo.one() + |> Repo.preload(identity: :plan) + end + + def cast_api_key(api_key) do + UUID.cast(api_key) + end +end diff --git a/apps/explorer/lib/explorer/accounts/api/plan.ex b/apps/explorer/lib/explorer/accounts/api/plan.ex new file mode 100644 index 0000000000..b89b33bc9c --- /dev/null +++ b/apps/explorer/lib/explorer/accounts/api/plan.ex @@ -0,0 +1,13 @@ +defmodule Explorer.Account.Api.Plan do + @moduledoc """ + Module is responsible for schema for API plans, each plan contains its name and maximum number of requests per second + """ + use Explorer.Schema + + schema "account_api_plans" do + field(:name, :string) + field(:max_req_per_second, :integer) + + timestamps() + end +end diff --git a/apps/explorer/lib/explorer/accounts/identity.ex b/apps/explorer/lib/explorer/accounts/identity.ex index 202d159607..cb1c69886a 100644 --- a/apps/explorer/lib/explorer/accounts/identity.ex +++ b/apps/explorer/lib/explorer/accounts/identity.ex @@ -5,6 +5,7 @@ defmodule Explorer.Accounts.Identity do use Ecto.Schema import Ecto.Changeset + alias Explorer.Account.Api.Plan alias Explorer.Accounts.{TagAddress, Watchlist} schema "account_identities" do @@ -13,6 +14,7 @@ defmodule Explorer.Accounts.Identity do field(:name, :string) has_many(:tag_addresses, TagAddress) has_many(:watchlists, Watchlist) + belongs_to(:plan, Plan) timestamps() end diff --git a/apps/explorer/priv/repo/migrations/20220407134152_add_api_keys_and_plans_tables.exs b/apps/explorer/priv/repo/migrations/20220407134152_add_api_keys_and_plans_tables.exs new file mode 100644 index 0000000000..94362b20a0 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20220407134152_add_api_keys_and_plans_tables.exs @@ -0,0 +1,34 @@ +defmodule Explorer.Repo.Migrations.AddApiKeysAndPlansTables do + use Ecto.Migration + + def change do + create table(:account_api_plans, primary_key: false) do + add(:id, :serial, null: false, primary_key: true) + add(:max_req_per_second, :smallint) + add(:name, :string) + + timestamps() + end + + create(unique_index(:account_api_plans, [:id])) + + execute( + "INSERT INTO account_api_plans (id, max_req_per_second, name, inserted_at, updated_at) VALUES (1, 10, 'Free Plan', NOW(), NOW());" + ) + + create table(:account_api_keys, primary_key: false) do + add(:identity_id, references(:account_identities, column: :id, on_delete: :delete_all), null: false) + add(:name, :string) + add(:api_key, :uuid, null: false, primary_key: true) + + timestamps() + end + + alter table(:account_identities) do + add(:plan_id, references(:account_api_plans, column: :id), default: 1) + end + + create(unique_index(:account_api_keys, [:api_key])) + create(index(:account_api_keys, [:identity_id])) + end +end