feat: update UserAuth to redirect to sudo verify page

This commit is contained in:
Graham McIntire 2026-02-01 14:58:07 -06:00
parent 9ff9c4ddc8
commit f0d6ae42d0
No known key found for this signature in database
3 changed files with 110 additions and 76 deletions

View file

@ -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

View file

@ -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

View file

@ -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