From 9779520d0c6dff10d7963119bd779d91d33edf75 Mon Sep 17 00:00:00 2001
From: sloane <git@sloanelybutsurely.com>
Date: Mon, 24 Mar 2025 05:30:37 -0400
Subject: [PATCH] mix phx.gen.auth

---
 .formatter.exs                                |   2 +-
 config/test.exs                               |   3 +
 lib/core.ex                                   |   2 +-
 lib/core/accounts.ex                          | 199 +++++++++++++
 lib/core/accounts/user.ex                     | 140 +++++++++
 lib/core/accounts/user_token.ex               |  68 +++++
 lib/schema.ex                                 |   2 +-
 lib/schema/user.ex                            |  15 +
 lib/schema/user_token.ex                      |  15 +
 lib/web/controllers/page_controller.ex        |   7 +
 lib/web/controllers/page_html.ex              |   5 +
 lib/web/controllers/page_html/home.html.heex  |   1 +
 .../controllers/user_session_controller.ex    |  41 +++
 lib/web/live/user_login_live.ex               |  47 +++
 lib/web/live/user_registration_live.ex        |  84 ++++++
 lib/web/live/user_settings_live.ex            | 156 ++++++++++
 lib/web/router.ex                             |  34 +++
 lib/web/user_auth.ex                          | 230 +++++++++++++++
 mix.exs                                       |   1 +
 mix.lock                                      |   2 +-
 ...0250324093345_create_users_auth_tables.exs |  27 ++
 test/support/{ => test}/conn_case.ex          |  26 ++
 test/support/{ => test}/data_case.ex          |   0
 test/support/test/fixtures/accounts.ex        |  16 +
 test/web/controllers/page_controller_test.exs |   8 -
 test/web/controllers/user_auth_test.exs       | 273 ++++++++++++++++++
 26 files changed, 1392 insertions(+), 12 deletions(-)
 create mode 100644 lib/core/accounts.ex
 create mode 100644 lib/core/accounts/user.ex
 create mode 100644 lib/core/accounts/user_token.ex
 create mode 100644 lib/schema/user.ex
 create mode 100644 lib/schema/user_token.ex
 create mode 100644 lib/web/controllers/page_controller.ex
 create mode 100644 lib/web/controllers/page_html.ex
 create mode 100644 lib/web/controllers/page_html/home.html.heex
 create mode 100644 lib/web/controllers/user_session_controller.ex
 create mode 100644 lib/web/live/user_login_live.ex
 create mode 100644 lib/web/live/user_registration_live.ex
 create mode 100644 lib/web/live/user_settings_live.ex
 create mode 100644 lib/web/user_auth.ex
 create mode 100644 priv/repo/migrations/20250324093345_create_users_auth_tables.exs
 rename test/support/{ => test}/conn_case.ex (61%)
 rename test/support/{ => test}/data_case.ex (100%)
 create mode 100644 test/support/test/fixtures/accounts.ex
 delete mode 100644 test/web/controllers/page_controller_test.exs
 create mode 100644 test/web/controllers/user_auth_test.exs

diff --git a/.formatter.exs b/.formatter.exs
index 7675824..18b26c5 100644
--- a/.formatter.exs
+++ b/.formatter.exs
@@ -1,5 +1,5 @@
 [
-  import_deps: [:ecto, :ecto_sql, :phoenix, :typed_struct],
+  import_deps: [:ecto, :ecto_sql, :phoenix],
   subdirectories: ["priv/*/migrations"],
   plugins: [Styler, Phoenix.LiveView.HTMLFormatter],
   inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"]
diff --git a/config/test.exs b/config/test.exs
index e447f24..239dcd6 100644
--- a/config/test.exs
+++ b/config/test.exs
@@ -1,5 +1,8 @@
 import Config
 
+# Only in tests, remove the complexity from the password hashing algorithm
+config :argon2_elixir, t_cost: 1, m_cost: 8
+
 config :logger, level: :warning
 
 config :phoenix, :plug_init_mode, :runtime
diff --git a/lib/core.ex b/lib/core.ex
index f49c395..8834e7a 100644
--- a/lib/core.ex
+++ b/lib/core.ex
@@ -1,4 +1,4 @@
 defmodule Core do
   @moduledoc false
-  use Boundary, deps: [Schema], exports: []
+  use Boundary, deps: [Schema], exports: [Accounts]
 end
diff --git a/lib/core/accounts.ex b/lib/core/accounts.ex
new file mode 100644
index 0000000..d783543
--- /dev/null
+++ b/lib/core/accounts.ex
@@ -0,0 +1,199 @@
+defmodule Core.Accounts do
+  @moduledoc """
+  The Accounts context.
+  """
+
+  alias Core.Accounts.User
+  alias Core.Accounts.UserToken
+  alias Core.Repo
+
+  ## Database getters
+
+  @doc """
+  Gets a user by username and password.
+
+  ## Examples
+
+      iex> get_user_by_username_and_password("foo@example.com", "correct_password")
+      %Schema.User{}
+
+      iex> get_user_by_username_and_password("foo@example.com", "invalid_password")
+      nil
+
+  """
+  def get_user_by_username_and_password(username, password) when is_binary(username) and is_binary(password) do
+    user = Repo.get_by(Schema.User, username: username)
+    if User.valid_password?(user, password), do: user
+  end
+
+  @doc """
+  Gets a single user.
+
+  Raises `Ecto.NoResultsError` if the User does not exist.
+
+  ## Examples
+
+      iex> get_user!(123)
+      %Schema.User{}
+
+      iex> get_user!(456)
+      ** (Ecto.NoResultsError)
+
+  """
+  def get_user!(id), do: Repo.get!(Schema.User, id)
+
+  ## User registration
+
+  @doc """
+  Registers a user.
+
+  ## Examples
+
+      iex> register_user(%{field: value})
+      {:ok, %Schema.User{}}
+
+      iex> register_user(%{field: bad_value})
+      {:error, %Ecto.Changeset{}}
+
+  """
+  def register_user(attrs) do
+    %Schema.User{}
+    |> User.registration_changeset(attrs)
+    |> Repo.insert()
+  end
+
+  @doc """
+  Returns an `%Ecto.Changeset{}` for tracking user changes.
+
+  ## Examples
+
+      iex> change_user_registration(user)
+      %Ecto.Changeset{data: %Schema.User{}}
+
+  """
+  def change_user_registration(%Schema.User{} = user, attrs \\ %{}) do
+    User.registration_changeset(user, attrs, hash_password: false, validate_username: false)
+  end
+
+  ## Settings
+
+  @doc """
+  Returns an `%Ecto.Changeset{}` for changing the user username.
+
+  ## Examples
+
+      iex> change_user_username(user)
+      %Ecto.Changeset{data: %Schema.User{}}
+
+  """
+  def change_user_username(user, attrs \\ %{}) do
+    User.username_changeset(user, attrs, validate_username: false)
+  end
+
+  @doc """
+  Updates the user username.
+
+  If the token matches, the user username is updated and the token is deleted.
+  The confirmed_at date is also updated to the current time.
+  """
+  def update_user_username(user, password, attrs) do
+    user
+    |> User.username_changeset(attrs)
+    |> User.validate_current_password(password)
+    |> Repo.update()
+  end
+
+  @doc """
+  Returns an `%Ecto.Changeset{}` for changing the user password.
+
+  ## Examples
+
+      iex> change_user_password(user)
+      %Ecto.Changeset{data: %Schema.User{}}
+
+  """
+  def change_user_password(user, attrs \\ %{}) do
+    User.password_changeset(user, attrs, hash_password: false)
+  end
+
+  @doc """
+  Updates the user password.
+
+  ## Examples
+
+      iex> update_user_password(user, "valid password", %{password: ...})
+      {:ok, %Schema.User{}}
+
+      iex> update_user_password(user, "invalid password", %{password: ...})
+      {:error, %Ecto.Changeset{}}
+
+  """
+  def update_user_password(user, password, attrs) do
+    changeset =
+      user
+      |> User.password_changeset(attrs)
+      |> User.validate_current_password(password)
+
+    Ecto.Multi.new()
+    |> Ecto.Multi.update(:user, changeset)
+    |> Ecto.Multi.delete_all(:tokens, UserToken.Query.for_user(user))
+    |> Repo.transaction()
+    |> case do
+      {:ok, %{user: user}} -> {:ok, user}
+      {:error, :user, changeset, _} -> {:error, changeset}
+    end
+  end
+
+  ## Session
+
+  @doc """
+  Generates a session token.
+  """
+  def generate_user_session_token(user) do
+    {token, user_token} = UserToken.build_session_token(user)
+    Repo.insert!(user_token)
+    token
+  end
+
+  @doc """
+  Gets the user with the given signed token.
+  """
+  def get_user_by_session_token(token) do
+    token
+    |> UserToken.Query.valid_session_token()
+    |> Repo.one()
+  end
+
+  @doc """
+  Deletes the signed token with the given context.
+  """
+  def delete_user_session_token(token) do
+    Repo.delete_all(UserToken.Query.where_token_and_context(token, "session"))
+    :ok
+  end
+
+  ## Reset password
+
+  @doc """
+  Resets the user password.
+
+  ## Examples
+
+      iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"})
+      {:ok, %Schema.User{}}
+
+      iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"})
+      {:error, %Ecto.Changeset{}}
+
+  """
+  def reset_user_password(user, attrs) do
+    Ecto.Multi.new()
+    |> Ecto.Multi.update(:user, User.password_changeset(user, attrs))
+    |> Ecto.Multi.delete_all(:tokens, UserToken.Query.for_user(user))
+    |> Repo.transaction()
+    |> case do
+      {:ok, %{user: user}} -> {:ok, user}
+      {:error, :user, changeset, _} -> {:error, changeset}
+    end
+  end
+end
diff --git a/lib/core/accounts/user.ex b/lib/core/accounts/user.ex
new file mode 100644
index 0000000..ace0216
--- /dev/null
+++ b/lib/core/accounts/user.ex
@@ -0,0 +1,140 @@
+defmodule Core.Accounts.User do
+  @moduledoc false
+  import Ecto.Changeset
+
+  @doc """
+  A user changeset for registration.
+
+  It is important to validate the length of both username and password.
+  Otherwise databases may truncate the username without warnings, which
+  could lead to unpredictable or insecure behaviour. Long passwords may
+  also be very expensive to hash for certain algorithms.
+
+  ## Options
+
+    * `:hash_password` - Hashes the password so it can be stored securely
+      in the database and ensures the password field is cleared to prevent
+      leaks in the logs. If password hashing is not needed and clearing the
+      password field is not desired (like when using this changeset for
+      validations on a LiveView form), this option can be set to `false`.
+      Defaults to `true`.
+
+    * `:validate_username` - Validates the uniqueness of the username, in case
+      you don't want to validate the uniqueness of the username (like when
+      using this changeset for validations on a LiveView form before
+      submitting the form), this option can be set to `false`.
+      Defaults to `true`.
+  """
+  def registration_changeset(user, attrs, opts \\ []) do
+    user
+    |> cast(attrs, [:username, :password])
+    |> validate_username(opts)
+    |> validate_password(opts)
+  end
+
+  defp validate_username(changeset, opts) do
+    changeset
+    |> validate_required([:username])
+    |> validate_length(:username, max: 160)
+    |> maybe_validate_unique_username(opts)
+  end
+
+  defp validate_password(changeset, opts) do
+    changeset
+    |> validate_required([:password])
+    |> validate_length(:password, min: 12, max: 72)
+    # Examples of additional password validation:
+    # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character")
+    # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character")
+    # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character")
+    |> maybe_hash_password(opts)
+  end
+
+  defp maybe_hash_password(changeset, opts) do
+    hash_password? = Keyword.get(opts, :hash_password, true)
+    password = get_change(changeset, :password)
+
+    if hash_password? && password && changeset.valid? do
+      changeset
+      # Hashing could be done with `Ecto.Changeset.prepare_changes/2`, but that
+      # would keep the database transaction open longer and hurt performance.
+      |> put_change(:hashed_password, Argon2.hash_pwd_salt(password))
+      |> delete_change(:password)
+    else
+      changeset
+    end
+  end
+
+  defp maybe_validate_unique_username(changeset, opts) do
+    if Keyword.get(opts, :validate_username, true) do
+      changeset
+      |> unsafe_validate_unique(:username, Core.Repo)
+      |> unique_constraint(:username)
+    else
+      changeset
+    end
+  end
+
+  @doc """
+  A user changeset for changing the username.
+
+  It requires the username to change otherwise an error is added.
+  """
+  def username_changeset(user, attrs, opts \\ []) do
+    user
+    |> cast(attrs, [:username])
+    |> validate_username(opts)
+    |> case do
+      %{changes: %{username: _}} = changeset -> changeset
+      %{} = changeset -> add_error(changeset, :username, "did not change")
+    end
+  end
+
+  @doc """
+  A user changeset for changing the password.
+
+  ## Options
+
+    * `:hash_password` - Hashes the password so it can be stored securely
+      in the database and ensures the password field is cleared to prevent
+      leaks in the logs. If password hashing is not needed and clearing the
+      password field is not desired (like when using this changeset for
+      validations on a LiveView form), this option can be set to `false`.
+      Defaults to `true`.
+  """
+  def password_changeset(user, attrs, opts \\ []) do
+    user
+    |> cast(attrs, [:password])
+    |> validate_confirmation(:password, message: "does not match password")
+    |> validate_password(opts)
+  end
+
+  @doc """
+  Verifies the password.
+
+  If there is no user or the user doesn't have a password, we call
+  `Argon2.no_user_verify/0` to avoid timing attacks.
+  """
+  def valid_password?(%Schema.User{hashed_password: hashed_password}, password)
+      when is_binary(hashed_password) and byte_size(password) > 0 do
+    Argon2.verify_pass(password, hashed_password)
+  end
+
+  def valid_password?(_, _) do
+    Argon2.no_user_verify()
+    false
+  end
+
+  @doc """
+  Validates the current password otherwise adds an error to the changeset.
+  """
+  def validate_current_password(changeset, password) do
+    changeset = cast(changeset, %{current_password: password}, [:current_password])
+
+    if valid_password?(changeset.data, password) do
+      changeset
+    else
+      add_error(changeset, :current_password, "is not valid")
+    end
+  end
+end
diff --git a/lib/core/accounts/user_token.ex b/lib/core/accounts/user_token.ex
new file mode 100644
index 0000000..5ff3267
--- /dev/null
+++ b/lib/core/accounts/user_token.ex
@@ -0,0 +1,68 @@
+defmodule Core.Accounts.UserToken do
+  @moduledoc false
+
+  @rand_size 32
+
+  @doc """
+  Generates a token that will be stored in a signed place,
+  such as session or cookie. As they are signed, those
+  tokens do not need to be hashed.
+
+  The reason why we store session tokens in the database, even
+  though Phoenix already provides a session cookie, is because
+  Phoenix' default session cookies are not persisted, they are
+  simply signed and potentially encrypted. This means they are
+  valid indefinitely, unless you change the signing/encryption
+  salt.
+
+  Therefore, storing them allows individual user
+  sessions to be expired. The token system can also be extended
+  to store additional data, such as the device used for logging in.
+  You could then use this information to display all valid sessions
+  and devices in the UI and allow users to explicitly expire any
+  session they deem invalid.
+  """
+  def build_session_token(user) do
+    token = :crypto.strong_rand_bytes(@rand_size)
+    {token, %Schema.UserToken{token: token, context: "session", user_id: user.id}}
+  end
+
+  defmodule Query do
+    @moduledoc false
+    import Ecto.Query
+
+    @session_validity_in_days 60
+
+    def base do
+      from Schema.UserToken, as: :user_tokens
+    end
+
+    def join_users(query \\ base()) do
+      join(query, :inner, [user_tokens: ut], u in assoc(ut, :user), as: :users)
+    end
+
+    @doc """
+    Checks if the token is valid and returns its underlying lookup query.
+
+    The query returns the user found by the token, if any.
+
+    The token is valid if it matches the value in the database and it has
+    not expired (after @session_validity_in_days).
+    """
+    def valid_session_token(query \\ base(), token) do
+      query
+      |> where_token_and_context(token, "session")
+      |> join_users()
+      |> where([user_tokens: ut], ut.inserted_at > ago(@session_validity_in_days, "day"))
+      |> select([users: user], user)
+    end
+
+    def where_token_and_context(query \\ base(), token, context) do
+      where(query, [user_tokens: ut], ut.token == ^token and ut.context == ^context)
+    end
+
+    def for_user(query \\ base(), user) do
+      where(query, [user_tokens: t], t.user_id == ^user.id)
+    end
+  end
+end
diff --git a/lib/schema.ex b/lib/schema.ex
index b55eea6..96e4ef7 100644
--- a/lib/schema.ex
+++ b/lib/schema.ex
@@ -1,4 +1,4 @@
 defmodule Schema do
   @moduledoc false
-  use Boundary, deps: [], exports: []
+  use Boundary, deps: [], exports: [User, UserToken]
 end
diff --git a/lib/schema/user.ex b/lib/schema/user.ex
new file mode 100644
index 0000000..9c1b4b3
--- /dev/null
+++ b/lib/schema/user.ex
@@ -0,0 +1,15 @@
+defmodule Schema.User do
+  @moduledoc false
+  use Ecto.Schema
+
+  @primary_key {:id, :binary_id, autogenerate: true}
+  @foreign_key_type :binary_id
+  schema "users" do
+    field :username, :string
+    field :password, :string, virtual: true, redact: true
+    field :hashed_password, :string, redact: true
+    field :current_password, :string, virtual: true, redact: true
+
+    timestamps(type: :utc_datetime_usec)
+  end
+end
diff --git a/lib/schema/user_token.ex b/lib/schema/user_token.ex
new file mode 100644
index 0000000..f53d30f
--- /dev/null
+++ b/lib/schema/user_token.ex
@@ -0,0 +1,15 @@
+defmodule Schema.UserToken do
+  @moduledoc false
+  use Ecto.Schema
+
+  @primary_key {:id, :binary_id, autogenerate: true}
+  @foreign_key_type :binary_id
+  schema "users_tokens" do
+    field :token, :binary
+    field :context, :string
+    field :sent_to, :string
+    belongs_to :user, Schema.User
+
+    timestamps(type: :utc_datetime_usec, updated_at: false)
+  end
+end
diff --git a/lib/web/controllers/page_controller.ex b/lib/web/controllers/page_controller.ex
new file mode 100644
index 0000000..f6a7755
--- /dev/null
+++ b/lib/web/controllers/page_controller.ex
@@ -0,0 +1,7 @@
+defmodule Web.PageController do
+  use Web, :controller
+
+  def home(conn, _params) do
+    render(conn, :home)
+  end
+end
diff --git a/lib/web/controllers/page_html.ex b/lib/web/controllers/page_html.ex
new file mode 100644
index 0000000..608e6bf
--- /dev/null
+++ b/lib/web/controllers/page_html.ex
@@ -0,0 +1,5 @@
+defmodule Web.PageHTML do
+  use Web, :html
+
+  embed_templates "page_html/*"
+end
diff --git a/lib/web/controllers/page_html/home.html.heex b/lib/web/controllers/page_html/home.html.heex
new file mode 100644
index 0000000..f95bef3
--- /dev/null
+++ b/lib/web/controllers/page_html/home.html.heex
@@ -0,0 +1 @@
+<h1>Home</h1>
diff --git a/lib/web/controllers/user_session_controller.ex b/lib/web/controllers/user_session_controller.ex
new file mode 100644
index 0000000..47e3b10
--- /dev/null
+++ b/lib/web/controllers/user_session_controller.ex
@@ -0,0 +1,41 @@
+defmodule Web.UserSessionController do
+  use Web, :controller
+
+  alias Web.UserAuth
+
+  def create(conn, %{"_action" => "registered"} = params) do
+    create(conn, params, "Account created successfully!")
+  end
+
+  def create(conn, %{"_action" => "password_updated"} = params) do
+    conn
+    |> put_session(:user_return_to, ~p"/admin/users/settings")
+    |> create(params, "Password updated successfully!")
+  end
+
+  def create(conn, params) do
+    create(conn, params, "Welcome back!")
+  end
+
+  defp create(conn, %{"user" => user_params}, info) do
+    %{"username" => username, "password" => password} = user_params
+
+    if user = Core.Accounts.get_user_by_username_and_password(username, password) do
+      conn
+      |> put_flash(:info, info)
+      |> UserAuth.log_in_user(user, user_params)
+    else
+      # In order to prevent user enumeration attacks, don't disclose whether the username is registered.
+      conn
+      |> put_flash(:error, "Invalid username or password")
+      |> put_flash(:username, String.slice(username, 0, 160))
+      |> redirect(to: ~p"/admin/users/log_in")
+    end
+  end
+
+  def delete(conn, _params) do
+    conn
+    |> put_flash(:info, "Logged out successfully.")
+    |> UserAuth.log_out_user()
+  end
+end
diff --git a/lib/web/live/user_login_live.ex b/lib/web/live/user_login_live.ex
new file mode 100644
index 0000000..3dcf3ac
--- /dev/null
+++ b/lib/web/live/user_login_live.ex
@@ -0,0 +1,47 @@
+defmodule Web.UserLoginLive do
+  @moduledoc false
+  use Web, :live_view
+
+  def render(assigns) do
+    # ~H"""
+    # <div class="mx-auto max-w-sm">
+    #   <.header class="text-center">
+    #     Log in to account
+    #     <:subtitle>
+    #       Don't have an account?
+    #       <.link navigate={~p/admin/users/register"} class="font-semibold text-brand hover:underline">
+    #         Sign up
+    #       </.link>
+    #       for an account now.
+    #     </:subtitle>
+    #   </.header>
+
+    #   <.simple_form for={@form} id="login_form" action={~p/admin/users/log_in"} phx-update="ignore">
+    #     <.input field={@form[:email]} type="email" label="Email" required />
+    #     <.input field={@form[:password]} type="password" label="Password" required />
+
+    #     <:actions>
+    #       <.input field={@form[:remember_me]} type="checkbox" label="Keep me logged in" />
+    #       <.link href={~p/admin/users/reset_password"} class="text-sm font-semibold">
+    #         Forgot your password?
+    #       </.link>
+    #     </:actions>
+    #     <:actions>
+    #       <.button phx-disable-with="Logging in..." class="w-full">
+    #         Log in <span aria-hidden="true">→</span>
+    #       </.button>
+    #     </:actions>
+    #   </.simple_form>
+    # </div>
+    # """
+    ~H"""
+    <pre>UserLoginLive</pre>
+    """
+  end
+
+  def mount(_params, _session, socket) do
+    email = Phoenix.Flash.get(socket.assigns.flash, :email)
+    form = to_form(%{"email" => email}, as: "user")
+    {:ok, assign(socket, form: form), temporary_assigns: [form: form]}
+  end
+end
diff --git a/lib/web/live/user_registration_live.ex b/lib/web/live/user_registration_live.ex
new file mode 100644
index 0000000..880aaa1
--- /dev/null
+++ b/lib/web/live/user_registration_live.ex
@@ -0,0 +1,84 @@
+defmodule Web.UserRegistrationLive do
+  @moduledoc false
+  use Web, :live_view
+
+  alias Core.Accounts
+
+  def render(assigns) do
+    # ~H"""
+    # <div class="mx-auto max-w-sm">
+    #   <.header class="text-center">
+    #     Register for an account
+    #     <:subtitle>
+    #       Already registered?
+    #       <.link navigate={~p/admin/users/log_in"} class="font-semibold text-brand hover:underline">
+    #         Log in
+    #       </.link>
+    #       to your account now.
+    #     </:subtitle>
+    #   </.header>
+
+    #   <.simple_form
+    #     for={@form}
+    #     id="registration_form"
+    #     phx-submit="save"
+    #     phx-change="validate"
+    #     phx-trigger-action={@trigger_submit}
+    #     action={~p/admin/users/log_in?_action=registered"}
+    #     method="post"
+    #   >
+    #     <.error :if={@check_errors}>
+    #       Oops, something went wrong! Please check the errors below.
+    #     </.error>
+
+    #     <.input field={@form[:email]} type="email" label="Email" required />
+    #     <.input field={@form[:password]} type="password" label="Password" required />
+
+    #     <:actions>
+    #       <.button phx-disable-with="Creating account..." class="w-full">Create an account</.button>
+    #     </:actions>
+    #   </.simple_form>
+    # </div>
+    # """
+    ~H"""
+    <pre>UserRegistrationLive</pre>
+    """
+  end
+
+  def mount(_params, _session, socket) do
+    changeset = Accounts.change_user_registration(%Schema.User{})
+
+    socket =
+      socket
+      |> assign(trigger_submit: false, check_errors: false)
+      |> assign_form(changeset)
+
+    {:ok, socket, temporary_assigns: [form: nil]}
+  end
+
+  def handle_event("save", %{"user" => user_params}, socket) do
+    case Accounts.register_user(user_params) do
+      {:ok, user} ->
+        changeset = Accounts.change_user_registration(user)
+        {:noreply, socket |> assign(trigger_submit: true) |> assign_form(changeset)}
+
+      {:error, %Ecto.Changeset{} = changeset} ->
+        {:noreply, socket |> assign(check_errors: true) |> assign_form(changeset)}
+    end
+  end
+
+  def handle_event("validate", %{"user" => user_params}, socket) do
+    changeset = Accounts.change_user_registration(%Schema.User{}, user_params)
+    {:noreply, assign_form(socket, Map.put(changeset, :action, :validate))}
+  end
+
+  defp assign_form(socket, %Ecto.Changeset{} = changeset) do
+    form = to_form(changeset, as: "user")
+
+    if changeset.valid? do
+      assign(socket, form: form, check_errors: false)
+    else
+      assign(socket, form: form)
+    end
+  end
+end
diff --git a/lib/web/live/user_settings_live.ex b/lib/web/live/user_settings_live.ex
new file mode 100644
index 0000000..7802340
--- /dev/null
+++ b/lib/web/live/user_settings_live.ex
@@ -0,0 +1,156 @@
+defmodule Web.UserSettingsLive do
+  @moduledoc false
+  use Web, :live_view
+
+  alias Core.Accounts
+
+  def render(assigns) do
+    # ~H"""
+    # <.header class="text-center">
+    #   Account Settings
+    #   <:subtitle>Manage your account username address and password settings</:subtitle>
+    # </.header>
+
+    # <div class="space-y-12 divide-y">
+    #   <div>
+    #     <.simple_form
+    #       for={@username_form}
+    #       id="username_form"
+    #       phx-submit="update_username"
+    #       phx-change="validate_username"
+    #     >
+    #       <.input field={@username_form[:username]} type="text" label="Email" required />
+    #       <.input
+    #         field={@username_form[:current_password]}
+    #         name="current_password"
+    #         id="current_password_for_username"
+    #         type="password"
+    #         label="Current password"
+    #         value={@username_form_current_password}
+    #         required
+    #       />
+    #       <:actions>
+    #         <.button phx-disable-with="Changing...">Change Email</.button>
+    #       </:actions>
+    #     </.simple_form>
+    #   </div>
+    #   <div>
+    #     <.simple_form
+    #       for={@password_form}
+    #       id="password_form"
+    #       action={~p/admin/users/log_in?_action=password_updated"}
+    #       method="post"
+    #       phx-change="validate_password"
+    #       phx-submit="update_password"
+    #       phx-trigger-action={@trigger_submit}
+    #     >
+    #       <input
+    #         name={@password_form[:username].name}
+    #         type="hidden"
+    #         id="hidden_user_username"
+    #         value={@current_username}
+    #       />
+    #       <.input field={@password_form[:password]} type="password" label="New password" required />
+    #       <.input
+    #         field={@password_form[:password_confirmation]}
+    #         type="password"
+    #         label="Confirm new password"
+    #       />
+    #       <.input
+    #         field={@password_form[:current_password]}
+    #         name="current_password"
+    #         type="password"
+    #         label="Current password"
+    #         id="current_password_for_password"
+    #         value={@current_password}
+    #         required
+    #       />
+    #       <:actions>
+    #         <.button phx-disable-with="Changing...">Change Password</.button>
+    #       </:actions>
+    #     </.simple_form>
+    #   </div>
+    # </div>
+    # """
+    ~H"""
+    <pre>UserSettingsLive</pre>
+    """
+  end
+
+  def mount(_params, _session, socket) do
+    user = socket.assigns.current_user
+    username_changeset = Accounts.change_user_username(user)
+    password_changeset = Accounts.change_user_password(user)
+
+    socket =
+      socket
+      |> assign(:current_password, nil)
+      |> assign(:username_form_current_password, nil)
+      |> assign(:current_username, user.username)
+      |> assign(:username_form, to_form(username_changeset))
+      |> assign(:password_form, to_form(password_changeset))
+      |> assign(:trigger_submit, false)
+
+    {:ok, socket}
+  end
+
+  def handle_event("validate_username", params, socket) do
+    %{"current_password" => password, "user" => user_params} = params
+
+    username_form =
+      socket.assigns.current_user
+      |> Accounts.change_user_username(user_params)
+      |> Map.put(:action, :validate)
+      |> to_form()
+
+    {:noreply, assign(socket, username_form: username_form, username_form_current_password: password)}
+  end
+
+  def handle_event("update_username", params, socket) do
+    %{"current_password" => password, "user" => user_params} = params
+    user = socket.assigns.current_user
+
+    case Accounts.update_user_username(user, password, user_params) do
+      {:ok, updated_user} ->
+        username_changeset = Accounts.change_user_username(updated_user)
+
+        {:noreply,
+         socket
+         |> put_flash(:info, "Email updated")
+         |> assign(username_form_current_password: nil, username_form: to_form(username_changeset))}
+
+      {:error, changeset} ->
+        {:noreply, assign(socket, :username_form, to_form(Map.put(changeset, :action, :insert)))}
+    end
+  end
+
+  def handle_event("validate_password", params, socket) do
+    %{"current_password" => password, "user" => user_params} = params
+
+    password_form =
+      socket.assigns.current_user
+      |> Accounts.change_user_password(user_params)
+      |> Map.put(:action, :validate)
+      |> to_form()
+
+    {:noreply, assign(socket, password_form: password_form, current_password: password)}
+  end
+
+  def handle_event("update_password", params, socket) do
+    %{"current_password" => password, "user" => user_params} = params
+    user = socket.assigns.current_user
+
+    case Accounts.update_user_password(user, password, user_params) do
+      {:ok, user} ->
+        password_form =
+          user
+          |> Accounts.change_user_password(user_params)
+          |> to_form()
+
+        {:noreply, assign(socket, trigger_submit: true, password_form: password_form)}
+
+      {:error, changeset} ->
+        {:noreply, assign(socket, password_form: to_form(changeset))}
+    end
+  end
+end
diff --git a/lib/web/router.ex b/lib/web/router.ex
index debe208..8ee4e37 100644
--- a/lib/web/router.ex
+++ b/lib/web/router.ex
@@ -1,6 +1,8 @@
 defmodule Web.Router do
   use Web, :router
 
+  import Web.UserAuth
+
   pipeline :browser do
     plug :accepts, ["html"]
     plug :fetch_session
@@ -8,5 +10,37 @@ defmodule Web.Router do
     plug :put_root_layout, html: {Web.Layouts, :root}
     plug :protect_from_forgery
     plug :put_secure_browser_headers
+    plug :fetch_current_user
+  end
+
+  scope "/", Web do
+    pipe_through [:browser, :redirect_if_user_is_authenticated]
+
+    live_session :redirect_if_user_is_authenticated,
+      on_mount: [{Web.UserAuth, :redirect_if_user_is_authenticated}] do
+      live "/admin/users/register", UserRegistrationLive, :new
+      live "/admin/users/log_in", UserLoginLive, :new
+    end
+
+    post "/admin/users/log_in", UserSessionController, :create
+  end
+
+  scope "/admin", Web do
+    pipe_through [:browser, :require_authenticated_user]
+
+    live_session :require_authenticated_user, on_mount: [{Web.UserAuth, :ensure_authenticated}] do
+      live "/users/settings", UserSettingsLive, :edit
+    end
+  end
+
+  scope "/", Web do
+    pipe_through [:browser]
+
+    get "/", PageController, :home
+
+    delete "/users/log_out", UserSessionController, :delete
+
+    # live_session :current_user, on_mount: [{Web.UserAuth, :mount_current_user}] do
+    # end
   end
 end
diff --git a/lib/web/user_auth.ex b/lib/web/user_auth.ex
new file mode 100644
index 0000000..0e3a4d4
--- /dev/null
+++ b/lib/web/user_auth.ex
@@ -0,0 +1,230 @@
+defmodule Web.UserAuth do
+  @moduledoc false
+  use Web, :verified_routes
+
+  import Phoenix.Controller
+  import Plug.Conn
+
+  alias Core.Accounts
+
+  # Make the remember me cookie valid for 60 days.
+  # If you want bump or reduce this value, also change
+  # the token expiry itself in UserToken.
+  @max_age 60 * 60 * 24 * 60
+  @remember_me_cookie "_core_web_user_remember_me"
+  @remember_me_options [sign: true, max_age: @max_age, same_site: "Lax"]
+
+  @doc """
+  Logs the user in.
+
+  It renews the session ID and clears the whole session
+  to avoid fixation attacks. See the renew_session
+  function to customize this behaviour.
+
+  It also sets a `:live_socket_id` key in the session,
+  so LiveView sessions are identified and automatically
+  disconnected on log out. The line can be safely removed
+  if you are not using LiveView.
+  """
+  def log_in_user(conn, user, params \\ %{}) do
+    token = Accounts.generate_user_session_token(user)
+    user_return_to = get_session(conn, :user_return_to)
+
+    conn
+    |> renew_session()
+    |> put_token_in_session(token)
+    |> maybe_write_remember_me_cookie(token, params)
+    |> redirect(to: user_return_to || signed_in_path(conn))
+  end
+
+  defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do
+    put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options)
+  end
+
+  defp maybe_write_remember_me_cookie(conn, _token, _params) do
+    conn
+  end
+
+  # This function renews the session ID and erases the whole
+  # session to avoid fixation attacks. If there is any data
+  # in the session you may want to preserve after log in/log out,
+  # you must explicitly fetch the session data before clearing
+  # and then immediately set it after clearing, for example:
+  #
+  #     defp renew_session(conn) do
+  #       preferred_locale = get_session(conn, :preferred_locale)
+  #
+  #       conn
+  #       |> configure_session(renew: true)
+  #       |> clear_session()
+  #       |> put_session(:preferred_locale, preferred_locale)
+  #     end
+  #
+  defp renew_session(conn) do
+    delete_csrf_token()
+
+    conn
+    |> configure_session(renew: true)
+    |> clear_session()
+  end
+
+  @doc """
+  Logs the user out.
+
+  It clears all session data for safety. See renew_session.
+  """
+  def log_out_user(conn) do
+    user_token = get_session(conn, :user_token)
+    user_token && Accounts.delete_user_session_token(user_token)
+
+    if live_socket_id = get_session(conn, :live_socket_id) do
+      Web.Endpoint.broadcast(live_socket_id, "disconnect", %{})
+    end
+
+    conn
+    |> renew_session()
+    |> delete_resp_cookie(@remember_me_cookie)
+    |> redirect(to: ~p"/")
+  end
+
+  @doc """
+  Authenticates the user by looking into the session
+  and remember me token.
+  """
+  def fetch_current_user(conn, _opts) do
+    {user_token, conn} = ensure_user_token(conn)
+    user = user_token && Accounts.get_user_by_session_token(user_token)
+    assign(conn, :current_user, user)
+  end
+
+  defp ensure_user_token(conn) do
+    if token = get_session(conn, :user_token) do
+      {token, conn}
+    else
+      conn = fetch_cookies(conn, signed: [@remember_me_cookie])
+
+      if token = conn.cookies[@remember_me_cookie] do
+        {token, put_token_in_session(conn, token)}
+      else
+        {nil, conn}
+      end
+    end
+  end
+
+  @doc """
+  Handles mounting and authenticating the current_user in LiveViews.
+
+  ## `on_mount` arguments
+
+    * `:mount_current_user` - Assigns current_user
+      to socket assigns based on user_token, or nil if
+      there's no user_token or no matching user.
+
+    * `:ensure_authenticated` - Authenticates the user from the session,
+      and assigns the current_user to socket assigns based
+      on user_token.
+      Redirects to login page if there's no logged user.
+
+    * `:redirect_if_user_is_authenticated` - Authenticates the user from the session.
+      Redirects to signed_in_path if there's a logged user.
+
+  ## Examples
+
+  Use the `on_mount` lifecycle macro in LiveViews to mount or authenticate
+  the current_user:
+
+      defmodule Web.PageLive do
+        use Web, :live_view
+
+        on_mount {Web.UserAuth, :mount_current_user}
+        ...
+      end
+
+  Or use the `live_session` of your router to invoke the on_mount callback:
+
+      live_session :authenticated, on_mount: [{Web.UserAuth, :ensure_authenticated}] do
+        live "/profile", ProfileLive, :index
+      end
+  """
+  def on_mount(:mount_current_user, _params, session, socket) do
+    {:cont, mount_current_user(socket, session)}
+  end
+
+  def on_mount(:ensure_authenticated, _params, session, socket) do
+    socket = mount_current_user(socket, session)
+
+    if socket.assigns.current_user do
+      {:cont, socket}
+    else
+      socket =
+        socket
+        |> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.")
+        |> Phoenix.LiveView.redirect(to: ~p"/admin/users/log_in")
+
+      {:halt, socket}
+    end
+  end
+
+  def on_mount(:redirect_if_user_is_authenticated, _params, session, socket) do
+    socket = mount_current_user(socket, session)
+
+    if socket.assigns.current_user do
+      {:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket))}
+    else
+      {:cont, socket}
+    end
+  end
+
+  defp mount_current_user(socket, session) do
+    Phoenix.Component.assign_new(socket, :current_user, fn ->
+      if user_token = session["user_token"] do
+        Accounts.get_user_by_session_token(user_token)
+      end
+    end)
+  end
+
+  @doc """
+  Used for routes that require the user to not be authenticated.
+  """
+  def redirect_if_user_is_authenticated(conn, _opts) do
+    if conn.assigns[:current_user] do
+      conn
+      |> redirect(to: signed_in_path(conn))
+      |> halt()
+    else
+      conn
+    end
+  end
+
+  @doc """
+  Used for routes that require the user to be authenticated.
+
+  If you want to enforce the user email is confirmed before
+  they use the application at all, here would be a good place.
+  """
+  def require_authenticated_user(conn, _opts) do
+    if conn.assigns[:current_user] do
+      conn
+    else
+      conn
+      |> put_flash(:error, "You must log in to access this page.")
+      |> maybe_store_return_to()
+      |> redirect(to: ~p"/admin/users/log_in")
+      |> halt()
+    end
+  end
+
+  defp put_token_in_session(conn, token) do
+    conn
+    |> put_session(:user_token, token)
+    |> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}")
+  end
+
+  defp maybe_store_return_to(%{method: "GET"} = conn) do
+    put_session(conn, :user_return_to, current_path(conn))
+  end
+
+  defp maybe_store_return_to(conn), do: conn
+
+  defp signed_in_path(_conn), do: ~p"/"
+end
diff --git a/mix.exs b/mix.exs
index 54a1b14..fd72445 100644
--- a/mix.exs
+++ b/mix.exs
@@ -33,6 +33,7 @@ defmodule SlaonelyButSurely.MixProject do
   # Type `mix help deps` for examples and options.
   defp deps do
     [
+      {:argon2_elixir, "~> 3.0"},
       # Default Phoenix dependencies
       {:phoenix, "~> 1.7.19"},
       {:phoenix_ecto, "~> 4.5"},
diff --git a/mix.lock b/mix.lock
index 669ba7c..fe31221 100644
--- a/mix.lock
+++ b/mix.lock
@@ -1,5 +1,5 @@
 %{
-  "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"},
+  "argon2_elixir": {:hex, :argon2_elixir, "3.2.1", "f47740bf9f2a39ffef79ba48eb25dea2ee37bcc7eadf91d49615591d1a6fce1a", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "a813b78217394530b5fcf4c8070feee43df03ffef938d044019169c766315690"},
   "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"},
   "boundary": {:hex, :boundary, "0.10.4", "5fec5d2736c12f9bfe1720c3a2bd8c48c3547c24d6002ebf8e087570afd5bd2f", [:mix], [], "hexpm", "8baf6f23987afdb1483033ed0bde75c9c703613c22ed58d5f23bf948f203247c"},
   "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
diff --git a/priv/repo/migrations/20250324093345_create_users_auth_tables.exs b/priv/repo/migrations/20250324093345_create_users_auth_tables.exs
new file mode 100644
index 0000000..a3e5289
--- /dev/null
+++ b/priv/repo/migrations/20250324093345_create_users_auth_tables.exs
@@ -0,0 +1,27 @@
+defmodule Core.Repo.Migrations.CreateUsersAuthTables do
+  use Ecto.Migration
+
+  def change do
+    create table(:users, primary_key: false) do
+      add :id, :binary_id, primary_key: true
+      add :username, :text, null: false
+      add :hashed_password, :text, null: false
+
+      timestamps(type: :utc_datetime_usec)
+    end
+
+    create unique_index(:users, [:username])
+
+    create table(:users_tokens, primary_key: false) do
+      add :id, :binary_id, primary_key: true
+      add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
+      add :token, :binary, null: false
+      add :context, :text, null: false
+
+      timestamps(type: :utc_datetime_usec, updated_at: false)
+    end
+
+    create index(:users_tokens, [:user_id])
+    create unique_index(:users_tokens, [:context, :token])
+  end
+end
diff --git a/test/support/conn_case.ex b/test/support/test/conn_case.ex
similarity index 61%
rename from test/support/conn_case.ex
rename to test/support/test/conn_case.ex
index 1e6b619..484f4bc 100644
--- a/test/support/conn_case.ex
+++ b/test/support/test/conn_case.ex
@@ -36,4 +36,30 @@ defmodule Test.ConnCase do
     Test.DataCase.setup_sandbox(tags)
     {:ok, conn: Phoenix.ConnTest.build_conn()}
   end
+
+  # @doc """
+  # Setup helper that registers and logs in users.
+
+  #     setup :register_and_log_in_user
+
+  # It stores an updated connection and a registered user in the
+  # test context.
+  # """
+  # def register_and_log_in_user(%{conn: conn}) do
+  #   user = Core.AccountsFixtures.user_fixture()
+  #   %{conn: log_in_user(conn, user), user: user}
+  # end
+
+  # @doc """
+  # Logs the given `user` into the `conn`.
+
+  # It returns an updated `conn`.
+  # """
+  # def log_in_user(conn, user) do
+  #   token = Core.Accounts.generate_user_session_token(user)
+
+  #   conn
+  #   |> Phoenix.ConnTest.init_test_session(%{})
+  #   |> Plug.Conn.put_session(:user_token, token)
+  # end
 end
diff --git a/test/support/data_case.ex b/test/support/test/data_case.ex
similarity index 100%
rename from test/support/data_case.ex
rename to test/support/test/data_case.ex
diff --git a/test/support/test/fixtures/accounts.ex b/test/support/test/fixtures/accounts.ex
new file mode 100644
index 0000000..abc0cda
--- /dev/null
+++ b/test/support/test/fixtures/accounts.ex
@@ -0,0 +1,16 @@
+defmodule Test.Fixtures.Accounts do
+  @moduledoc """
+  This module defines test helpers for creating
+  entities via the `Core.Accounts` context.
+  """
+
+  def unique_user_username, do: "user#{System.unique_integer()}"
+  def valid_user_password, do: "hello world!"
+
+  def user(attrs \\ %{}) do
+    Enum.into(attrs, %{
+      username: unique_user_username(),
+      password: valid_user_password()
+    })
+  end
+end
diff --git a/test/web/controllers/page_controller_test.exs b/test/web/controllers/page_controller_test.exs
deleted file mode 100644
index 6747117..0000000
--- a/test/web/controllers/page_controller_test.exs
+++ /dev/null
@@ -1,8 +0,0 @@
-defmodule Test.Web.PageControllerTest do
-  use Test.ConnCase
-
-  test "GET /", %{conn: conn} do
-    conn = get(conn, ~p"/")
-    assert html_response(conn, 200) =~ "sloanelybutsurely.com"
-  end
-end
diff --git a/test/web/controllers/user_auth_test.exs b/test/web/controllers/user_auth_test.exs
new file mode 100644
index 0000000..bd61de2
--- /dev/null
+++ b/test/web/controllers/user_auth_test.exs
@@ -0,0 +1,273 @@
+defmodule Test.Web.UserAuthTest do
+  use Test.ConnCase, async: true
+
+  alias Core.Accounts
+  alias Phoenix.LiveView
+  alias Web.UserAuth
+
+  @remember_me_cookie "_core_web_user_remember_me"
+
+  setup %{conn: conn} do
+    conn =
+      conn
+      |> Map.replace!(:secret_key_base, Web.Endpoint.config(:secret_key_base))
+      |> init_test_session(%{})
+
+    {:ok, user} = Accounts.register_user(Test.Fixtures.Accounts.user())
+
+    %{user: user, conn: conn}
+  end
+
+  describe "log_in_user/3" do
+    test "stores the user token in the session", %{conn: conn, user: user} do
+      conn = UserAuth.log_in_user(conn, user)
+      assert token = get_session(conn, :user_token)
+      assert get_session(conn, :live_socket_id) == "users_sessions:#{Base.url_encode64(token)}"
+      assert redirected_to(conn) == ~p"/"
+      assert Accounts.get_user_by_session_token(token)
+    end
+
+    test "clears everything previously stored in the session", %{conn: conn, user: user} do
+      conn = conn |> put_session(:to_be_removed, "value") |> UserAuth.log_in_user(user)
+      refute get_session(conn, :to_be_removed)
+    end
+
+    test "redirects to the configured path", %{conn: conn, user: user} do
+      conn = conn |> put_session(:user_return_to, "/hello") |> UserAuth.log_in_user(user)
+      assert redirected_to(conn) == "/hello"
+    end
+
+    test "writes a cookie if remember_me is configured", %{conn: conn, user: user} do
+      conn = conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"})
+      assert get_session(conn, :user_token) == conn.cookies[@remember_me_cookie]
+
+      assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie]
+      assert signed_token != get_session(conn, :user_token)
+      assert max_age == 5_184_000
+    end
+  end
+
+  describe "logout_user/1" do
+    test "erases session and cookies", %{conn: conn, user: user} do
+      user_token = Accounts.generate_user_session_token(user)
+
+      conn =
+        conn
+        |> put_session(:user_token, user_token)
+        |> put_req_cookie(@remember_me_cookie, user_token)
+        |> fetch_cookies()
+        |> UserAuth.log_out_user()
+
+      refute get_session(conn, :user_token)
+      refute conn.cookies[@remember_me_cookie]
+      assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie]
+      assert redirected_to(conn) == ~p"/"
+      refute Accounts.get_user_by_session_token(user_token)
+    end
+
+    test "broadcasts to the given live_socket_id", %{conn: conn} do
+      live_socket_id = "users_sessions:abcdef-token"
+      Web.Endpoint.subscribe(live_socket_id)
+
+      conn
+      |> put_session(:live_socket_id, live_socket_id)
+      |> UserAuth.log_out_user()
+
+      assert_receive %Phoenix.Socket.Broadcast{event: "disconnect", topic: ^live_socket_id}
+    end
+
+    test "works even if user is already logged out", %{conn: conn} do
+      conn = conn |> fetch_cookies() |> UserAuth.log_out_user()
+      refute get_session(conn, :user_token)
+      assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie]
+      assert redirected_to(conn) == ~p"/"
+    end
+  end
+
+  describe "fetch_current_user/2" do
+    test "authenticates user from session", %{conn: conn, user: user} do
+      user_token = Accounts.generate_user_session_token(user)
+      conn = conn |> put_session(:user_token, user_token) |> UserAuth.fetch_current_user([])
+      assert conn.assigns.current_user.id == user.id
+    end
+
+    test "authenticates user from cookies", %{conn: conn, user: user} do
+      logged_in_conn =
+        conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"})
+
+      user_token = logged_in_conn.cookies[@remember_me_cookie]
+      %{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie]
+
+      conn =
+        conn
+        |> put_req_cookie(@remember_me_cookie, signed_token)
+        |> UserAuth.fetch_current_user([])
+
+      assert conn.assigns.current_user.id == user.id
+      assert get_session(conn, :user_token) == user_token
+
+      assert get_session(conn, :live_socket_id) ==
+               "users_sessions:#{Base.url_encode64(user_token)}"
+    end
+
+    test "does not authenticate if data is missing", %{conn: conn, user: user} do
+      _ = Accounts.generate_user_session_token(user)
+      conn = UserAuth.fetch_current_user(conn, [])
+      refute get_session(conn, :user_token)
+      refute conn.assigns.current_user
+    end
+  end
+
+  describe "on_mount :mount_current_user" do
+    test "assigns current_user based on a valid user_token", %{conn: conn, user: user} do
+      user_token = Accounts.generate_user_session_token(user)
+      session = conn |> put_session(:user_token, user_token) |> get_session()
+
+      {:cont, updated_socket} =
+        UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{})
+
+      assert updated_socket.assigns.current_user.id == user.id
+    end
+
+    test "assigns nil to current_user assign if there isn't a valid user_token", %{conn: conn} do
+      user_token = "invalid_token"
+      session = conn |> put_session(:user_token, user_token) |> get_session()
+
+      {:cont, updated_socket} =
+        UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{})
+
+      assert updated_socket.assigns.current_user == nil
+    end
+
+    test "assigns nil to current_user assign if there isn't a user_token", %{conn: conn} do
+      session = get_session(conn)
+
+      {:cont, updated_socket} =
+        UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{})
+
+      assert updated_socket.assigns.current_user == nil
+    end
+  end
+
+  describe "on_mount :ensure_authenticated" do
+    test "authenticates current_user based on a valid user_token", %{conn: conn, user: user} do
+      user_token = Accounts.generate_user_session_token(user)
+      session = conn |> put_session(:user_token, user_token) |> get_session()
+
+      {:cont, updated_socket} =
+        UserAuth.on_mount(:ensure_authenticated, %{}, session, %LiveView.Socket{})
+
+      assert updated_socket.assigns.current_user.id == user.id
+    end
+
+    test "redirects to login page if there isn't a valid user_token", %{conn: conn} do
+      user_token = "invalid_token"
+      session = conn |> put_session(:user_token, user_token) |> get_session()
+
+      socket = %LiveView.Socket{
+        endpoint: Web.Endpoint,
+        assigns: %{__changed__: %{}, flash: %{}}
+      }
+
+      {:halt, updated_socket} = UserAuth.on_mount(:ensure_authenticated, %{}, session, socket)
+      assert updated_socket.assigns.current_user == nil
+    end
+
+    test "redirects to login page if there isn't a user_token", %{conn: conn} do
+      session = get_session(conn)
+
+      socket = %LiveView.Socket{
+        endpoint: Web.Endpoint,
+        assigns: %{__changed__: %{}, flash: %{}}
+      }
+
+      {:halt, updated_socket} = UserAuth.on_mount(:ensure_authenticated, %{}, session, socket)
+      assert updated_socket.assigns.current_user == nil
+    end
+  end
+
+  describe "on_mount :redirect_if_user_is_authenticated" do
+    test "redirects if there is an authenticated  user ", %{conn: conn, user: user} do
+      user_token = Accounts.generate_user_session_token(user)
+      session = conn |> put_session(:user_token, user_token) |> get_session()
+
+      assert {:halt, _updated_socket} =
+               UserAuth.on_mount(
+                 :redirect_if_user_is_authenticated,
+                 %{},
+                 session,
+                 %LiveView.Socket{}
+               )
+    end
+
+    test "doesn't redirect if there is no authenticated user", %{conn: conn} do
+      session = get_session(conn)
+
+      assert {:cont, _updated_socket} =
+               UserAuth.on_mount(
+                 :redirect_if_user_is_authenticated,
+                 %{},
+                 session,
+                 %LiveView.Socket{}
+               )
+    end
+  end
+
+  describe "redirect_if_user_is_authenticated/2" do
+    test "redirects if user is authenticated", %{conn: conn, user: user} do
+      conn = conn |> assign(:current_user, user) |> UserAuth.redirect_if_user_is_authenticated([])
+      assert conn.halted
+      assert redirected_to(conn) == ~p"/"
+    end
+
+    test "does not redirect if user is not authenticated", %{conn: conn} do
+      conn = UserAuth.redirect_if_user_is_authenticated(conn, [])
+      refute conn.halted
+      refute conn.status
+    end
+  end
+
+  describe "require_authenticated_user/2" do
+    test "redirects if user is not authenticated", %{conn: conn} do
+      conn = conn |> fetch_flash() |> UserAuth.require_authenticated_user([])
+      assert conn.halted
+
+      assert redirected_to(conn) == ~p"/admin/users/log_in"
+
+      assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
+               "You must log in to access this page."
+    end
+
+    test "stores the path to redirect to on GET", %{conn: conn} do
+      halted_conn =
+        %{conn | path_info: ["foo"], query_string: ""}
+        |> fetch_flash()
+        |> UserAuth.require_authenticated_user([])
+
+      assert halted_conn.halted
+      assert get_session(halted_conn, :user_return_to) == "/foo"
+
+      halted_conn =
+        %{conn | path_info: ["foo"], query_string: "bar=baz"}
+        |> fetch_flash()
+        |> UserAuth.require_authenticated_user([])
+
+      assert halted_conn.halted
+      assert get_session(halted_conn, :user_return_to) == "/foo?bar=baz"
+
+      halted_conn =
+        %{conn | path_info: ["foo"], query_string: "bar", method: "POST"}
+        |> fetch_flash()
+        |> UserAuth.require_authenticated_user([])
+
+      assert halted_conn.halted
+      refute get_session(halted_conn, :user_return_to)
+    end
+
+    test "does not redirect if user is authenticated", %{conn: conn, user: user} do
+      conn = conn |> assign(:current_user, user) |> UserAuth.require_authenticated_user([])
+      refute conn.halted
+      refute conn.status
+    end
+  end
+end