towerops/docs/plans/2026-02-01-sudo-mode-mfa-only.md
Graham McIntire 264154a3d8
feat: implement sudo mode MFA-only verification controller
- 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>
2026-02-01 14:34:17 -06:00

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

  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