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
parent
d04b2b3fb4
commit
c60bd06e5e
@ -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 |
@ -0,0 +1 @@ |
||||
<div data-test="administrator_dashboard"></div> |
@ -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 < 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 |
@ -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 |
@ -0,0 +1 @@ |
||||
priv/.recovery |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
Loading…
Reference in new issue