Add bare admin panel (#927)

* Add router for admin routes

* Add mechanism for having a generated admin recovery key

* Add admin panel to web interface

Web portion now includes a set of routes exclusive for managing a BlockScout
instance.
Whenever an instance hasn't been configuration, a user will be prompted with a
page to setup their instance. Once they've followed the steps and registered,
the user will be able to perform administrative tasks.
pull/940/head
Alex Garibay 6 years ago committed by GitHub
parent d04b2b3fb4
commit c60bd06e5e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      apps/block_scout_web/lib/block_scout_web.ex
  2. 47
      apps/block_scout_web/lib/block_scout_web/admin_router.ex
  3. 7
      apps/block_scout_web/lib/block_scout_web/controllers/admin/dashboard_controller.ex
  4. 37
      apps/block_scout_web/lib/block_scout_web/controllers/admin/session_controller.ex
  5. 90
      apps/block_scout_web/lib/block_scout_web/controllers/admin/setup_controller.ex
  6. 29
      apps/block_scout_web/lib/block_scout_web/plug/admin/check_owner_registered.ex
  7. 26
      apps/block_scout_web/lib/block_scout_web/plug/admin/require_admin_role.ex
  8. 20
      apps/block_scout_web/lib/block_scout_web/plug/fetch_user_from_session.ex
  9. 1
      apps/block_scout_web/lib/block_scout_web/router.ex
  10. 1
      apps/block_scout_web/lib/block_scout_web/templates/admin/dashboard/index.html.eex
  11. 17
      apps/block_scout_web/lib/block_scout_web/templates/admin/session/login_form.html.eex
  12. 19
      apps/block_scout_web/lib/block_scout_web/templates/admin/setup/admin_registration.html.eex
  13. 25
      apps/block_scout_web/lib/block_scout_web/templates/admin/setup/verify.html.eex
  14. 15
      apps/block_scout_web/lib/block_scout_web/templates/form/text_field.html.eex
  15. 3
      apps/block_scout_web/lib/block_scout_web/views/admin/dashboard_view.ex
  16. 7
      apps/block_scout_web/lib/block_scout_web/views/admin/session_view.ex
  17. 7
      apps/block_scout_web/lib/block_scout_web/views/admin/setup_view.ex
  18. 19
      apps/block_scout_web/lib/block_scout_web/views/error_helpers.ex
  19. 66
      apps/block_scout_web/lib/block_scout_web/views/form_view.ex
  20. 26
      apps/block_scout_web/test/block_scout_web/controllers/admin/dashboard_controller_test.exs
  21. 83
      apps/block_scout_web/test/block_scout_web/controllers/admin/session_controller_test.exs
  22. 109
      apps/block_scout_web/test/block_scout_web/controllers/admin/setup_controller_test.exs
  23. 27
      apps/block_scout_web/test/block_scout_web/plug/admin/check_owner_registered_test.exs
  24. 42
      apps/block_scout_web/test/block_scout_web/plug/admin/require_admin_role_test.exs
  25. 46
      apps/block_scout_web/test/block_scout_web/plug/fetch_user_from_session_test.exs
  26. 2
      apps/block_scout_web/test/support/conn_case.ex
  27. 1
      apps/explorer/.gitignore
  28. 72
      apps/explorer/lib/explorer/accounts/accounts.ex
  29. 22
      apps/explorer/lib/explorer/accounts/user/authenticate.ex
  30. 2
      apps/explorer/lib/explorer/accounts/user/registration.ex
  31. 5
      apps/explorer/lib/explorer/accounts/user_contact.ex
  32. 76
      apps/explorer/lib/explorer/admin.ex
  33. 41
      apps/explorer/lib/explorer/admin/administrator.ex
  34. 67
      apps/explorer/lib/explorer/admin/recovery.ex
  35. 32
      apps/explorer/lib/explorer/admin/role.ex
  36. 4
      apps/explorer/lib/explorer/application.ex
  37. 15
      apps/explorer/priv/repo/migrations/20181008195723_create_administrators.exs
  38. 10
      apps/explorer/priv/repo/migrations/20181015173318_add_case_insensitive_extension.exs
  39. 23
      apps/explorer/priv/repo/migrations/20181015173319_modify_users_username.exs
  40. 23
      apps/explorer/priv/repo/migrations/20181016163236_modify_user_contacts_email.exs
  41. 51
      apps/explorer/test/explorer/accounts/accounts_test.exs
  42. 2
      apps/explorer/test/explorer/accounts/user_contact_test.exs
  43. 43
      apps/explorer/test/explorer/admin/administrator/role_test.exs
  44. 74
      apps/explorer/test/explorer/admin/recovery_test.exs
  45. 57
      apps/explorer/test/explorer/admin_test.exs
  46. 26
      apps/explorer/test/support/factory.ex

@ -26,6 +26,8 @@ defmodule BlockScoutWeb do
import BlockScoutWeb.Gettext import BlockScoutWeb.Gettext
import BlockScoutWeb.ErrorHelpers import BlockScoutWeb.ErrorHelpers
import Plug.Conn import Plug.Conn
alias BlockScoutWeb.AdminRouter.Helpers, as: AdminRoutes
end end
end end

@ -0,0 +1,47 @@
defmodule BlockScoutWeb.AdminRouter do
@moduledoc """
Router for admin pages.
"""
use BlockScoutWeb, :router
alias BlockScoutWeb.Plug.FetchUserFromSession
alias BlockScoutWeb.Plug.Admin.{CheckOwnerRegistered, RequireAdminRole}
pipeline :browser do
plug(:accepts, ["html"])
plug(:fetch_session)
plug(:fetch_flash)
plug(:protect_from_forgery)
plug(BlockScoutWeb.CSPHeader)
end
pipeline :check_configured do
plug(CheckOwnerRegistered)
end
pipeline :ensure_admin do
plug(FetchUserFromSession)
plug(RequireAdminRole)
end
scope "/setup", BlockScoutWeb.Admin do
pipe_through([:browser])
get("/", SetupController, :configure)
post("/", SetupController, :configure_admin)
end
scope "/login", BlockScoutWeb.Admin do
pipe_through([:browser, :check_configured])
get("/", SessionController, :new)
post("/", SessionController, :create)
end
scope "/", BlockScoutWeb.Admin do
pipe_through([:browser, :check_configured, :ensure_admin])
get("/", DashboardController, :index)
end
end

@ -0,0 +1,7 @@
defmodule BlockScoutWeb.Admin.DashboardController do
use BlockScoutWeb, :controller
def index(conn, _) do
render(conn, "index.html")
end
end

@ -0,0 +1,37 @@
defmodule BlockScoutWeb.Admin.SessionController do
use BlockScoutWeb, :controller
alias Ecto.Changeset
alias Explorer.{Accounts, Admin}
alias Explorer.Accounts.User.Authenticate
def new(conn, _) do
changeset = Authenticate.changeset()
render(conn, "login_form.html", changeset: changeset)
end
def create(conn, %{"authenticate" => params}) do
with {:user, {:ok, user}} <- {:user, Accounts.authenticate(params)},
{:admin, {:ok, _}} <- {:admin, Admin.from_user(user)} do
conn
|> put_session(:user_id, user.id)
|> redirect(to: AdminRoutes.dashboard_path(conn, :index))
else
{:user, {:error, :invalid_credentials}} ->
changeset = Authenticate.changeset(params)
render(conn, "login_form.html", changeset: changeset)
{:user, {:error, %Changeset{} = changeset}} ->
render(conn, "login_form.html", changeset: changeset)
{:admin, {:error, :not_found}} ->
changeset = Authenticate.changeset()
render(conn, "login_form.html", changeset: changeset)
end
end
def create(conn, _) do
changeset = Authenticate.changeset()
render(conn, "login_form.html", changeset: changeset)
end
end

@ -0,0 +1,90 @@
defmodule BlockScoutWeb.Admin.SetupController do
use BlockScoutWeb, :controller
import BlockScoutWeb.AdminRouter.Helpers
alias BlockScoutWeb.Endpoint
alias Explorer.Accounts.User.Registration
alias Explorer.Admin
alias Phoenix.Token
@admin_registration_salt "admin-registration"
plug(:redirect_to_login_if_configured)
# Step 2: enter new admin credentials
def configure(conn, %{"state" => state}) do
case valid_state?(state) do
true ->
changeset = Registration.changeset()
render(conn, "admin_registration.html", changeset: changeset)
false ->
render(conn, "verify.html")
end
end
# Step 1: enter recovery key
def configure(conn, _) do
render(conn, "verify.html")
end
# Step 1: verify recovery token
def configure_admin(conn, %{"verify" => %{"recovery_key" => key}}) do
if key == Admin.recovery_key() do
redirect(conn, to: setup_path(conn, :configure, %{state: generate_secure_token()}))
else
render(conn, "verify.html")
end
end
# Step 2: register new admin
def configure_admin(conn, %{"state" => state, "registration" => registration}) do
with true <- valid_state?(state),
{:ok, %{user: user, admin: _admin}} <- Admin.register_owner(registration) do
conn
|> put_session(:user_id, user.id)
|> redirect(to: dashboard_path(conn, :index))
else
false ->
render(conn, "verify.html")
{:error, changeset} ->
render(conn, "admin_registration.html", changeset: changeset)
end
end
# Step 1: enter recovery key
def configure_admin(conn, _) do
render(conn, "verify.html")
end
@doc false
def generate_secure_token do
key = Admin.recovery_key()
Token.sign(Endpoint, @admin_registration_salt, key)
end
defp valid_state?(state) do
# 5 minutes
max_age_in_seconds = 300
opts = [max_age: max_age_in_seconds]
case Token.verify(Endpoint, @admin_registration_salt, state, opts) do
{:ok, _} -> true
_ -> false
end
end
defp redirect_to_login_if_configured(conn, _) do
case Admin.owner() do
{:ok, _} ->
conn
|> redirect(to: session_path(conn, :new))
|> halt()
_ ->
conn
end
end
end

@ -0,0 +1,29 @@
defmodule BlockScoutWeb.Plug.Admin.CheckOwnerRegistered do
@moduledoc """
Checks that an admin owner has registered.
If the admin owner, the user is redirected to a page
with instructions of how to continue setup.
"""
import Phoenix.Controller, only: [redirect: 2]
import Plug.Conn
alias BlockScoutWeb.AdminRouter.Helpers, as: AdminRoutes
alias Explorer.Admin
alias Plug.Conn
def init(opts), do: opts
def call(%Conn{} = conn, _opts) do
case Admin.owner() do
{:ok, _} ->
conn
{:error, :not_found} ->
conn
|> redirect(to: AdminRoutes.setup_path(conn, :configure))
|> halt()
end
end
end

@ -0,0 +1,26 @@
defmodule BlockScoutWeb.Plug.Admin.RequireAdminRole do
@moduledoc """
Authorization plug requiring a user to be authenticated and have an admin role.
"""
import Plug.Conn
import Phoenix.Controller, only: [redirect: 2]
alias BlockScoutWeb.AdminRouter.Helpers, as: AdminRoutes
alias Explorer.Admin
def init(opts), do: opts
def call(conn, _) do
with user when not is_nil(user) <- conn.assigns[:user],
{:ok, admin} <- Admin.from_user(user) do
assign(conn, :admin, admin)
else
_ ->
conn
|> redirect(to: AdminRoutes.session_path(conn, :new))
|> halt()
end
end
end

@ -0,0 +1,20 @@
defmodule BlockScoutWeb.Plug.FetchUserFromSession do
@moduledoc """
Fetches a `t:Explorer.Accounts.User.t/0` record if a user id is found in the session.
"""
import Plug.Conn
alias Explorer.Accounts
def init(opts), do: opts
def call(conn, _opts) do
with user_id when not is_nil(user_id) <- get_session(conn, :user_id),
{:ok, user} <- Accounts.fetch_user(user_id) do
assign(conn, :user, user)
else
_ -> conn
end
end
end

@ -2,6 +2,7 @@ defmodule BlockScoutWeb.Router do
use BlockScoutWeb, :router use BlockScoutWeb, :router
forward("/wobserver", Wobserver.Web.Router) forward("/wobserver", Wobserver.Web.Router)
forward("/admin", BlockScoutWeb.AdminRouter)
pipeline :browser do pipeline :browser do
plug(:accepts, ["html"]) plug(:accepts, ["html"])

@ -0,0 +1,17 @@
<div class="container">
<div class="row justify-content-center">
<div class="col col-8">
<div class="card">
<div class="card-body">
<h2 class="card-title text-center" data-test="administrator_login">Administrator Login</h2>
<%= form_for @changeset, session_path(@conn, :create), fn f -> %>
<%= FormView.text_field(f, :username, :text, id: "username", required: true, label: "Username") %>
<%= FormView.text_field(f, :password, :password, id: "password", required: true, label: "Password") %>
<button class="button button-primary">Login</button>
<% end %>
</div>
</div>
</div>
</div>
</div>

@ -0,0 +1,19 @@
<div class="container">
<div class="row justify-content-center">
<div class="col col-8">
<div class="card">
<div class="card-body">
<h2 class="card-title text-center" data-test="administrator_registration">Administrator Setup</h2>
<%= form_for @changeset, setup_path(@conn, :configure_admin, %{state: @conn.query_params["state"]}), fn f -> %>
<%= FormView.text_field(f, :username, :text, id: "username", required: true, label: "Username") %>
<%= FormView.text_field(f, :email, :email, id: "email", required: true, label: "Email Address") %>
<%= FormView.text_field(f, :password, :password, id: "password", required: true, label: "Password") %>
<%= FormView.text_field(f, :password_confirmation, :password, id: "password-confirmation", required: true, label: "Password Confirmation") %>
<button class="button button-primary">Create Admin Account</button>
<% end %>
</div>
</div>
</div>
</div>
</div>

@ -0,0 +1,25 @@
<div class="container">
<div class="row justify-content-center">
<div class="col col-8">
<div class="card">
<div class="card-body">
<h2 class="card-title text-center" data-test="administrator_verify">Administrator Setup</h2>
<p>You have not setup the administrator account for BlockScout. Run the following command in your terminal at the root directory of where your application is deployed. Paste the value inside of the <strong>Recovery Key</strong> field. <strong>Do not share this key!</strong></p>
<div class="tile tile-muted" style="margin-bottom: 1em;">
<pre class="pre-wrap"><code><span class="text-dark">pbcopy &#60; apps/explorer/priv/.recovery</span></code></pre>
</div>
<%= form_for @conn, setup_path(@conn, :configure_admin), [as: "verify"], fn f -> %>
<div class="form-group">
<label for="recovery-key"><strong>Recovery Key</strong></label>
<%= password_input(f, :recovery_key, class: "form-control", type: "password", id: "recovery-key", placeholder: "JRAJpuEGNKM1XQK3zpWMdAAHVzQtJfDyW/sN/Zn1Ev8=", required: true) %>
</div>
<button class="button button-primary">Continue Setup</button>
<% end %>
</div>
</div>
</div>
</div>
</div>

@ -0,0 +1,15 @@
<div class="form-group">
<%= if @label do %>
<%= if @id do %>
<label for="<%= @id %>"><%= @label %></label>
<% else %>
<label><%= @label %></label>
<% end %>
<% end %>
<%= @input_field %>
<%= for error <- @errors do %>
<div class="invalid-feedback">
<%= error %>
</div>
<% end %>
</div>

@ -0,0 +1,3 @@
defmodule BlockScoutWeb.Admin.DashboardView do
use BlockScoutWeb, :view
end

@ -0,0 +1,7 @@
defmodule BlockScoutWeb.Admin.SessionView do
use BlockScoutWeb, :view
import BlockScoutWeb.AdminRouter.Helpers
alias BlockScoutWeb.FormView
end

@ -0,0 +1,7 @@
defmodule BlockScoutWeb.Admin.SetupView do
use BlockScoutWeb, :view
import BlockScoutWeb.AdminRouter.Helpers
alias BlockScoutWeb.FormView
end

@ -5,6 +5,10 @@ defmodule BlockScoutWeb.ErrorHelpers do
use Phoenix.HTML use Phoenix.HTML
alias Ecto.Changeset
alias Phoenix.HTML.Form
alias Plug.Conn
@doc """ @doc """
Generates tag for inlined form input errors. Generates tag for inlined form input errors.
""" """
@ -14,6 +18,21 @@ defmodule BlockScoutWeb.ErrorHelpers do
end) end)
end end
@doc """
Gets the errors for a form's input.
"""
def errors_for_field(%Form{source: %Conn{}}, _), do: []
def errors_for_field(%Form{source: %Changeset{action: nil}}, _), do: []
def errors_for_field(%Form{source: %Changeset{action: :ignore}}, _), do: []
def errors_for_field(%Form{source: %Changeset{errors: errors}}, field) do
for error <- Keyword.get_values(errors, field) do
translate_error(error)
end
end
@doc """ @doc """
Translates an error message using gettext. Translates an error message using gettext.
""" """

@ -0,0 +1,66 @@
defmodule BlockScoutWeb.FormView do
use BlockScoutWeb, :view
alias Phoenix.HTML.Form
@type text_input_type :: :email | :hidden | :password | :text
@type text_field_option ::
{:default_value, String.t()}
| {:id, String.t()}
| {:label, String.t()}
| {:placeholder, String.t()}
| {:required, boolean()}
defguard is_text_input(type) when type in ~w(email hidden password text)a
@doc """
Renders a text input field with certain properties.
## Supported Options
* `:label` - Label for the input field
## Options as HTML 5 Attriutes
The following options will be applied as HTML 5 attributes on the
`<input>` element:
* `:default_value` - Default value to attach to the input field
* `:id` - ID to attatch to the input field
* `:placeholder` - Placeholder text for the input field
* `:required` - Mark the input field as required
* `:type` - Input field type
"""
@spec text_field(Form.t(), atom(), text_input_type(), [text_field_option()]) :: Phoenix.HTML.safe()
def text_field(%Form{} = form, form_key, input_type, opts \\ [])
when is_text_input(input_type) and is_atom(form_key) do
errors = errors_for_field(form, form_key)
label = Keyword.get(opts, :label)
id = Keyword.get(opts, :id)
supported_input_field_attrs = ~w(default_value id placeholder required)a
base_input_field_opts = Keyword.take(opts, supported_input_field_attrs)
input_field_class =
case errors do
[_ | _] -> "form-control is-invalid"
_ -> "form-control"
end
input_field_opts = Keyword.put(base_input_field_opts, :class, input_field_class)
input_field = input_for_type(input_type).(form, form_key, input_field_opts)
render_opts = [
errors: errors,
id: id,
input_field: input_field,
label: label
]
render("text_field.html", render_opts)
end
defp input_for_type(:email), do: &email_input/3
defp input_for_type(:text), do: &text_input/3
defp input_for_type(:hidden), do: &hidden_input/3
defp input_for_type(:password), do: &password_input/3
end

@ -0,0 +1,26 @@
defmodule BlockScoutWeb.Admin.DashboardControllerTest do
use BlockScoutWeb.ConnCase
alias BlockScoutWeb.Router
describe "index/2" do
setup %{conn: conn} do
admin = insert(:administrator)
conn =
conn
|> bypass_through(Router, [:browser])
|> get("/")
|> put_session(:user_id, admin.user.id)
|> send_resp(200, "")
|> recycle()
{:ok, conn: conn}
end
test "shows the dashboard page", %{conn: conn} do
result = get(conn, "/admin" <> AdminRoutes.dashboard_path(conn, :index))
assert html_response(result, 200) =~ "administrator_dashboard"
end
end
end

@ -0,0 +1,83 @@
defmodule BlockScoutWeb.Admin.SessionControllerTest do
use BlockScoutWeb.ConnCase
setup %{conn: conn} do
conn =
conn
|> bypass_through()
|> get("/")
{:ok, conn: conn}
end
describe "new/2" do
test "redirects to setup page if not configured", %{conn: conn} do
result = get(conn, AdminRoutes.session_path(conn, :new))
assert redirected_to(result) == AdminRoutes.setup_path(conn, :configure)
end
test "shows the admin login page", %{conn: conn} do
insert(:administrator)
result = get(conn, AdminRoutes.session_path(conn, :new))
assert html_response(result, 200) =~ "administrator_login"
end
end
describe "create/2" do
test "redirects to setup page if not configured", %{conn: conn} do
result = post(conn, AdminRoutes.session_path(conn, :create), %{})
assert redirected_to(result) == AdminRoutes.setup_path(conn, :configure)
end
test "redirects to dashboard on successful admin login", %{conn: conn} do
admin = insert(:administrator)
params = %{
"authenticate" => %{
username: admin.user.username,
password: "password"
}
}
result = post(conn, AdminRoutes.session_path(conn, :create), params)
assert redirected_to(result) == AdminRoutes.dashboard_path(conn, :index)
end
test "reshows form if params are invalid", %{conn: conn} do
insert(:administrator)
params = %{"authenticate" => %{}}
result = post(conn, AdminRoutes.session_path(conn, :create), params)
assert html_response(result, 200) =~ "administrator_login"
end
test "reshows form if credentials are invalid", %{conn: conn} do
admin = insert(:administrator)
params = %{
"authenticate" => %{
username: admin.user.username,
password: "badpassword"
}
}
result = post(conn, AdminRoutes.session_path(conn, :create), params)
assert html_response(result, 200) =~ "administrator_login"
end
test "reshows form if user is not an admin", %{conn: conn} do
insert(:administrator)
user = insert(:user)
params = %{
"authenticate" => %{
username: user.username,
password: "password"
}
}
result = post(conn, AdminRoutes.session_path(conn, :create), params)
assert html_response(result, 200) =~ "administrator_login"
end
end
end

@ -0,0 +1,109 @@
defmodule BlockScoutWeb.Admin.SetupControllerTest do
use BlockScoutWeb.ConnCase
alias BlockScoutWeb.Admin.SetupController
alias Explorer.Admin
setup %{conn: conn} do
conn =
conn
|> bypass_through()
|> get("/")
{:ok, conn: conn}
end
describe "configure/2" do
test "redirects to session page if already configured", %{conn: conn} do
insert(:administrator)
result = get(conn, AdminRoutes.setup_path(conn, :configure))
assert redirected_to(result) == AdminRoutes.session_path(conn, :new)
end
end
describe "configure/2 with no params" do
test "shows the verification page", %{conn: conn} do
result = get(conn, AdminRoutes.setup_path(conn, :configure))
assert html_response(result, 200) =~ "administrator_verify"
end
end
describe "configure/2 with state param" do
test "shows verification page when state is invalid", %{conn: conn} do
result = get(conn, AdminRoutes.setup_path(conn, :configure), %{state: ""})
assert html_response(result, 200) =~ "administrator_verify"
end
test "shows registration page when state is valid", %{conn: conn} do
state = SetupController.generate_secure_token()
result = get(conn, AdminRoutes.setup_path(conn, :configure), %{state: state})
assert html_response(result, 200) =~ "administrator_registration"
end
end
describe "configure_admin/2" do
test "redirects to session page if already configured", %{conn: conn} do
insert(:administrator)
result = post(conn, AdminRoutes.setup_path(conn, :configure), %{})
assert redirected_to(result) == AdminRoutes.session_path(conn, :new)
end
end
describe "configure_admin/2 with no params" do
test "reshows the verification page", %{conn: conn} do
result = post(conn, AdminRoutes.setup_path(conn, :configure_admin), %{})
assert html_response(result, 200) =~ "administrator_verify"
end
end
describe "configure_admin/2 with verify param" do
test "redirects with valid recovery key", %{conn: conn} do
key = Admin.recovery_key()
params = %{verify: %{recovery_key: key}}
result = post(conn, AdminRoutes.setup_path(conn, :configure_admin), params)
assert redirected_to(result) =~ AdminRoutes.setup_path(conn, :configure, %{state: ""})
end
test "reshows the verification page with invalid key", %{conn: conn} do
params = %{verify: %{recovery_key: "bad_key"}}
result = post(conn, AdminRoutes.setup_path(conn, :configure_admin), params)
assert html_response(result, 200) =~ "administrator_verify"
end
end
describe "configure_admin with state and registration params" do
setup do
[state: SetupController.generate_secure_token()]
end
test "reshows the verification page when state is invalid", %{conn: conn} do
params = %{state: "invalid_state", registration: %{}}
result = post(conn, AdminRoutes.setup_path(conn, :configure_admin), params)
assert html_response(result, 200) =~ "administrator_verify"
end
test "reshows the registration page when registration is invalid", %{conn: conn, state: state} do
params = %{state: state, registration: %{}}
result = post(conn, AdminRoutes.setup_path(conn, :configure_admin), params)
response = html_response(result, 200)
assert response =~ "administrator_registration"
assert response =~ "invalid-feedback"
assert response =~ "is-invalid"
end
test "redirects to dashboard when state and registration are valid", %{conn: conn, state: state} do
params = %{
state: state,
registration: %{
username: "admin_user",
email: "admin_user@blockscout",
password: "testpassword",
password_confirmation: "testpassword"
}
}
result = post(conn, AdminRoutes.setup_path(conn, :configure_admin), params)
assert redirected_to(result) == AdminRoutes.dashboard_path(conn, :index)
end
end
end

@ -0,0 +1,27 @@
defmodule BlockScoutWeb.Plug.Admin.CheckOwnerRegisteredTest do
use BlockScoutWeb.ConnCase
alias BlockScoutWeb.Plug.Admin.CheckOwnerRegistered
alias Explorer.Admin
test "init/1" do
assert CheckOwnerRegistered.init([]) == []
end
describe "call/2" do
test "redirects if owner user isn't configured", %{conn: conn} do
assert {:error, _} = Admin.owner()
result = CheckOwnerRegistered.call(conn, [])
assert redirected_to(result) == AdminRoutes.setup_path(conn, :configure)
assert result.halted
end
test "continues if owner user is configured", %{conn: conn} do
insert(:administrator)
assert {:ok, _} = Admin.owner()
result = CheckOwnerRegistered.call(conn, [])
assert result.state == :unset
refute result.halted
end
end
end

@ -0,0 +1,42 @@
defmodule BlockScoutWeb.Plug.Admin.RequireAdminRoleTest do
use BlockScoutWeb.ConnCase
import Plug.Conn, only: [put_session: 3, assign: 3]
alias BlockScoutWeb.Router
alias BlockScoutWeb.Plug.Admin.RequireAdminRole
test "init/1" do
assert RequireAdminRole.init([]) == []
end
describe "call/2" do
setup %{conn: conn} do
conn =
conn
|> bypass_through(Router, [:browser])
|> get("/")
{:ok, conn: conn}
end
test "redirects if user in conn isn't an admin", %{conn: conn} do
result = RequireAdminRole.call(conn, [])
assert redirected_to(result) == AdminRoutes.session_path(conn, :new)
assert result.halted
end
test "continues if user in assigns is an admin", %{conn: conn} do
administrator = insert(:administrator)
result =
conn
|> put_session(:user_id, administrator.user.id)
|> assign(:user, administrator.user)
|> RequireAdminRole.call([])
refute result.halted
assert result.state == :unset
end
end
end

@ -0,0 +1,46 @@
defmodule BlockScoutWeb.Plug.FetchUserFromSessionTest do
use BlockScoutWeb.ConnCase
import Plug.Conn, only: [put_session: 3]
alias BlockScoutWeb.Plug.FetchUserFromSession
alias BlockScoutWeb.Router
alias Explorer.Accounts.User
test "init/1" do
assert FetchUserFromSession.init([]) == []
end
describe "call/2" do
setup %{conn: conn} do
conn =
conn
|> bypass_through(Router, [:browser])
|> get("/")
{:ok, conn: conn}
end
test "loads user if valid user id in session", %{conn: conn} do
user = insert(:user)
result =
conn
|> put_session(:user_id, user.id)
|> FetchUserFromSession.call([])
assert %User{} = result.assigns.user
end
test "returns conn if user id is invalid in session", %{conn: conn} do
conn = put_session(conn, :user_id, 1)
result = FetchUserFromSession.call(conn, [])
assert conn == result
end
test "returns conn if no user id is in session", %{conn: conn} do
assert FetchUserFromSession.call(conn, []) == conn
end
end
end

@ -25,6 +25,8 @@ defmodule BlockScoutWeb.ConnCase do
@endpoint BlockScoutWeb.Endpoint @endpoint BlockScoutWeb.Endpoint
import Explorer.Factory import Explorer.Factory
alias BlockScoutWeb.AdminRouter.Helpers, as: AdminRoutes
end end
end end

@ -0,0 +1 @@
priv/.recovery

@ -3,9 +3,10 @@ defmodule Explorer.Accounts do
Entrypoint for modifying user account information. Entrypoint for modifying user account information.
""" """
alias Comeonin.Bcrypt
alias Ecto.Changeset alias Ecto.Changeset
alias Explorer.Accounts.{User} alias Explorer.Accounts.{User}
alias Explorer.Accounts.User.Registration alias Explorer.Accounts.User.{Authenticate, Registration}
alias Explorer.Repo alias Explorer.Repo
@doc """ @doc """
@ -13,28 +14,31 @@ defmodule Explorer.Accounts do
""" """
@spec register_new_account(map()) :: {:ok, User.t()} | {:error, Changeset.t()} @spec register_new_account(map()) :: {:ok, User.t()} | {:error, Changeset.t()}
def register_new_account(params) do def register_new_account(params) do
registration_changeset = Registration.changeset(params) registration =
params
|> Registration.changeset()
|> Changeset.apply_action(:insert)
with {:registration_valid?, true} <- {:registration_valid?, registration_changeset.valid?}, with {:registration, {:ok, registration}} <- {:registration, registration},
{:ok, user} <- do_register_new_account(registration_changeset) do {:ok, user} <- do_register_new_account(registration) do
{:ok, user} {:ok, user}
else else
{:registration_valid?, false} -> {:registration, {:error, _} = error} ->
{:error, registration_changeset} error
{:error, %Changeset{} = user_changeset} -> {:error, %Changeset{}} = error ->
{:error, %Changeset{registration_changeset | errors: user_changeset.errors, valid?: false}} error
end end
end end
@spec do_register_new_account(Changeset.t()) :: {:ok, User.t()} | {:error, Changeset.t()} @spec do_register_new_account(Registration.t()) :: {:ok, User.t()} | {:error, Changeset.t()}
defp do_register_new_account(%Changeset{changes: changes}) do defp do_register_new_account(%Registration{} = registration) do
new_user_params = %{ new_user_params = %{
username: changes.username, username: registration.username,
password: changes.password, password: registration.password,
contacts: [ contacts: [
%{ %{
email: changes.email, email: registration.email,
primary: true primary: true
} }
] ]
@ -44,4 +48,46 @@ defmodule Explorer.Accounts do
|> User.changeset(new_user_params) |> User.changeset(new_user_params)
|> Repo.insert() |> Repo.insert()
end end
@doc """
Authenticates a user from a map of authentication params.
"""
@spec authenticate(map()) :: {:ok, User.t()} | {:error, :invalid_credentials | Changeset.t()}
def authenticate(user_params) when is_map(user_params) do
authentication =
user_params
|> Authenticate.changeset()
|> Changeset.apply_action(:insert)
with {:ok, authentication} <- authentication,
{:user, %User{} = user} <- {:user, Repo.get_by(User, username: authentication.username)},
{:password, true} <- {:password, Bcrypt.checkpw(authentication.password, user.password_hash)} do
{:ok, user}
else
{:error, %Changeset{}} = error ->
error
{:user, nil} ->
# Run dummy check to mitigate timing attacks
Bcrypt.dummy_checkpw()
{:error, :invalid_credentials}
{:password, false} ->
{:error, :invalid_credentials}
end
end
@doc """
Fetches a user by id.
"""
@spec fetch_user(integer()) :: {:ok, User.t()} | {:error, :not_found}
def fetch_user(user_id) do
case Repo.get(User, user_id) do
nil ->
{:error, :not_found}
user ->
{:ok, user}
end
end
end end

@ -0,0 +1,22 @@
defmodule Explorer.Accounts.User.Authenticate do
@moduledoc """
Represents the data required to authenticate a user.
"""
use Ecto.Schema
import Ecto.Changeset
embedded_schema do
field(:username, :string)
field(:password, :string)
end
@required_attrs ~w(password username)a
def changeset(params \\ %{}) do
%__MODULE__{}
|> cast(params, @required_attrs)
|> validate_required(@required_attrs)
end
end

@ -9,6 +9,8 @@ defmodule Explorer.Accounts.User.Registration do
alias Explorer.Accounts.User.Registration alias Explorer.Accounts.User.Registration
@type t :: %__MODULE__{}
embedded_schema do embedded_schema do
field(:username, :string) field(:username, :string)
field(:email, :string) field(:email, :string)

@ -48,10 +48,7 @@ defmodule Explorer.Accounts.UserContact do
end end
defp format_email(%Changeset{valid?: true, changes: %{email: email}} = changeset) do defp format_email(%Changeset{valid?: true, changes: %{email: email}} = changeset) do
formatted_email = formatted_email = String.trim(email)
email
|> String.trim()
|> String.downcase()
put_change(changeset, :email, formatted_email) put_change(changeset, :email, formatted_email)
end end

@ -0,0 +1,76 @@
defmodule Explorer.Admin do
@moduledoc """
Context for performing administrative tasks.
"""
import Ecto.Query
alias Ecto.Changeset
alias Explorer.{Accounts, Repo}
alias Explorer.Accounts.User
alias Explorer.Admin.{Administrator, Recovery}
@doc """
Fetches the owner of the explorer.
"""
@spec owner :: {:ok, Administrator.t()} | {:error, :not_found}
def owner do
query =
from(a in Administrator,
where: a.role == "owner",
preload: [:user]
)
case Repo.one(query) do
nil ->
{:error, :not_found}
admin ->
{:ok, admin}
end
end
@doc """
Retrieves an admin record from a user
"""
def from_user(%User{id: user_id}) do
query =
from(a in Administrator,
where: a.user_id == ^user_id
)
case Repo.one(query) do
%Administrator{} = admin ->
{:ok, admin}
nil ->
{:error, :not_found}
end
end
@doc """
Registers a new user as an administrator with the `owner` role.
"""
@spec register_owner(map()) :: {:ok, %{user: User.t(), admin: Administrator.t()}} | {:error, Changeset.t()}
def register_owner(params) do
Repo.transaction(fn ->
with {:ok, user} <- Accounts.register_new_account(params),
{:ok, admin} <- promote_user(user, "owner") do
%{admin: admin, user: user}
else
{:error, error} ->
Repo.rollback(error)
end
end)
end
defp promote_user(%User{id: user_id}, role) do
%Administrator{}
|> Administrator.changeset(%{user_id: user_id, role: role})
|> Repo.insert()
end
def recovery_key do
Recovery.key(Recovery)
end
end

@ -0,0 +1,41 @@
defmodule Explorer.Admin.Administrator do
@moduledoc """
Represents a user with administrative privileges.
"""
use Ecto.Schema
import Ecto.Changeset
alias Explorer.Accounts.User
alias Explorer.Admin.Administrator
alias Explorer.Admin.Administrator.Role
@typedoc """
* `:role` - Administrator's role determining permission level
* `:user` - The `t:User.t/0` that is an admin
* `:user_id` - User foreign key
"""
@type t :: %Administrator{
role: Role.t(),
user: User.t() | %Ecto.Association.NotLoaded{}
}
schema "administrators" do
field(:role, :string)
belongs_to(:user, User)
timestamps()
end
@required_attrs ~w(role user_id)a
@doc false
def changeset(struct, params \\ %{}) do
struct
|> cast(params, @required_attrs)
|> validate_required(@required_attrs)
|> assoc_constraint(:user)
|> unique_constraint(:role, name: :owner_role_limit)
end
end

@ -0,0 +1,67 @@
defmodule Explorer.Admin.Recovery do
@moduledoc """
Generates a recovery key to configure the application as an administrator.
"""
use GenServer
def child_spec([init_options]) do
child_spec([init_options, []])
end
def child_spec([_init_options, _gen_server_options] = start_link_arguments) do
spec = %{
id: __MODULE__,
start: {__MODULE__, :start_link, start_link_arguments},
restart: :permanent,
type: :worker
}
Supervisor.child_spec(spec, [])
end
def start_link(init_options, gen_server_options \\ []) do
GenServer.start_link(__MODULE__, init_options, gen_server_options)
end
def init(_) do
send(self(), :load_key)
{:ok, %{}}
end
# sobelow_skip ["Misc.FilePath", "Traversal"]
def handle_info(:load_key, _state) do
base_path = Application.app_dir(:explorer)
file_path = Path.join([base_path, "priv/.recovery"])
recovery_key =
case File.read(file_path) do
{:ok, <<recovery_key::binary-size(44)>>} ->
recovery_key
_ ->
recovery_key = gen_secret()
File.write(file_path, recovery_key)
recovery_key
end
{:noreply, %{key: recovery_key}}
end
def handle_call(:recovery_key, _from, %{key: key} = state) do
{:reply, key, state}
end
@spec key(GenServer.server()) :: String.t()
def key(server) do
GenServer.call(server, :recovery_key)
end
@doc false
def gen_secret do
32
|> :crypto.strong_rand_bytes()
|> Base.encode64()
end
end

@ -0,0 +1,32 @@
defmodule Explorer.Admin.Administrator.Role do
@moduledoc """
Supported roles for an administrator.
"""
@behaviour Ecto.Type
@typedoc """
Supported role atoms for an administrator.
"""
@type t :: :owner
@impl Ecto.Type
@spec cast(term()) :: {:ok, t()} | :error
def cast(t) when t in ~w(owner)a, do: {:ok, t}
def cast("owner"), do: {:ok, :owner}
def cast(_), do: :error
@impl Ecto.Type
@spec dump(term()) :: {:ok, String.t()} | :error
def dump(:owner), do: {:ok, "owner"}
def dump(_), do: :error
@impl Ecto.Type
@spec load(term) :: {:ok, t()} | :error
def load("owner"), do: {:ok, :owner}
def load(_), do: :error
@impl Ecto.Type
@spec type() :: :string
def type, do: :string
end

@ -6,6 +6,7 @@ defmodule Explorer.Application do
use Application use Application
alias Explorer.Repo.PrometheusLogger alias Explorer.Repo.PrometheusLogger
alias Explorer.Admin
@impl Application @impl Application
def start(_type, _args) do def start(_type, _args) do
@ -16,7 +17,8 @@ defmodule Explorer.Application do
Explorer.Repo, Explorer.Repo,
Supervisor.child_spec({Task.Supervisor, name: Explorer.MarketTaskSupervisor}, id: Explorer.MarketTaskSupervisor), Supervisor.child_spec({Task.Supervisor, name: Explorer.MarketTaskSupervisor}, id: Explorer.MarketTaskSupervisor),
Supervisor.child_spec({Task.Supervisor, name: Explorer.TaskSupervisor}, id: Explorer.TaskSupervisor), Supervisor.child_spec({Task.Supervisor, name: Explorer.TaskSupervisor}, id: Explorer.TaskSupervisor),
{Registry, keys: :duplicate, name: Registry.ChainEvents, id: Registry.ChainEvents} {Registry, keys: :duplicate, name: Registry.ChainEvents, id: Registry.ChainEvents},
{Admin.Recovery, [[], [name: Admin.Recovery]]}
] ]
children = base_children ++ configurable_children() children = base_children ++ configurable_children()

@ -0,0 +1,15 @@
defmodule Explorer.Repo.Migrations.CreateAdministrators do
use Ecto.Migration
def change do
create table(:administrators) do
add(:role, :string, null: false)
add(:user_id, references(:users, column: :id, on_delete: :delete_all), null: false)
timestamps()
end
create(unique_index(:administrators, :role, name: :owner_role_limit, where: "role = 'owner'"))
create(unique_index(:administrators, :user_id))
end
end

@ -0,0 +1,10 @@
defmodule Explorer.Repo.Migrations.AddCaseInsensitiveExtension do
use Ecto.Migration
def change do
execute(
"CREATE EXTENSION IF NOT EXISTS citext",
"DROP EXTENSION IF EXISTS citext"
)
end
end

@ -0,0 +1,23 @@
defmodule Explorer.Repo.Migrations.ModifyUsersUsername do
use Ecto.Migration
def up do
drop(index(:users, ["lower(username)"], unique: true, name: :unique_username))
alter table(:users) do
modify(:username, :citext)
end
create(unique_index(:users, [:username], name: :unique_username))
end
def down do
drop(unique_index(:users, [:username], name: :unique_username))
alter table(:users) do
modify(:username, :string)
end
create(index(:users, ["lower(username)"], unique: true, name: :unique_username))
end
end

@ -0,0 +1,23 @@
defmodule Explorer.Repo.Migrations.ModifyUserContactsEmail do
use Ecto.Migration
def up do
drop(unique_index(:user_contacts, [:user_id, "lower(email)"], name: :email_unique_for_user))
alter table(:user_contacts) do
modify(:email, :citext)
end
create(unique_index(:user_contacts, [:user_id, :email], name: :email_unique_for_user))
end
def down do
drop(unique_index(:user_contacts, [:user_id, :email], name: :email_unique_for_user))
alter table(:user_contacts) do
modify(:email, :string)
end
drop(unique_index(:user_contacts, [:user_id, "lower(email)"], name: :email_unique_for_user))
end
end

@ -1,6 +1,7 @@
defmodule Explorer.AccountsTest do defmodule Explorer.AccountsTest do
use Explorer.DataCase use Explorer.DataCase
alias Ecto.Changeset
alias Explorer.Accounts alias Explorer.Accounts
describe "register_new_account/1" do describe "register_new_account/1" do
@ -72,4 +73,54 @@ defmodule Explorer.AccountsTest do
assert hd(errors.password_confirmation) =~ "match" assert hd(errors.password_confirmation) =~ "match"
end end
end end
describe "authenticate/1" do
setup do
user = insert(:user)
[user: user]
end
test "returns user when credentials are valid", %{user: user} do
params = %{
username: user.username,
password: "password"
}
assert {:ok, result_user} = Accounts.authenticate(params)
assert result_user.id == user.id
end
test "returns error when params are invalid" do
assert {:error, %Changeset{}} = Accounts.authenticate(%{})
end
test "returns error when user isn't found" do
params = %{
username: "testuser",
password: "password"
}
assert {:error, :invalid_credentials} == Accounts.authenticate(params)
end
test "returns error when password doesn't match", %{user: user} do
params = %{
username: user.username,
password: "badpassword"
}
assert {:error, :invalid_credentials} == Accounts.authenticate(params)
end
end
describe "fetch_user/1" do
test "returns user when id is valid" do
user = insert(:user)
assert {:ok, _} = Accounts.fetch_user(user.id)
end
test "return error when id is invalid" do
assert {:error, :not_found} == Accounts.fetch_user(1)
end
end
end end

@ -5,7 +5,7 @@ defmodule Explorer.Accounts.UserContactTest do
describe "changeset/2" do describe "changeset/2" do
test "formats an email address" do test "formats an email address" do
expected = "test@poanetwork.com" expected = "Test@POAnetworK.com"
changeset = UserContact.changeset(%UserContact{}, %{email: " Test@POAnetworK.com "}) changeset = UserContact.changeset(%UserContact{}, %{email: " Test@POAnetworK.com "})
assert changeset.valid? assert changeset.valid?
assert changeset.changes.email == expected assert changeset.changes.email == expected

@ -0,0 +1,43 @@
defmodule Exploer.Admin.Administrator.RoleTest do
use ExUnit.Case
alias Explorer.Admin.Administrator.Role
describe "cast/1" do
test "with a valid role atom" do
assert Role.cast(:owner) == {:ok, :owner}
end
test "with a valid role string" do
assert Role.cast("owner") == {:ok, :owner}
end
test "with an invalid value" do
assert Role.cast("admin") == :error
end
end
describe "dump/1" do
test "with a valid role atom" do
assert Role.dump(:owner) == {:ok, "owner"}
end
test "with an invalid role" do
assert Role.dump(:admin) == :error
end
end
describe "load/1" do
test "with a valid role string" do
assert Role.load("owner") == {:ok, :owner}
end
test "with an invalid role value" do
assert Role.load("admin") == :error
end
end
test "type/0" do
assert Role.type() == :string
end
end

@ -0,0 +1,74 @@
defmodule Explorer.Admin.RecoveryTest do
use ExUnit.Case, async: false
alias Explorer.Admin.Recovery
describe "init/1" do
test "configures the process" do
assert Recovery.init(nil) == {:ok, %{}}
assert_received :load_key
end
end
describe "handle_info with :load_key" do
setup :configure_recovery_file
test "loads the value from the .recovery file", %{key: key} do
assert File.exists?(recovery_key_path())
assert Recovery.handle_info(:load_key, %{}) == {:noreply, %{key: key}}
end
test "creates a .recovery if no file present", %{key: key} do
delete_key()
refute File.exists?(recovery_key_path())
assert {:noreply, %{key: new_key}} = Recovery.handle_info(:load_key, %{})
refute key == new_key
assert File.exists?(recovery_key_path())
end
end
describe "handle_call with :recovery_key" do
test "loads the key value store in state" do
key = "super_secret_key"
state = %{key: key}
assert Recovery.handle_call(:recovery_key, self(), state) == {:reply, key, state}
end
end
describe "key/1" do
setup :configure_recovery_file
test "returns the key saved in a process", %{key: key} do
pid = start_supervised!({Recovery, [[], []]})
assert Recovery.key(pid) == key
stop_supervised(pid)
end
end
def configure_recovery_file(_context) do
key = write_key()
on_exit(fn ->
delete_key()
end)
[key: key]
end
defp recovery_key_path do
base_path = Application.app_dir(:explorer)
Path.join([base_path, "priv/.recovery"])
end
defp write_key do
file_path = recovery_key_path()
recovery_key = Recovery.gen_secret()
File.write(file_path, recovery_key)
recovery_key
end
defp delete_key do
File.rm(recovery_key_path())
end
end

@ -0,0 +1,57 @@
defmodule Explorer.AdminTest do
use Explorer.DataCase
alias Explorer.Admin
describe "owner/0" do
test "returns the owner if configured" do
expected_admin = insert(:administrator)
assert {:ok, admin} = Admin.owner()
assert admin.id == expected_admin.id
end
test "returns error if no owner configured" do
assert {:error, :not_found} = Admin.owner()
end
end
describe "register_owner/1" do
@valid_registration_params %{
username: "blockscoutuser",
email: "blockscoutuser@blockscout",
password: "password",
password_confirmation: "password"
}
test "registers a new owner" do
assert {:ok, result} = Admin.register_owner(@valid_registration_params)
assert result.admin.role == "owner"
assert result.user.username == @valid_registration_params.username
assert Enum.at(result.user.contacts, 0).email == @valid_registration_params.email
end
test "returns error with invalid changeset params" do
assert {:error, _changeset} = Admin.register_owner(%{})
end
test "returns error if owner already exists" do
insert(:administrator)
assert {:error, changeset} = Admin.register_owner(@valid_registration_params)
changeset_errors = changeset_errors(changeset)
assert Enum.at(changeset_errors.role, 0) =~ "taken"
end
end
describe "from_user/1" do
test "returns record if user is admin" do
admin = insert(:administrator)
assert {:ok, result} = Admin.from_user(admin.user)
assert result.id == admin.id
end
test "returns error if user is not an admin" do
user = insert(:user)
assert {:error, :not_found} == Admin.from_user(user)
end
end
end

@ -6,6 +6,9 @@ defmodule Explorer.Factory do
import Ecto.Query import Ecto.Query
import Kernel, except: [+: 2] import Kernel, except: [+: 2]
alias Comeonin.Bcrypt
alias Explorer.Accounts.{User, UserContact}
alias Explorer.Admin.Administrator
alias Explorer.Chain.Block.{Range, Reward} alias Explorer.Chain.Block.{Range, Reward}
alias Explorer.Chain.{ alias Explorer.Chain.{
@ -519,4 +522,27 @@ defmodule Explorer.Factory do
{:ok, hash} = Explorer.Chain.Hash.cast(Explorer.Chain.Hash.Address, "0x" <> hash_string) {:ok, hash} = Explorer.Chain.Hash.cast(Explorer.Chain.Hash.Address, "0x" <> hash_string)
hash hash
end end
def user_factory do
username = sequence("user", &"user#{&1}")
%User{
username: username,
password_hash: Bcrypt.hashpwsalt("password"),
contacts: [
%UserContact{
email: "#{username}@blockscout",
primary: true,
verified: true
}
]
}
end
def administrator_factory do
%Administrator{
role: "owner",
user: build(:user)
}
end
end end

Loading…
Cancel
Save