From 264154a3d8287b610db84ab5a8e38e81e7707130 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 1 Feb 2026 14:34:17 -0600 Subject: [PATCH] feat: implement sudo mode MFA-only verification controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/plans/2026-02-01-sudo-mode-mfa-only.md | 898 ++++++++++++++++++ lib/towerops/accounts.ex | 5 +- .../controllers/user_sudo_controller.ex | 71 ++ .../controllers/user_sudo_html.ex | 7 + .../user_sudo_html/verify.html.heex | 47 + lib/towerops_web/router.ex | 8 + lib/towerops_web/user_auth.ex | 1 + .../controllers/user_sudo_controller_test.exs | 165 ++++ 8 files changed, 1200 insertions(+), 2 deletions(-) create mode 100644 docs/plans/2026-02-01-sudo-mode-mfa-only.md create mode 100644 lib/towerops_web/controllers/user_sudo_controller.ex create mode 100644 lib/towerops_web/controllers/user_sudo_html.ex create mode 100644 lib/towerops_web/controllers/user_sudo_html/verify.html.heex create mode 100644 test/towerops_web/controllers/user_sudo_controller_test.exs diff --git a/docs/plans/2026-02-01-sudo-mode-mfa-only.md b/docs/plans/2026-02-01-sudo-mode-mfa-only.md new file mode 100644 index 00000000..c0b34cf7 --- /dev/null +++ b/docs/plans/2026-02-01-sudo-mode-mfa-only.md @@ -0,0 +1,898 @@ +# 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 diff --git a/lib/towerops/accounts.ex b/lib/towerops/accounts.ex index 7263375e..c349091e 100644 --- a/lib/towerops/accounts.ex +++ b/lib/towerops/accounts.ex @@ -677,7 +677,7 @@ defmodule Towerops.Accounts do def grant_sudo_mode(%User{} = user) do now = DateTime.utc_now() - with {:ok, _updated_user} = result <- + with {:ok, updated_user} <- user |> User.sudo_changeset(%{last_sudo_at: now}) |> Repo.update() do @@ -688,7 +688,8 @@ defmodule Towerops.Accounts do metadata: %{granted_at: now} ) - result + # Set authenticated_at virtual field for sudo_mode? check + {:ok, %{updated_user | authenticated_at: now}} end end diff --git a/lib/towerops_web/controllers/user_sudo_controller.ex b/lib/towerops_web/controllers/user_sudo_controller.ex new file mode 100644 index 00000000..cf1a64a4 --- /dev/null +++ b/lib/towerops_web/controllers/user_sudo_controller.ex @@ -0,0 +1,71 @@ +defmodule ToweropsWeb.UserSudoController do + @moduledoc """ + Controller for sudo mode verification. + + Handles re-authentication for sensitive operations by requiring TOTP verification + (no recovery codes allowed). + """ + + use ToweropsWeb, :controller + + import ToweropsWeb.UserAuth, only: [require_authenticated_user: 2] + + alias Towerops.Accounts + + plug :require_authenticated_user + + def verify(conn, _params) do + # Render the sudo verification form + form = Phoenix.Component.to_form(%{}, as: "user") + render(conn, :verify, form: form) + end + + def create(conn, %{"user" => %{"totp_code" => totp_code}}) do + user = conn.assigns.current_scope.user + + case Accounts.verify_totp_only(user, totp_code) do + {:ok, _verified_user} -> + # Grant sudo mode + case Accounts.grant_sudo_mode(user) do + {:ok, _updated_user} -> + # Redirect to return_to path or default + return_to = get_session(conn, :user_return_to) || ~p"/orgs" + + conn + |> delete_session(:user_return_to) + |> redirect(to: return_to) + + {:error, _changeset} -> + # Shouldn't happen, but handle gracefully + form = Phoenix.Component.to_form(%{"totp_code" => totp_code}, as: "user") + + conn + |> put_flash(:error, "Failed to grant sudo mode. Please try again.") + |> render(:verify, form: form) + end + + {:error, :recovery_code_not_allowed} -> + form = Phoenix.Component.to_form(%{"totp_code" => totp_code}, as: "user") + + conn + |> put_flash( + :error, + "Recovery codes are not allowed for sudo mode verification. Please use your authenticator app." + ) + |> render(:verify, form: form) + + {:error, :invalid_code} -> + form = Phoenix.Component.to_form(%{"totp_code" => totp_code}, as: "user") + + conn + |> put_flash(:error, "Invalid authentication code. Please try again.") + |> render(:verify, form: form) + end + end + + def create(conn, _params) do + # Handle missing totp_code parameter + form = Phoenix.Component.to_form(%{}, as: "user") + render(conn, :verify, form: form) + end +end diff --git a/lib/towerops_web/controllers/user_sudo_html.ex b/lib/towerops_web/controllers/user_sudo_html.ex new file mode 100644 index 00000000..161ff7a6 --- /dev/null +++ b/lib/towerops_web/controllers/user_sudo_html.ex @@ -0,0 +1,7 @@ +defmodule ToweropsWeb.UserSudoHTML do + @moduledoc false + + use ToweropsWeb, :html + + embed_templates "user_sudo_html/*" +end diff --git a/lib/towerops_web/controllers/user_sudo_html/verify.html.heex b/lib/towerops_web/controllers/user_sudo_html/verify.html.heex new file mode 100644 index 00000000..2de6a728 --- /dev/null +++ b/lib/towerops_web/controllers/user_sudo_html/verify.html.heex @@ -0,0 +1,47 @@ + +
+
+ <.header> + Re-authenticate + <:subtitle> + Enter the 6-digit code from your authenticator app to continue + + +
+ +
+ <.form :let={f} for={@form} as={:user} id="sudo_verify_form" action={~p"/users/sudo/verify"}> + <.input + field={f[:totp_code]} + type="text" + label="Authentication Code" + placeholder="000000" + autocomplete="one-time-code" + inputmode="numeric" + required + phx-mounted={JS.focus()} + /> +
+

+ Enter the 6-digit code from your authenticator app. Recovery codes are not allowed for sensitive operations. +

+
+
+ <.button class="w-full" variant="primary"> + Verify + +
+ + +
+ <.link + href={~p"/users/log-out"} + method="delete" + class="text-sm font-semibold text-gray-600 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300" + > + ← Log out instead + +
+
+
+
diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index 1ebc63b5..d381ff7d 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -185,6 +185,14 @@ defmodule ToweropsWeb.Router do live "/users/reset-password/:token", UserResetPasswordLive, :edit end + # Sudo mode verification routes + scope "/", ToweropsWeb do + pipe_through [:browser, :require_authenticated_user] + + get "/users/sudo/verify", UserSudoController, :verify + post "/users/sudo/verify", UserSudoController, :create + end + ## Admin routes (superuser only) scope "/", ToweropsWeb do diff --git a/lib/towerops_web/user_auth.ex b/lib/towerops_web/user_auth.ex index fc709e3a..8777d6bb 100644 --- a/lib/towerops_web/user_auth.ex +++ b/lib/towerops_web/user_auth.ex @@ -372,6 +372,7 @@ defmodule ToweropsWeb.UserAuth do "/users/register", "/users/reset-password", "/users/confirm", + "/users/sudo", "/docs/api", "/privacy", "/terms", diff --git a/test/towerops_web/controllers/user_sudo_controller_test.exs b/test/towerops_web/controllers/user_sudo_controller_test.exs new file mode 100644 index 00000000..ff5e692d --- /dev/null +++ b/test/towerops_web/controllers/user_sudo_controller_test.exs @@ -0,0 +1,165 @@ +defmodule ToweropsWeb.UserSudoControllerTest do + use ToweropsWeb.ConnCase, async: true + + import Towerops.AccountsFixtures + + alias Towerops.Accounts + + setup do + # Create user with TOTP enabled (required for sudo mode) + user = user_fixture(enable_totp: true) + %{user: user} + end + + describe "GET /users/sudo/verify" do + test "renders sudo verification page for authenticated user", %{conn: conn, user: user} do + conn = + conn + |> log_in_user(user) + |> get(~p"/users/sudo/verify") + + response = html_response(conn, 200) + assert response =~ "Re-authenticate" + assert response =~ "Enter the 6-digit code from your authenticator app" + end + + test "redirects unauthenticated users to login", %{conn: conn} do + conn = get(conn, ~p"/users/sudo/verify") + assert redirected_to(conn) == ~p"/users/log-in" + assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You must log in to access this page." + end + + test "preserves return_to path from session", %{conn: conn, user: user} do + conn = + conn + |> log_in_user(user) + |> init_test_session(user_return_to: "/users/settings") + |> get(~p"/users/sudo/verify") + + assert html_response(conn, 200) =~ "Re-authenticate" + assert get_session(conn, :user_return_to) == "/users/settings" + end + end + + describe "POST /users/sudo/verify" do + test "grants sudo mode and redirects on valid TOTP code", %{conn: conn, user: user} do + # Get valid TOTP code for user + code = NimbleTOTP.verification_code(user.totp_secret) + + conn = + conn + |> log_in_user(user) + |> init_test_session(user_return_to: "/users/settings") + |> post(~p"/users/sudo/verify", %{"user" => %{"totp_code" => code}}) + + # Should redirect to return_to path (implies sudo mode was granted) + assert redirected_to(conn) == "/users/settings" + + # Should clear return_to from session + assert get_session(conn, :user_return_to) == nil + + # Should update last_sudo_at timestamp + updated_user = Accounts.get_user!(user.id) + assert updated_user.last_sudo_at + assert DateTime.diff(updated_user.last_sudo_at, DateTime.utc_now(), :second) <= 1 + end + + test "grants sudo mode and redirects to /orgs if no return_to", %{conn: conn, user: user} do + code = NimbleTOTP.verification_code(user.totp_secret) + + conn = + conn + |> log_in_user(user) + |> post(~p"/users/sudo/verify", %{"user" => %{"totp_code" => code}}) + + # Should redirect to /orgs (implies sudo mode was granted) + assert redirected_to(conn) == ~p"/orgs" + + # Should update last_sudo_at timestamp + updated_user = Accounts.get_user!(user.id) + assert updated_user.last_sudo_at + assert DateTime.diff(updated_user.last_sudo_at, DateTime.utc_now(), :second) <= 1 + end + + test "shows error on 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 authentication code" + assert response =~ "Re-authenticate" + + # Should not grant sudo mode + user = Accounts.get_user!(user.id) + refute Accounts.sudo_mode?(user) + end + + test "shows error when recovery code is attempted", %{conn: conn, user: user} do + conn = + conn + |> log_in_user(user) + |> post(~p"/users/sudo/verify", %{"user" => %{"totp_code" => "ABCD-EFGH"}}) + + response = html_response(conn, 200) + assert response =~ "Recovery codes are not allowed for sudo mode verification" + assert response =~ "Re-authenticate" + + # Should not grant sudo mode + user = Accounts.get_user!(user.id) + refute Accounts.sudo_mode?(user) + end + + test "shows error when TOTP code is too long", %{conn: conn, user: user} do + conn = + conn + |> log_in_user(user) + |> post(~p"/users/sudo/verify", %{"user" => %{"totp_code" => "1234567"}}) + + response = html_response(conn, 200) + assert response =~ "Recovery codes are not allowed for sudo mode verification" + assert response =~ "Re-authenticate" + end + + test "shows error when TOTP code contains letters", %{conn: conn, user: user} do + conn = + conn + |> log_in_user(user) + |> post(~p"/users/sudo/verify", %{"user" => %{"totp_code" => "12AB34"}}) + + response = html_response(conn, 200) + assert response =~ "Recovery codes are not allowed for sudo mode verification" + assert response =~ "Re-authenticate" + end + + test "redirects unauthenticated users to login", %{conn: conn} do + conn = post(conn, ~p"/users/sudo/verify", %{"user" => %{"totp_code" => "123456"}}) + assert redirected_to(conn) == ~p"/users/log-in" + assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You must log in to access this page." + end + + test "preserves return_to path on verification failure", %{conn: conn, user: user} do + conn = + conn + |> log_in_user(user) + |> init_test_session(user_return_to: "/users/settings") + |> post(~p"/users/sudo/verify", %{"user" => %{"totp_code" => "000000"}}) + + assert html_response(conn, 200) =~ "Invalid authentication code" + # return_to should be preserved for next attempt + assert get_session(conn, :user_return_to) == "/users/settings" + end + + test "handles missing TOTP code parameter", %{conn: conn, user: user} do + conn = + conn + |> log_in_user(user) + |> post(~p"/users/sudo/verify", %{"user" => %{}}) + + response = html_response(conn, 200) + assert response =~ "Re-authenticate" + # Should render form again (no crash) + end + end +end