- Add UserSudoController with GET and POST /users/sudo/verify routes - Create verify.html.heex template for TOTP verification form - Only accept TOTP codes (6 numeric digits), reject recovery codes - Update grant_sudo_mode to set authenticated_at virtual field - Exclude /users/sudo paths from return_to overwriting - Add comprehensive controller tests (12 test cases) - Verify redirect behavior, error handling, and session management 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
24 KiB
Sudo Mode MFA-Only Verification Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Replace full login requirement for sudo mode with TOTP-only verification at dedicated /users/sudo-verify endpoint.
Architecture: Add new controller for sudo verification, modify UserAuth plugs to redirect to TOTP-only page instead of full login, add Accounts functions for granting sudo mode and verifying TOTP without recovery codes.
Tech Stack: Phoenix 1.8, Ecto, Phoenix.Controller, Plug, ExUnit
Task 1: Add Accounts.grant_sudo_mode/1 function
Files:
- Modify:
lib/towerops/accounts/user.ex - Modify:
lib/towerops/accounts.ex - Create:
test/towerops/accounts/grant_sudo_mode_test.exs
Step 1: Write the failing test
Create test/towerops/accounts/grant_sudo_mode_test.exs:
defmodule Towerops.Accounts.GrantSudoModeTest do
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
alias Towerops.Accounts
describe "grant_sudo_mode/1" do
test "updates user's last_sudo_at timestamp" do
user = user_fixture()
assert is_nil(user.last_sudo_at)
{:ok, updated_user} = Accounts.grant_sudo_mode(user)
assert %DateTime{} = updated_user.last_sudo_at
assert DateTime.diff(DateTime.utc_now(), updated_user.last_sudo_at, :second) < 2
end
test "returns error if user is invalid" do
user = user_fixture()
# Pass invalid user struct
assert {:error, _changeset} = Accounts.grant_sudo_mode(%{user | id: nil})
end
end
end
Step 2: Run test to verify it fails
Run: mix test test/towerops/accounts/grant_sudo_mode_test.exs
Expected: FAIL with "function Accounts.grant_sudo_mode/1 is undefined"
Step 3: Add sudo_changeset to User schema
In lib/towerops/accounts/user.ex, add after the existing changesets:
@doc """
Changeset for updating sudo mode timestamp.
"""
def sudo_changeset(user, attrs) do
user
|> cast(attrs, [:last_sudo_at])
|> validate_required([:last_sudo_at])
end
Step 4: Add grant_sudo_mode/1 to Accounts context
In lib/towerops/accounts.ex, add after the sudo_mode?/2 function:
@doc """
Grants sudo mode to a user by updating their last_sudo_at timestamp.
Returns `{:ok, user}` on success or `{:error, changeset}` on failure.
## Examples
iex> grant_sudo_mode(user)
{:ok, %User{last_sudo_at: ~U[2026-02-01 12:00:00Z]}}
"""
def grant_sudo_mode(%User{} = user) do
now = DateTime.utc_now()
user
|> User.sudo_changeset(%{last_sudo_at: now})
|> Repo.update()
end
Step 5: Run test to verify it passes
Run: mix test test/towerops/accounts/grant_sudo_mode_test.exs
Expected: PASS (2 tests)
Step 6: Commit
git add lib/towerops/accounts/user.ex lib/towerops/accounts.ex test/towerops/accounts/grant_sudo_mode_test.exs
git commit -m "feat: add Accounts.grant_sudo_mode/1 function"
Task 2: Add Accounts.verify_totp_only/2 function
Files:
- Modify:
lib/towerops/accounts.ex - Create:
test/towerops/accounts/verify_totp_only_test.exs
Step 1: Write the failing test
Create test/towerops/accounts/verify_totp_only_test.exs:
defmodule Towerops.Accounts.VerifyTotpOnlyTest do
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
alias Towerops.Accounts
describe "verify_totp_only/2" do
setup do
user = user_fixture()
# Create TOTP device
{:ok, device} =
Accounts.create_user_totp_device(user, %{
name: "Test Device",
secret: NimbleTOTP.secret()
})
%{user: user, device: device}
end
test "accepts valid TOTP codes", %{user: user, device: device} do
code = NimbleTOTP.verification_code(device.secret)
assert {:ok, returned_user} = Accounts.verify_totp_only(user, code)
assert returned_user.id == user.id
end
test "rejects invalid TOTP codes", %{user: user} do
assert {:error, :invalid_code} = Accounts.verify_totp_only(user, "000000")
end
test "rejects recovery codes", %{user: user} do
# Recovery codes are longer than 6 digits
recovery_code = "ABCD-EFGH-IJKL"
assert {:error, :recovery_code_not_allowed} =
Accounts.verify_totp_only(user, recovery_code)
end
test "rejects codes longer than 6 digits", %{user: user} do
assert {:error, :recovery_code_not_allowed} =
Accounts.verify_totp_only(user, "1234567890")
end
end
end
Step 2: Run test to verify it fails
Run: mix test test/towerops/accounts/verify_totp_only_test.exs
Expected: FAIL with "function Accounts.verify_totp_only/2 is undefined"
Step 3: Add verify_totp_only/2 to Accounts context
In lib/towerops/accounts.ex, add after the verify_user_mfa/2 function:
@doc """
Verifies a TOTP code for a user, rejecting recovery codes.
This is used for sudo mode verification where we want to ensure
the user has access to their authenticator device, not just a recovery code.
Returns `{:ok, user}` on success with valid TOTP code.
Returns `{:error, :recovery_code_not_allowed}` if a recovery code is provided.
Returns `{:error, reason}` for invalid TOTP codes.
## Examples
iex> verify_totp_only(user, "123456")
{:ok, %User{}}
iex> verify_totp_only(user, "ABCD-EFGH-IJKL")
{:error, :recovery_code_not_allowed}
iex> verify_totp_only(user, "000000")
{:error, :invalid_code}
"""
def verify_totp_only(%User{} = user, code) when is_binary(code) do
# Reject codes longer than 6 digits (likely recovery codes)
if String.length(String.replace(code, ~r/[^0-9]/, "")) > 6 do
{:error, :recovery_code_not_allowed}
else
case verify_user_mfa(user, code) do
{:ok, user, :totp} -> {:ok, user}
{:ok, _user, :recovery_code} -> {:error, :recovery_code_not_allowed}
{:error, reason} -> {:error, reason}
end
end
end
Step 4: Run test to verify it passes
Run: mix test test/towerops/accounts/verify_totp_only_test.exs
Expected: PASS (4 tests)
Step 5: Commit
git add lib/towerops/accounts.ex test/towerops/accounts/verify_totp_only_test.exs
git commit -m "feat: add Accounts.verify_totp_only/2 function"
Task 3: Create UserSudoController and HTML module
Files:
- Create:
lib/towerops_web/controllers/user_sudo_controller.ex - Create:
lib/towerops_web/controllers/user_sudo_html.ex - Create:
lib/towerops_web/controllers/user_sudo_html/verify.html.heex - Create:
test/towerops_web/controllers/user_sudo_controller_test.exs
Step 1: Write the failing test
Create test/towerops_web/controllers/user_sudo_controller_test.exs:
defmodule ToweropsWeb.UserSudoControllerTest do
use ToweropsWeb.ConnCase, async: true
import Towerops.AccountsFixtures
alias Towerops.Accounts
setup do
user = user_fixture()
# Create TOTP device
{:ok, device} =
Accounts.create_user_totp_device(user, %{
name: "Test Device",
secret: NimbleTOTP.secret()
})
%{user: user, device: device}
end
describe "GET /users/sudo-verify" do
test "redirects to login when not authenticated", %{conn: conn} do
conn = get(conn, ~p"/users/sudo-verify")
assert redirected_to(conn) == ~p"/users/log-in"
end
test "shows verification form for authenticated users", %{conn: conn, user: user} do
conn = conn |> log_in_user(user) |> get(~p"/users/sudo-verify")
response = html_response(conn, 200)
assert response =~ "Verify Your Identity"
assert response =~ "Enter the code from your authenticator app"
end
test "redirects to settings if already in sudo mode", %{conn: conn, user: user} do
{:ok, user} = Accounts.grant_sudo_mode(user)
conn = conn |> log_in_user(user) |> get(~p"/users/sudo-verify")
assert redirected_to(conn) == ~p"/users/settings"
end
test "redirects to return path if already in sudo mode", %{conn: conn, user: user} do
{:ok, user} = Accounts.grant_sudo_mode(user)
conn =
conn
|> log_in_user(user)
|> put_session(:user_return_to, "/some/path")
|> get(~p"/users/sudo-verify")
assert redirected_to(conn) == "/some/path"
end
test "shows error if user has no TOTP devices", %{conn: conn} do
user_no_totp = user_fixture(%{email: "nototp@example.com"})
conn = conn |> log_in_user(user_no_totp) |> get(~p"/users/sudo-verify")
assert redirected_to(conn) == ~p"/account/totp-enrollment"
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Two-factor authentication"
end
end
describe "POST /users/sudo-verify" do
test "redirects to login when not authenticated", %{conn: conn} do
conn = post(conn, ~p"/users/sudo-verify", %{"user" => %{"totp_code" => "123456"}})
assert redirected_to(conn) == ~p"/users/log-in"
end
test "grants sudo mode with valid TOTP code", %{conn: conn, user: user, device: device} do
code = NimbleTOTP.verification_code(device.secret)
conn =
conn
|> log_in_user(user)
|> post(~p"/users/sudo-verify", %{"user" => %{"totp_code" => code}})
assert redirected_to(conn) == ~p"/users/settings"
# Verify sudo mode was granted
updated_user = Accounts.get_user!(user.id)
assert Accounts.sudo_mode?(updated_user, -10)
end
test "redirects to return path after verification", %{conn: conn, user: user, device: device} do
code = NimbleTOTP.verification_code(device.secret)
conn =
conn
|> log_in_user(user)
|> put_session(:user_return_to, "/custom/path")
|> post(~p"/users/sudo-verify", %{"user" => %{"totp_code" => code}})
assert redirected_to(conn) == "/custom/path"
end
test "shows error with invalid TOTP code", %{conn: conn, user: user} do
conn =
conn
|> log_in_user(user)
|> post(~p"/users/sudo-verify", %{"user" => %{"totp_code" => "000000"}})
response = html_response(conn, 200)
assert response =~ "Invalid verification code"
end
test "rejects recovery codes with specific error", %{conn: conn, user: user} do
# Create a recovery code
{:ok, codes} = Accounts.generate_recovery_codes(user)
recovery_code = hd(codes)
conn =
conn
|> log_in_user(user)
|> post(~p"/users/sudo-verify", %{"user" => %{"totp_code" => recovery_code}})
response = html_response(conn, 200)
assert response =~ "Please use your authenticator app code"
assert response =~ "not a recovery code"
end
end
end
Step 2: Run test to verify it fails
Run: mix test test/towerops_web/controllers/user_sudo_controller_test.exs
Expected: FAIL with "no route found" or "module not found"
Step 3: Create UserSudoController
Create lib/towerops_web/controllers/user_sudo_controller.ex:
defmodule ToweropsWeb.UserSudoController do
@moduledoc """
Controller for sudo mode verification via TOTP.
Provides a lightweight re-authentication flow that only requires TOTP
instead of full email + password + TOTP login.
"""
use ToweropsWeb, :controller
alias Towerops.Accounts
plug :require_authenticated_user
@doc """
Shows the TOTP verification form for sudo mode.
If user is already in sudo mode, redirects to return path or settings.
If user has no TOTP devices, redirects to enrollment.
"""
def new(conn, _params) do
user = conn.assigns.current_scope.user
cond do
# Already in sudo mode, redirect to destination
Accounts.sudo_mode?(user, -10) ->
redirect_to_return_path(conn)
# No TOTP devices, must enroll first
!Accounts.totp_enabled?(user) ->
conn
|> put_flash(:error, "Two-factor authentication is required for this action.")
|> redirect(to: ~p"/account/totp-enrollment")
# Show verification form
true ->
form = Phoenix.Component.to_form(%{}, as: "user")
render(conn, :verify, form: form)
end
end
@doc """
Verifies the TOTP code and grants sudo mode.
On success, redirects to return path or settings.
On failure, re-renders form with error.
"""
def verify(conn, %{"user" => %{"totp_code" => code}}) do
user = conn.assigns.current_scope.user
case Accounts.verify_totp_only(user, code) do
{:ok, _user} ->
# Grant sudo mode
{:ok, _updated_user} = Accounts.grant_sudo_mode(user)
conn
|> put_flash(:info, "Identity verified successfully.")
|> redirect_to_return_path()
{:error, :recovery_code_not_allowed} ->
form = Phoenix.Component.to_form(%{"totp_code" => code}, as: "user")
conn
|> put_flash(:error, "Please use your authenticator app code, not a recovery code.")
|> render(:verify, form: form)
{:error, _reason} ->
form = Phoenix.Component.to_form(%{"totp_code" => code}, as: "user")
conn
|> put_flash(:error, "Invalid verification code. Please try again.")
|> render(:verify, form: form)
end
end
defp redirect_to_return_path(conn) do
return_path = get_session(conn, :user_return_to) || ~p"/users/settings"
conn = delete_session(conn, :user_return_to)
redirect(conn, to: return_path)
end
defp require_authenticated_user(conn, _opts) do
if conn.assigns.current_scope && conn.assigns.current_scope.user do
conn
else
conn
|> put_flash(:error, "You must log in to access this page.")
|> redirect(to: ~p"/users/log-in")
|> halt()
end
end
end
Step 4: Create UserSudoHTML module
Create lib/towerops_web/controllers/user_sudo_html.ex:
defmodule ToweropsWeb.UserSudoHTML do
@moduledoc """
HTML templates for sudo mode verification.
"""
use ToweropsWeb, :html
embed_templates "user_sudo_html/*"
end
Step 5: Create verify.html.heex template
Create lib/towerops_web/controllers/user_sudo_html/verify.html.heex:
<div class="mx-auto max-w-sm">
<.header class="text-center">
Verify Your Identity
<:subtitle>Enter the code from your authenticator app to continue</:subtitle>
</.header>
<.simple_form :let={f} for={@form} action={~p"/users/sudo-verify"} phx-update="ignore">
<.error :if={@flash["error"]}>
<%= @flash["error"] %>
</.error>
<.input
field={f[:totp_code]}
type="text"
label="Verification Code"
placeholder="000000"
required
autofocus
autocomplete="off"
inputmode="numeric"
pattern="[0-9]*"
maxlength="6"
/>
<:actions>
<.button phx-disable-with="Verifying..." class="w-full">
Verify
</.button>
</:actions>
</.simple_form>
<p class="text-center text-sm text-zinc-500 mt-4">
Need help? <.link href={~p"/users/settings"} class="text-brand hover:underline">Go back</.link>
</p>
</div>
Step 6: Add routes to router
In lib/towerops_web/router.ex, find the scope with pipe_through [:browser, :require_authenticated_user] and add:
get "/users/sudo-verify", UserSudoController, :new
post "/users/sudo-verify", UserSudoController, :verify
Step 7: Run test to verify it passes
Run: mix test test/towerops_web/controllers/user_sudo_controller_test.exs
Expected: PASS (10 tests)
Step 8: Commit
git add lib/towerops_web/controllers/user_sudo_controller.ex lib/towerops_web/controllers/user_sudo_html.ex lib/towerops_web/controllers/user_sudo_html/verify.html.heex lib/towerops_web/router.ex test/towerops_web/controllers/user_sudo_controller_test.exs
git commit -m "feat: add UserSudoController for TOTP-only verification"
Task 4: Update UserAuth plugs to redirect to sudo verify
Files:
- Modify:
lib/towerops_web/user_auth.ex - Modify:
test/towerops_web/user_auth_test.exs
Step 1: Write failing tests for updated behavior
In test/towerops_web/user_auth_test.exs, find the describe "require_sudo_mode/2" block and update it:
describe "require_sudo_mode/2" do
test "redirects to sudo verify when sudo mode expired and TOTP enabled", %{conn: conn} do
user = user_fixture()
# Create TOTP device
{:ok, _device} =
Accounts.create_user_totp_device(user, %{
name: "Test Device",
secret: NimbleTOTP.secret()
})
conn =
conn
|> log_in_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()
conn =
conn
|> log_in_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
|> log_in_user(user)
|> UserAuth.require_sudo_mode([])
refute conn.halted
end
end
Step 2: Run test to verify it fails
Run: mix test test/towerops_web/user_auth_test.exs::<line_number>
(Replace <line_number> with the line number of the describe block)
Expected: FAIL - tests expect old redirect behavior
Step 3: Update require_sudo_mode/2 plug
In lib/towerops_web/user_auth.ex, replace the require_sudo_mode/2 function:
@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
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
Step 4: Run test to verify it passes
Run: mix test test/towerops_web/user_auth_test.exs
Expected: PASS for require_sudo_mode tests
Step 5: Write failing tests for on_mount hook
In test/towerops_web/user_auth_test.exs, find or create the describe "on_mount: :require_sudo_mode" block:
describe "on_mount: :require_sudo_mode" do
test "redirects to sudo verify when sudo mode expired and TOTP enabled" do
user = user_fixture()
# Create TOTP device
{:ok, _device} =
Accounts.create_user_totp_device(user, %{
name: "Test Device",
secret: NimbleTOTP.secret()
})
socket = %Phoenix.LiveView.Socket{}
socket = Phoenix.Component.assign(socket, :current_scope, Scope.for_user(user))
assert {:halt, redirected_socket} =
UserAuth.on_mount(:require_sudo_mode, %{}, %{}, socket)
assert Phoenix.Flash.get(redirected_socket.assigns.flash, :error) =~ "verify your identity"
end
test "redirects to TOTP enrollment when sudo mode expired and no TOTP" do
user = user_fixture()
socket = %Phoenix.LiveView.Socket{}
socket = Phoenix.Component.assign(socket, :current_scope, Scope.for_user(user))
assert {:halt, redirected_socket} =
UserAuth.on_mount(:require_sudo_mode, %{}, %{}, socket)
assert Phoenix.Flash.get(redirected_socket.assigns.flash, :error) =~ "Two-factor authentication"
end
test "allows access when sudo mode is active" do
user = user_fixture()
{:ok, user} = Accounts.grant_sudo_mode(user)
socket = %Phoenix.LiveView.Socket{}
socket = Phoenix.Component.assign(socket, :current_scope, Scope.for_user(user))
assert {:cont, _socket} = UserAuth.on_mount(:require_sudo_mode, %{}, %{}, socket)
end
end
Step 6: Run test to verify it fails
Run: mix test test/towerops_web/user_auth_test.exs
Expected: FAIL for on_mount tests
Step 7: Update on_mount(:require_sudo_mode) hook
In lib/towerops_web/user_auth.ex, replace the on_mount(:require_sudo_mode) clause:
def on_mount(:require_sudo_mode, _params, _session, socket) do
user = socket.assigns.current_scope && socket.assigns.current_scope.user
case {user && Accounts.sudo_mode?(user, -10), user && Accounts.totp_enabled?(user)} do
{true, _} ->
{:cont, 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
Step 8: Run test to verify all tests pass
Run: mix test test/towerops_web/user_auth_test.exs
Expected: PASS for all UserAuth tests
Step 9: Commit
git add lib/towerops_web/user_auth.ex test/towerops_web/user_auth_test.exs
git commit -m "feat: update UserAuth to redirect to sudo verify page"
Task 5: Update UserSettingsLive tests
Files:
- Modify:
test/towerops_web/live/user_settings_live_test.exs
Step 1: Add helper to grant sudo mode in setup
In test/towerops_web/live/user_settings_live_test.exs, update the setup block:
setup %{conn: conn} do
user = user_fixture()
# Grant sudo mode so tests can access settings page
{:ok, user} = Accounts.grant_sudo_mode(user)
%{conn: log_in_user(conn, user), user: user}
end
Step 2: Add test for sudo mode redirect
Add a new describe block in the same file:
describe "sudo mode requirement" do
test "redirects to sudo verify when sudo mode expired", %{conn: conn} do
user = user_fixture()
# Don't grant sudo mode
{:error, {:redirect, %{to: path}}} =
live(log_in_user(conn, user), ~p"/users/settings")
assert path == "/users/sudo-verify"
end
test "allows access when sudo mode is active", %{conn: conn, user: user} do
# Sudo mode already granted in setup
{:ok, _view, html} = live(conn, ~p"/users/settings")
assert html =~ "Account Settings"
end
end
Step 3: Run tests to verify they pass
Run: mix test test/towerops_web/live/user_settings_live_test.exs
Expected: PASS for all UserSettingsLive tests
Step 4: Commit
git add test/towerops_web/live/user_settings_live_test.exs
git commit -m "test: update UserSettingsLive tests for sudo mode"
Task 6: Run full test suite and format
Step 1: Run all tests
Run: mix test
Expected: All tests pass
Step 2: Format code
Run: mix format
Expected: All files formatted
Step 3: Run precommit checks
Run: mix precommit
Expected: All checks pass (compile, format, test)
Step 4: Commit if any formatting changes
git add -u
git commit -m "chore: format code"
Task 7: Manual testing
Step 1: Start the server
Run: mix phx.server
Step 2: Test the flow
- Log in as a user with TOTP enabled
- Navigate to
/users/settings - Should redirect to
/users/sudo-verify(if sudo mode expired) - Enter valid TOTP code
- Should redirect back to
/users/settings - Verify you can access settings without re-verifying for 10 minutes
Step 3: Test error cases
- Try entering invalid TOTP code - should show error
- Try entering recovery code - should show specific error message
- Navigate to
/users/sudo-verifywhile already in sudo mode - should redirect to settings
Step 4: Document any issues found
If any issues are found, create new tasks to fix them.
Completion Checklist
- Task 1: Add Accounts.grant_sudo_mode/1 function
- Task 2: Add Accounts.verify_totp_only/2 function
- Task 3: Create UserSudoController and HTML module
- Task 4: Update UserAuth plugs to redirect to sudo verify
- Task 5: Update UserSettingsLive tests
- Task 6: Run full test suite and format
- Task 7: Manual testing
Success Criteria
✅ All tests pass ✅ Code formatted and precommit checks pass ✅ User can verify sudo mode with TOTP only (no password) ✅ Recovery codes are rejected with clear error message ✅ Return path redirection works correctly ✅ Edge cases handled gracefully