# 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`: ```elixir 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: ```elixir @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: ```elixir @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** ```bash 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`: ```elixir 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: ```elixir @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** ```bash 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`: ```elixir 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`: ```elixir 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`: ```elixir 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`: ```heex
<.header class="text-center"> Verify Your Identity <:subtitle>Enter the code from your authenticator app to continue <.simple_form :let={f} for={@form} action={~p"/users/sudo-verify"} phx-update="ignore"> <.error :if={@flash["error"]}> <%= @flash["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

Need help? <.link href={~p"/users/settings"} class="text-brand hover:underline">Go back

``` **Step 6: Add routes to router** In `lib/towerops_web/router.ex`, find the scope with `pipe_through [:browser, :require_authenticated_user]` and add: ```elixir 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** ```bash 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: ```elixir 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::` (Replace `` 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: ```elixir @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: ```elixir 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: ```elixir 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** ```bash 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: ```elixir 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: ```elixir 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** ```bash 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** ```bash 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** 1. Log in as a user with TOTP enabled 2. Navigate to `/users/settings` 3. Should redirect to `/users/sudo-verify` (if sudo mode expired) 4. Enter valid TOTP code 5. Should redirect back to `/users/settings` 6. Verify you can access settings without re-verifying for 10 minutes **Step 3: Test error cases** 1. Try entering invalid TOTP code - should show error 2. Try entering recovery code - should show specific error message 3. Navigate to `/users/sudo-verify` while 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