Add API keys to account

account
nikitosing 3 years ago committed by Viktor Baranov
parent 96d3bbc63b
commit 464f260f13
  1. 68
      apps/block_scout_web/lib/block_scout_web/controllers/account/api_key_controller.ex
  2. 34
      apps/block_scout_web/lib/block_scout_web/templates/account/api_key/form.html.eex
  3. 44
      apps/block_scout_web/lib/block_scout_web/templates/account/api_key/index.html.eex
  4. 14
      apps/block_scout_web/lib/block_scout_web/templates/account/api_key/row.html.eex
  5. 3
      apps/block_scout_web/lib/block_scout_web/templates/account/common/_nav.html.eex
  6. 1
      apps/block_scout_web/lib/block_scout_web/templates/layout/_topnav.html.eex
  7. 20
      apps/block_scout_web/lib/block_scout_web/views/access_helpers.ex
  8. 3
      apps/block_scout_web/lib/block_scout_web/views/account/api_key_view.ex
  9. 5
      apps/block_scout_web/lib/block_scout_web/web_router.ex
  10. 38
      apps/block_scout_web/priv/gettext/default.pot
  11. 38
      apps/block_scout_web/priv/gettext/en/LC_MESSAGES/default.po
  12. 113
      apps/explorer/lib/explorer/accounts/api/key.ex
  13. 13
      apps/explorer/lib/explorer/accounts/api/plan.ex
  14. 2
      apps/explorer/lib/explorer/accounts/identity.ex
  15. 34
      apps/explorer/priv/repo/migrations/20220407134152_add_api_keys_and_plans_tables.exs

@ -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

@ -0,0 +1,34 @@
<section class="container">
<div class="row">
<%= render BlockScoutWeb.Account.CommonView, "_nav.html", conn: @conn %>
<div class="col-sm-10">
<div class="card">
<div class="card-body">
<h1 class="card-title list-title-description header-account"><%=if @method == :update, do: gettext("Update"), else: gettext("Add") %> <%= gettext "API key"%></h1>
<div class="col-sm-10 card-body-account">
<% path = if @method == :update, do: api_key_path(@conn, @method, @api_key.data.api_key), else: api_key_path(@conn, @method) %>
<%= form_for @api_key, path, fn f -> %>
<%= if f.data.api_key do %>
<div class="form-group">
<%= label f, :api_key, gettext("API key"), class: "control-label", style: "font-size: 14px" %>
<%= text_input f, :api_key, class: "form-control", placeholder: gettext("API key"), readonly: true %>
<%= error_tag f, :api_key %>
</div>
<% end %>
<div class="form-group">
<%= label f, :name, gettext("Name"), class: "control-label", style: "font-size: 14px" %>
<%= text_input f, :name, class: "form-control", placeholder: gettext("Name this API key") %>
<%= error_tag f, :name %>
</div>
<br>
<div class="form-group float-right form-input">
<a class="btn btn-line" href="<%= api_key_path(@conn, :index) %>"><%= gettext "Back to API keys (Cancel)"%></a>
<%= submit gettext("Save"), class: "button button-primary button-sm ml-3" %>
</div>
<% end %>
</div>
</div>
</div>
</div>
</div>
</section>

@ -0,0 +1,44 @@
<section class="container">
<div class="row">
<%= render BlockScoutWeb.Account.CommonView, "_nav.html", conn: @conn %>
<div class="col-md">
<div class="card">
<div class="card-body">
<h1 class="card-title list-title-description header-account"><%= gettext "Api keys" %> </h1>
<br>
<div class="col-sm">
<div class="mb-3 row o-flow-x">
<%= if @api_keys == [] do %>
<div style="min-width: 100%;">
<div class="tile tile-muted text-center" data-selector="empty-coin-balances-list">
<%= gettext "You don't have API keys yet" %>
</div>
</div>
<h2></h2>
<% else %>
<table class="table mb-3 table-watchlist">
<thead style="font-size: 14px; color: #6c757d" >
<tr>
<th scope="col"><%= gettext "Name" %></th>
<th scope="col"><%= gettext "API key" %></th>
<th scope="col"></th>
<th scope="col"></th>
</tr>
</thead>
<tbody style="font-size: 15px; color: #6c757d" >
<%= Enum.map(@api_keys, fn key ->
render("row.html", api_key: key, conn: @conn)
end) %>
</tbody>
</table>
<% end %>
</div>
</div>
<%= if Enum.count(@api_keys) < 3 do %>
<a class="button button-primary button-sm" href="<%= api_key_path(@conn, :new) %>"><%= gettext "Add API key" %></a>
<% end %>
</div>
</div>
</div>
</div>
</section>

@ -0,0 +1,14 @@
<tr>
<td><%= @api_key.name %></td>
<td>
<span><%= @api_key.api_key %></span>
<%= 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;" %>
</td>
<td>
<%= link gettext("Remove"), to: api_key_path(@conn, :delete, @api_key.api_key), method: :delete %>
</td>
<td>
<%= link gettext("Edit"), to: api_key_path(@conn, :edit, @api_key.api_key) %>
</td>
</tr>

@ -12,5 +12,8 @@
<li class="nav-item">
<a class="dropdown-item fs-14" href="<%= tag_transaction_path(@conn, :index) %>">Transaction Tags</a>
</li>
<li class="nav-item">
<a class="dropdown-item fs-14" href="<%= api_key_path(@conn, :index) %>"><%= gettext "API keys" %></a>
</li>
</ul>
</div>

@ -187,6 +187,7 @@
<a href="<%= watchlist_path(@conn, :show) %>" class= "dropdown-item">Watch list</a>
<a href="<%= tag_address_path(@conn, :index) %>" class= "dropdown-item">Address Tags</a>
<a href="<%= tag_transaction_path(@conn, :index) %>" class= "dropdown-item">Transaction Tags</a>
<a href="<%= api_key_path(@conn, :index) %>" class= "dropdown-item"><%= gettext "API keys" %></a>
<a href="<%= BlockScoutWeb.LayoutView.sign_out_link %>" class= "dropdown-item">Sign out</a>
</div>
</li>

@ -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} ->

@ -0,0 +1,3 @@
defmodule BlockScoutWeb.Account.ApiKeyView do
use BlockScoutWeb, :view
end

@ -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)

@ -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"

@ -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"

@ -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

@ -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

@ -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

@ -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
Loading…
Cancel
Save