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