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