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>
This commit is contained in:
Graham McIntire 2026-02-01 14:34:17 -06:00
parent d7234e02a3
commit 264154a3d8
No known key found for this signature in database
8 changed files with 1200 additions and 2 deletions

View file

@ -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
<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:
```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::<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:
```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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,7 @@
defmodule ToweropsWeb.UserSudoHTML do
@moduledoc false
use ToweropsWeb, :html
embed_templates "user_sudo_html/*"
end

View file

@ -0,0 +1,47 @@
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="mx-auto max-w-sm">
<div class="text-center">
<.header>
Re-authenticate
<:subtitle>
Enter the 6-digit code from your authenticator app to continue
</:subtitle>
</.header>
</div>
<div class="mt-8">
<.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()}
/>
<div class="mt-2">
<p class="text-sm text-gray-500 dark:text-gray-400">
Enter the 6-digit code from your authenticator app. Recovery codes are not allowed for sensitive operations.
</p>
</div>
<div class="mt-6">
<.button class="w-full" variant="primary">
Verify <span aria-hidden="true">→</span>
</.button>
</div>
</.form>
<div class="mt-6 text-center">
<.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
</.link>
</div>
</div>
</div>
</Layouts.app>

View file

@ -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

View file

@ -372,6 +372,7 @@ defmodule ToweropsWeb.UserAuth do
"/users/register",
"/users/reset-password",
"/users/confirm",
"/users/sudo",
"/docs/api",
"/privacy",
"/terms",

View file

@ -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