From 339d075edda35110f5aa24beb334542383e68af7 Mon Sep 17 00:00:00 2001
From: sloane <git@sloanelybutsurely.com>
Date: Sat, 22 Feb 2025 09:16:49 -0500
Subject: [PATCH] feat: add basic admin mode

---
 .envrc                                        |  1 +
 config/runtime.exs                            |  9 +++++
 .../components/layouts/admin.html.heex        | 17 --------
 lib/cms_web/components/layouts/app.html.heex  | 24 +++++++++--
 lib/cms_web/controllers/admin_auth.ex         | 40 +++++++++++++++++++
 lib/cms_web/controllers/admin_mode.ex         | 18 +++++++++
 .../controllers/admin_session_controller.ex   | 23 +++++++++++
 lib/cms_web/live/admin_live.ex                |  1 +
 lib/cms_web/live/admin_login_live.ex          | 29 ++++++++++++++
 lib/cms_web/router.ex                         | 38 ++++++++----------
 lib/mix/tasks/cms.gen.password_hash.ex        | 23 +++++++++++
 mix.exs                                       |  1 +
 mix.lock                                      |  3 ++
 test/support/conn_case.ex                     |  1 +
 14 files changed, 186 insertions(+), 42 deletions(-)
 create mode 100644 .envrc
 delete mode 100644 lib/cms_web/components/layouts/admin.html.heex
 create mode 100644 lib/cms_web/controllers/admin_auth.ex
 create mode 100644 lib/cms_web/controllers/admin_mode.ex
 create mode 100644 lib/cms_web/controllers/admin_session_controller.ex
 create mode 100644 lib/cms_web/live/admin_login_live.ex
 create mode 100644 lib/mix/tasks/cms.gen.password_hash.ex

diff --git a/.envrc b/.envrc
new file mode 100644
index 0000000..43f40df
--- /dev/null
+++ b/.envrc
@@ -0,0 +1 @@
+export PASSWORD_HASH='$argon2id$v=19$m=65536,t=3,p=4$ctxMPSfgu5i28J0bjRl2yg$D7+qs+R7caAe5lw5m7s+k9M0t75R4XBhkwG1dv6MGOQ'
diff --git a/config/runtime.exs b/config/runtime.exs
index c94d12f..827c59b 100644
--- a/config/runtime.exs
+++ b/config/runtime.exs
@@ -20,6 +20,15 @@ if System.get_env("PHX_SERVER") do
   config :cms, CMSWeb.Endpoint, server: true
 end
 
+config :cms,
+  password_hash:
+    System.get_env("PASSWORD_HASH") ||
+      raise("""
+      environment variable PASSWORD_HASH is missing.
+
+      Generate a hashed password using `mix cms.gen.password_hash`
+      """)
+
 if config_env() == :prod do
   database_url =
     System.get_env("DATABASE_URL") ||
diff --git a/lib/cms_web/components/layouts/admin.html.heex b/lib/cms_web/components/layouts/admin.html.heex
deleted file mode 100644
index ef11a28..0000000
--- a/lib/cms_web/components/layouts/admin.html.heex
+++ /dev/null
@@ -1,17 +0,0 @@
-<div class="flex flex-row py-1 px-3 mb-2 border-b border-slate-100">
-  admin mode
-</div>
-<div class="flex flex-col md:flex-row mx-auto max-w-3xl">
-  <section class="flex flex-col p-2 gap-y-1 border-slate-100 border-b md:border-b-0">
-    <.link navigate={~p"/"} class="font-bold hover:underline">sloanelybutsurely.com</.link>
-    <nav>
-      <ul>
-        <li><.link navigate={~p"/writing"} class="hover:underline">writing</.link></li>
-        <li><.link navigate={~p"/microblog"} class="hover:underline">microblog</.link></li>
-      </ul>
-    </nav>
-  </section>
-  <main class="p-2">
-    {@inner_content}
-  </main>
-</div>
diff --git a/lib/cms_web/components/layouts/app.html.heex b/lib/cms_web/components/layouts/app.html.heex
index e737f45..3c0707f 100644
--- a/lib/cms_web/components/layouts/app.html.heex
+++ b/lib/cms_web/components/layouts/app.html.heex
@@ -1,10 +1,26 @@
-<div class="flex flex-col md:flex-row mx-auto max-w-3xl">
+<div :if={@admin?} class="flex flex-row justify-between py-1 px-3 mb-2 border-b border-slate-100">
+  <section class="flex flex-row gap-x-2">
+    <div class="pr-2 border-r border-slate-100">
+      <.link navigate={~p"/admin"} class="font-bold">admin mode</.link>
+    </div>
+    <nav>
+      <ul class="flex flex-row">
+        <.link href="#" class="hover:underline">new post</.link>
+      </ul>
+    </nav>
+  </section>
+
+  <section class="flex flex-row">
+    <.link href={~p"/admin/session"} method="delete" class="hover:underline">sign out</.link>
+  </section>
+</div>
+<div class="flex flex-col md:flex-row mx-auto max-w-4xl">
   <section class="flex flex-col p-2 gap-y-1 border-slate-100 border-b md:border-b-0">
-    <.link navigate={~p"/"} class="font-bold hover:underline">sloanelybutsurely.com</.link>
+    <.link href={~p"/"} class="font-bold hover:underline">sloanelybutsurely.com</.link>
     <nav>
       <ul>
-        <li><.link navigate={~p"/writing"} class="hover:underline">writing</.link></li>
-        <li><.link navigate={~p"/microblog"} class="hover:underline">microblog</.link></li>
+        <li><.link href={~p"/writing"} class="hover:underline">writing</.link></li>
+        <li><.link href={~p"/microblog"} class="hover:underline">microblog</.link></li>
       </ul>
     </nav>
   </section>
diff --git a/lib/cms_web/controllers/admin_auth.ex b/lib/cms_web/controllers/admin_auth.ex
new file mode 100644
index 0000000..3f8e346
--- /dev/null
+++ b/lib/cms_web/controllers/admin_auth.ex
@@ -0,0 +1,40 @@
+defmodule CMSWeb.AdminAuth do
+  @moduledoc false
+  use CMSWeb, :verified_routes
+
+  import Phoenix.Controller
+  import Plug.Conn
+
+  def log_in_admin(conn) do
+    conn
+    |> renew_session()
+    |> put_session(:admin?, true)
+    |> redirect(to: ~p"/")
+  end
+
+  def log_out_admin(conn) do
+    if live_socket_id = get_session(conn, :live_socket_id) do
+      CMSWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
+    end
+
+    conn
+    |> renew_session()
+    |> redirect(to: ~p"/")
+  end
+
+  def correct_password?(password) do
+    password_hash = Application.fetch_env!(:cms, :password_hash)
+
+    Argon2.verify_pass(password, password_hash)
+  end
+
+  ## private
+
+  defp renew_session(conn) do
+    delete_csrf_token()
+
+    conn
+    |> configure_session(renew: true)
+    |> clear_session()
+  end
+end
diff --git a/lib/cms_web/controllers/admin_mode.ex b/lib/cms_web/controllers/admin_mode.ex
new file mode 100644
index 0000000..2c67d43
--- /dev/null
+++ b/lib/cms_web/controllers/admin_mode.ex
@@ -0,0 +1,18 @@
+defmodule CMSWeb.AdminMode do
+  @moduledoc false
+  use CMSWeb, :live_view
+
+  def admin_mode(%Plug.Conn{} = conn, _opts) do
+    Plug.Conn.assign(conn, :admin?, admin?(conn))
+  end
+
+  def on_mount(:default, _params, session, socket) do
+    {:cont, assign(socket, :admin?, admin?(session))}
+  end
+
+  defp admin?(%Plug.Conn{} = conn) do
+    Plug.Conn.get_session(conn, :admin?, false) == true
+  end
+
+  defp admin?(%{} = session), do: Map.get(session, :admin?, false) == true
+end
diff --git a/lib/cms_web/controllers/admin_session_controller.ex b/lib/cms_web/controllers/admin_session_controller.ex
new file mode 100644
index 0000000..139d944
--- /dev/null
+++ b/lib/cms_web/controllers/admin_session_controller.ex
@@ -0,0 +1,23 @@
+defmodule CMSWeb.AdminSessionController do
+  use CMSWeb, :controller
+
+  alias CMSWeb.AdminAuth
+
+  def create(conn, %{"password" => password}) do
+    if AdminAuth.correct_password?(password) do
+      AdminAuth.log_in_admin(conn)
+    else
+      redirect(conn, to: ~p"/admin/sign-in")
+    end
+  end
+
+  def create(conn, _params) do
+    redirect(conn, to: ~p"/admin/sign-in")
+  end
+
+  def destroy(conn, _params) do
+    conn
+    |> AdminAuth.log_out_admin()
+    |> redirect(to: ~p"/")
+  end
+end
diff --git a/lib/cms_web/live/admin_live.ex b/lib/cms_web/live/admin_live.ex
index 3bf9f74..8ae7075 100644
--- a/lib/cms_web/live/admin_live.ex
+++ b/lib/cms_web/live/admin_live.ex
@@ -1,4 +1,5 @@
 defmodule CMSWeb.AdminLive do
+  @moduledoc false
   use CMSWeb, :live_view
 
   @impl true
diff --git a/lib/cms_web/live/admin_login_live.ex b/lib/cms_web/live/admin_login_live.ex
new file mode 100644
index 0000000..2d97891
--- /dev/null
+++ b/lib/cms_web/live/admin_login_live.ex
@@ -0,0 +1,29 @@
+defmodule CMSWeb.AdminLoginLive do
+  @moduledoc false
+  use CMSWeb, :live_view
+
+  @impl true
+  def mount(_params, _session, socket) do
+    socket = assign(socket, :form, to_form(%{"password" => ""}))
+
+    {:ok, socket}
+  end
+
+  @impl true
+  def render(assigns) do
+    ~H"""
+    <h1 class="font-bold text-lg mb-4">Sign in</h1>
+
+    <.form for={@form} action={~p"/admin/session"}>
+      <input
+        type="password"
+        placeholder="password"
+        id={@form[:password].id}
+        name={@form[:password].name}
+        value={@form[:password].value}
+        required
+      />
+    </.form>
+    """
+  end
+end
diff --git a/lib/cms_web/router.ex b/lib/cms_web/router.ex
index 3878b7e..e5c97de 100644
--- a/lib/cms_web/router.ex
+++ b/lib/cms_web/router.ex
@@ -1,7 +1,10 @@
 defmodule CMSWeb.Router do
-  alias CMSWeb.Layouts
   use CMSWeb, :router
 
+  import CMSWeb.AdminMode
+
+  alias CMSWeb.AdminMode
+
   pipeline :browser do
     plug :accepts, ["html"]
     plug :fetch_session
@@ -9,33 +12,26 @@ defmodule CMSWeb.Router do
     plug :put_root_layout, html: {CMSWeb.Layouts, :root}
     plug :protect_from_forgery
     plug :put_secure_browser_headers
+    plug :admin_mode
   end
 
-  pipeline :api do
-    plug :accepts, ["json"]
-  end
-
-  scope "/", CMSWeb do
-    pipe_through :browser
-
-    get "/", PageController, :home
-    get "/writing", PageController, :writing
-    get "/microblog", PageController, :microblog
-  end
-
-  live_session :admin, layout: {Layouts, :admin} do
-    scope "/admin", CMSWeb do
+  live_session :default, on_mount: AdminMode do
+    scope "/", CMSWeb do
       pipe_through :browser
 
-      live "/", AdminLive
+      get "/", PageController, :home
+      get "/writing", PageController, :writing
+      get "/microblog", PageController, :microblog
+
+      live "/admin", AdminLive
+
+      live "/admin/sign-in", AdminLoginLive
+
+      post "/admin/session", AdminSessionController, :create
+      delete "/admin/session", AdminSessionController, :destroy
     end
   end
 
-  # Other scopes may use custom stacks.
-  # scope "/api", CMSWeb do
-  #   pipe_through :api
-  # end
-
   # Enable LiveDashboard in development
   if Application.compile_env(:cms, :dev_routes) do
     # If you want to use the LiveDashboard in production, you should put
diff --git a/lib/mix/tasks/cms.gen.password_hash.ex b/lib/mix/tasks/cms.gen.password_hash.ex
new file mode 100644
index 0000000..8dfab3c
--- /dev/null
+++ b/lib/mix/tasks/cms.gen.password_hash.ex
@@ -0,0 +1,23 @@
+defmodule Mix.Tasks.Cms.Gen.PasswordHash do
+  @shortdoc @moduledoc
+  @moduledoc """
+  Hashes a password for the admin account
+  """
+  use Mix.Task
+
+  @impl Mix.Task
+  def run(_args) do
+    password = Mix.shell().prompt("Password: ")
+    password = String.trim_trailing(password)
+
+    password_confirmation = Mix.shell().prompt("Confirm password: ")
+    password_confirmation = String.trim_trailing(password_confirmation)
+
+    if password == password_confirmation do
+      hashed = Argon2.hash_pwd_salt(password)
+      Mix.shell().info(hashed)
+    else
+      Mix.shell().error("Passwords do not match")
+    end
+  end
+end
diff --git a/mix.exs b/mix.exs
index 4966a8d..5ea2e58 100644
--- a/mix.exs
+++ b/mix.exs
@@ -50,6 +50,7 @@ defmodule CMS.MixProject do
       {:jason, "~> 1.2"},
       {:dns_cluster, "~> 0.1.1"},
       {:bandit, "~> 1.5"},
+      {:argon2_elixir, "~> 4.1"},
 
       # dev/test only
       {:styler, "~> 1.4", only: [:dev, :test], runtime: false}
diff --git a/mix.lock b/mix.lock
index 349bd08..7316b7e 100644
--- a/mix.lock
+++ b/mix.lock
@@ -1,11 +1,14 @@
 %{
+  "argon2_elixir": {:hex, :argon2_elixir, "4.1.2", "1160a3ccd59b951175525882240651f5ed3303b75c616204713f8b31c76b37bd", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "9222341e1b0d9aa5ca7e26a1c77bd1bd92d2314c92b57ca3e2c7ed847223b51d"},
   "bandit": {:hex, :bandit, "1.6.7", "42f30e37a1c89a2a12943c5dca76f731a2313e8a2e21c1a95dc8241893e922d1", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "551ba8ff5e4fc908cbeb8c9f0697775fb6813a96d9de5f7fe02e34e76fd7d184"},
   "castore": {:hex, :castore, "1.0.11", "4bbd584741601eb658007339ea730b082cc61f3554cf2e8f39bf693a11b49073", [:mix], [], "hexpm", "e03990b4db988df56262852f20de0f659871c35154691427a5047f4967a16a62"},
+  "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
   "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"},
   "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
   "dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"},
   "ecto": {:hex, :ecto, "3.12.5", "4a312960ce612e17337e7cefcf9be45b95a3be6b36b6f94dfb3d8c361d631866", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6eb18e80bef8bb57e17f5a7f068a1719fbda384d40fc37acb8eb8aeca493b6ea"},
   "ecto_sql": {:hex, :ecto_sql, "3.12.1", "c0d0d60e85d9ff4631f12bafa454bc392ce8b9ec83531a412c12a0d415a3a4d0", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aff5b958a899762c5f09028c847569f7dfb9cc9d63bdb8133bff8a5546de6bf5"},
+  "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
   "esbuild": {:hex, :esbuild, "0.9.0", "f043eeaca4932ca8e16e5429aebd90f7766f31ac160a25cbd9befe84f2bc068f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "b415027f71d5ab57ef2be844b2a10d0c1b5a492d431727f43937adce22ba45ae"},
   "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"},
   "floki": {:hex, :floki, "0.37.0", "b83e0280bbc6372f2a403b2848013650b16640cd2470aea6701f0632223d719e", [:mix], [], "hexpm", "516a0c15a69f78c47dc8e0b9b3724b29608aa6619379f91b1ffa47109b5d0dd3"},
diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex
index d655c24..82631ca 100644
--- a/test/support/conn_case.ex
+++ b/test/support/conn_case.ex
@@ -20,6 +20,7 @@ defmodule CMSWeb.ConnCase do
   using do
     quote do
       use CMSWeb, :verified_routes
+
       import CMSWeb.ConnCase
       import Phoenix.ConnTest
       import Plug.Conn