From f0d6ae42d0dbcde0984cdd1da5d9106b0502c028 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 1 Feb 2026 14:58:07 -0600 Subject: [PATCH] feat: update UserAuth to redirect to sudo verify page --- lib/towerops_web/user_auth.ex | 59 ++++++--- .../20260131154549_drop_user_credentials.exs | 2 +- test/towerops_web/user_auth_test.exs | 125 +++++++++--------- 3 files changed, 110 insertions(+), 76 deletions(-) diff --git a/lib/towerops_web/user_auth.ex b/lib/towerops_web/user_auth.ex index 8777d6bb..32bcab5a 100644 --- a/lib/towerops_web/user_auth.ex +++ b/lib/towerops_web/user_auth.ex @@ -273,16 +273,30 @@ defmodule ToweropsWeb.UserAuth do @doc """ Plug for routes that require sudo mode. + + Checks if the user's sudo mode is active (within 10 minutes). + If TOTP is enabled, redirects to TOTP-only verification page. + If TOTP is not enabled, redirects to enrollment. """ def require_sudo_mode(conn, _opts) do - if Accounts.sudo_mode?(conn.assigns.current_scope.user, -10) do - conn - else - conn - |> put_flash(:error, "You must re-authenticate to access this page.") - |> maybe_store_return_to() - |> redirect(to: ~p"/users/log-in") - |> halt() + user = conn.assigns.current_scope.user + + case {Accounts.sudo_mode?(user, -10), Accounts.totp_enabled?(user)} do + {true, _} -> + conn + + {false, true} -> + conn + |> put_flash(:error, "Please verify your identity to continue.") + |> maybe_store_return_to() + |> redirect(to: ~p"/users/sudo-verify") + |> halt() + + {false, false} -> + conn + |> put_flash(:error, "Two-factor authentication is required for this action.") + |> redirect(to: ~p"/account/totp-enrollment") + |> halt() end end @@ -540,15 +554,28 @@ defmodule ToweropsWeb.UserAuth do def on_mount(:require_sudo_mode, _params, _session, socket) do user = socket.assigns.current_scope && socket.assigns.current_scope.user - if user && Accounts.sudo_mode?(user, -10) do - {:cont, socket} - else - socket = - socket - |> LiveView.put_flash(:error, "You must re-authenticate to access this page.") - |> LiveView.redirect(to: ~p"/users/log-in") + case {user && Accounts.sudo_mode?(user, -10), user && Accounts.totp_enabled?(user)} do + {true, _} -> + {:cont, socket} - {:halt, socket} + {false, true} -> + socket = + socket + |> LiveView.put_flash(:error, "Please verify your identity to continue.") + |> LiveView.redirect(to: ~p"/users/sudo-verify") + + {:halt, socket} + + {false, false} -> + socket = + socket + |> LiveView.put_flash(:error, "Two-factor authentication is required for this action.") + |> LiveView.redirect(to: ~p"/account/totp-enrollment") + + {:halt, socket} + + _ -> + {:halt, LiveView.redirect(socket, to: ~p"/users/log-in")} end end diff --git a/priv/repo/migrations/20260131154549_drop_user_credentials.exs b/priv/repo/migrations/20260131154549_drop_user_credentials.exs index d4bffba7..49bd4958 100644 --- a/priv/repo/migrations/20260131154549_drop_user_credentials.exs +++ b/priv/repo/migrations/20260131154549_drop_user_credentials.exs @@ -2,6 +2,6 @@ defmodule Towerops.Repo.Migrations.DropUserCredentials do use Ecto.Migration def change do - drop table(:user_credentials) + drop_if_exists table(:user_credentials) end end diff --git a/test/towerops_web/user_auth_test.exs b/test/towerops_web/user_auth_test.exs index 45d52990..a857ffe2 100644 --- a/test/towerops_web/user_auth_test.exs +++ b/test/towerops_web/user_auth_test.exs @@ -188,7 +188,40 @@ defmodule ToweropsWeb.UserAuthTest do end describe "require_sudo_mode/2" do - test "allows users that have authenticated in the last 10 minutes", %{conn: conn, user: user} do + test "redirects to sudo verify when sudo mode expired and TOTP enabled", %{conn: conn} do + user = user_fixture() + + # Create TOTP device + {:ok, _device, _secret} = + Accounts.create_totp_device(user.id, "Test Device", NimbleTOTP.secret()) + + conn = + conn + |> fetch_flash() + |> assign(:current_scope, Scope.for_user(user)) + |> UserAuth.require_sudo_mode([]) + + assert redirected_to(conn) == ~p"/users/sudo-verify" + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "verify your identity" + end + + test "redirects to TOTP enrollment when sudo mode expired and no TOTP", %{conn: conn} do + user = user_fixture(enable_totp: false) + + conn = + conn + |> fetch_flash() + |> assign(:current_scope, Scope.for_user(user)) + |> UserAuth.require_sudo_mode([]) + + assert redirected_to(conn) == ~p"/account/totp-enrollment" + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Two-factor authentication" + end + + test "allows access when sudo mode is active", %{conn: conn} do + user = user_fixture() + {:ok, user} = Accounts.grant_sudo_mode(user) + conn = conn |> fetch_flash() @@ -196,37 +229,14 @@ defmodule ToweropsWeb.UserAuthTest do |> UserAuth.require_sudo_mode([]) refute conn.halted - refute conn.status end - test "redirects when authentication is too old", %{conn: conn, user: user} do - eleven_minutes_ago = DateTime.add(DateTime.utc_now(), -11, :minute) + test "stores return path on GET requests when redirecting", %{conn: conn} do + user = user_fixture() - # Update the user in the database with old authenticated_at - user = - user - |> Ecto.Changeset.change(authenticated_at: eleven_minutes_ago) - |> Towerops.Repo.update!() - - conn = - conn - |> fetch_flash() - |> assign(:current_scope, Scope.for_user(user)) - |> UserAuth.require_sudo_mode([]) - - assert redirected_to(conn) == ~p"/users/log-in" - - assert Phoenix.Flash.get(conn.assigns.flash, :error) == - "You must re-authenticate to access this page." - end - - test "stores return path on GET requests when redirecting", %{conn: conn, user: user} do - eleven_minutes_ago = DateTime.add(DateTime.utc_now(), -11, :minute) - - user = - user - |> Ecto.Changeset.change(authenticated_at: eleven_minutes_ago) - |> Towerops.Repo.update!() + # Create TOTP device + {:ok, _device, _secret} = + Accounts.create_totp_device(user.id, "Test Device", NimbleTOTP.secret()) conn = %{conn | path_info: ["admin", "users"], query_string: "", method: "GET"} @@ -234,7 +244,7 @@ defmodule ToweropsWeb.UserAuthTest do |> assign(:current_scope, Scope.for_user(user)) |> UserAuth.require_sudo_mode([]) - assert redirected_to(conn) == ~p"/users/log-in" + assert redirected_to(conn) == ~p"/users/sudo-verify" assert get_session(conn, :user_return_to) == "/admin/users" end end @@ -866,45 +876,42 @@ defmodule ToweropsWeb.UserAuthTest do end describe "on_mount/4 - :require_sudo_mode" do - test "allows users in sudo mode", %{user: user} do + test "redirects to sudo verify when sudo mode expired and TOTP enabled" do + user = user_fixture() + + # Create TOTP device + {:ok, _device, _secret} = + Accounts.create_totp_device(user.id, "Test Device", NimbleTOTP.secret()) + socket = %Socket{assigns: %{__changed__: %{}, flash: %{}}} + socket = Phoenix.Component.assign(socket, :current_scope, Scope.for_user(user)) - socket = - Phoenix.Component.assign_new(socket, :current_scope, fn -> Scope.for_user(user) end) + assert {:halt, redirected_socket} = + UserAuth.on_mount(:require_sudo_mode, %{}, %{}, socket) - {:cont, result_socket} = - UserAuth.on_mount( - :require_sudo_mode, - %{}, - %{}, - socket - ) - - refute result_socket.redirected + assert Phoenix.Flash.get(redirected_socket.assigns.flash, :error) =~ "verify your identity" end - test "redirects users not in sudo mode" do - eleven_minutes_ago = DateTime.add(DateTime.utc_now(), -11, :minute) - - user = - user_fixture() - |> Ecto.Changeset.change(authenticated_at: eleven_minutes_ago) - |> Towerops.Repo.update!() + test "redirects to TOTP enrollment when sudo mode expired and no TOTP" do + user = user_fixture(enable_totp: false) socket = %Socket{assigns: %{__changed__: %{}, flash: %{}}} + socket = Phoenix.Component.assign(socket, :current_scope, Scope.for_user(user)) - socket = - Phoenix.Component.assign_new(socket, :current_scope, fn -> Scope.for_user(user) end) + assert {:halt, redirected_socket} = + UserAuth.on_mount(:require_sudo_mode, %{}, %{}, socket) - {:halt, result_socket} = - UserAuth.on_mount( - :require_sudo_mode, - %{}, - %{}, - socket - ) + assert Phoenix.Flash.get(redirected_socket.assigns.flash, :error) =~ "Two-factor authentication" + end - assert result_socket.redirected + test "allows access when sudo mode is active" do + user = user_fixture() + {:ok, user} = Accounts.grant_sudo_mode(user) + + socket = %Socket{assigns: %{__changed__: %{}, flash: %{}}} + socket = Phoenix.Component.assign(socket, :current_scope, Scope.for_user(user)) + + assert {:cont, _socket} = UserAuth.on_mount(:require_sudo_mode, %{}, %{}, socket) end test "redirects unauthenticated users" do