towerops/docs/plans/2026-02-01-sudo-mode-mfa-only-design.md

11 KiB

Sudo Mode MFA-Only Verification Design

Date: 2026-02-01 Status: Approved

Overview

Modify the sudo mode authentication flow to only require MFA (TOTP) verification instead of the full login flow. This provides a better user experience for accessing sensitive settings while maintaining security.

Current Behavior

When a user tries to access the settings page (/users/settings) without active sudo mode:

  1. :require_sudo_mode hook checks Accounts.sudo_mode?(user, -10)
  2. If expired → redirect to /users/log-in (full login page requiring email + password + TOTP)
  3. User must complete full authentication flow

New Behavior

When a user tries to access a sudo-protected page without active sudo mode:

  1. :require_sudo_mode hook checks Accounts.sudo_mode?(user, -10) and Accounts.totp_enabled?(user)
  2. If sudo expired + TOTP enabled → redirect to /users/sudo-verify
  3. User enters TOTP code only
  4. On success → grant sudo mode + redirect to original page
  5. On failure → show error, allow retry

Architecture

Flow Diagram

User accesses /users/settings (sudo-protected)
            ↓
    Is sudo mode active?
      ↓              ↓
     YES            NO
      ↓              ↓
   Allow      Has TOTP enabled?
   Access       ↓           ↓
              YES          NO
               ↓            ↓
         Redirect to   Show error
      /users/sudo-verify  (edge case)
               ↓
        Show TOTP form
               ↓
        User enters code
               ↓
         Valid TOTP?
       ↓            ↓
      YES          NO
       ↓            ↓
  Grant sudo   Show error
     mode       re-render
       ↓
   Redirect to
  original page

Key Design Decisions

  1. Dedicated verification page - Use /users/sudo-verify instead of modal or inline form
  2. Return path handling - Store original destination in session, redirect after verification
  3. TOTP only - Accept only TOTP codes, reject recovery codes for sudo verification
  4. Edge case handling - If user has no TOTP (shouldn't happen), show error and block access
  5. Reuse existing logic - Leverage Accounts.verify_user_mfa/2 for TOTP validation

Components

New Files

1. lib/towerops_web/controllers/user_sudo_controller.ex

  • new/2 - Shows TOTP verification form
    • Check if user already in sudo mode → redirect to return path
    • Check if user has TOTP → show error if not
    • Render verification form
  • verify/2 - Validates TOTP and grants sudo mode
    • Verify TOTP code (reject recovery codes)
    • Grant sudo mode on success
    • Redirect to return path or default to /users/settings
    • Show error on failure

2. lib/towerops_web/controllers/user_sudo_html.ex

  • HTML module for templates

3. lib/towerops_web/controllers/user_sudo_html/verify.html.heex

  • TOTP verification form template
  • Similar UI to existing user_session_html/totp.html.heex
  • Single input field for 6-digit code
  • Submit button
  • Error display

4. test/towerops_web/controllers/user_sudo_controller_test.exs

  • Comprehensive controller tests (see Testing section)

Files to Modify

1. lib/towerops_web/user_auth.ex

Update require_sudo_mode/2 plug:

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

Update on_mount(:require_sudo_mode) hook:

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

2. lib/towerops_web/router.ex

Add routes under :require_authenticated_user scope:

scope "/users", ToweropsWeb do
  pipe_through [:browser, :require_authenticated_user]

  get "/sudo-verify", UserSudoController, :new
  post "/sudo-verify", UserSudoController, :verify
end

3. lib/towerops/accounts.ex

Add new functions:

@doc """
Grants sudo mode to a user by updating their last_sudo_at timestamp.
"""
def grant_sudo_mode(%User{} = user) do
  now = DateTime.utc_now()

  user
  |> User.sudo_changeset(%{last_sudo_at: now})
  |> Repo.update()
end

@doc """
Verifies a TOTP code for a user, rejecting recovery codes.
Returns {:ok, user} on success or {:error, reason} on failure.
"""
def verify_totp_only(%User{} = user, code) when is_binary(code) do
  # Reject recovery codes (they are longer than TOTP codes)
  if String.length(code) > 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

Add changeset to lib/towerops/accounts/user.ex:

def sudo_changeset(user, attrs) do
  user
  |> cast(attrs, [:last_sudo_at])
  |> validate_required([:last_sudo_at])
end

Data Flow

Session Variables

  • user_return_to - Existing variable, stores original destination path
  • Return path stored via maybe_store_return_to/1 (already exists in user_auth.ex)
  • No new session variables needed

Sudo Verification Flow

  1. User accesses sudo-protected page (e.g., /users/settings)
  2. Return path stored in session via store_return_to_for_liveview/2 plug
  3. :require_sudo_mode hook redirects to /users/sudo-verify
  4. UserSudoController.new/2 renders TOTP form
  5. User submits TOTP code
  6. UserSudoController.verify/2 validates code
  7. On success: grant_sudo_mode/1 + redirect to user_return_to or /users/settings
  8. On failure: re-render form with error

Sudo Mode Granting

# Update user record with current timestamp
Accounts.grant_sudo_mode(user)
# Updates user.last_sudo_at to DateTime.utc_now()

# Subsequent checks pass
Accounts.sudo_mode?(user, -10)
# Returns true if last_sudo_at is within 10 minutes

Error Handling

Edge Cases

  1. User not authenticated

    • Handled by :require_authenticated_user pipeline
    • Redirects to /users/log-in
  2. User has no TOTP devices

    • Should never happen (TOTP is mandatory)
    • If it does: redirect to /account/totp-enrollment
    • Log warning for investigation
  3. Invalid TOTP code

    • Show error: "Invalid verification code"
    • Re-render form
    • Allow unlimited retries (rate limiting handled elsewhere)
  4. Recovery code submitted

    • verify_totp_only/2 rejects with :recovery_code_not_allowed
    • Show error: "Please use your authenticator app code, not a recovery code"
  5. No return path in session

    • Default to /users/settings
  6. User already in sudo mode

    • Redirect to return path or /users/settings
    • No need to re-verify
  7. Session expires during verification

    • Handled by :require_authenticated_user
    • Redirects to login

Security Considerations

  • TOTP verification uses constant-time comparison (existing in verify_user_mfa/2)
  • Audit log entry when sudo mode is granted (add to grant_sudo_mode/1)
  • Return path validation via existing valid_return_path?/1
  • CSRF protection via Phoenix form tokens

Testing Strategy

New Test File: test/towerops_web/controllers/user_sudo_controller_test.exs

defmodule ToweropsWeb.UserSudoControllerTest do
  use ToweropsWeb.ConnCase, async: true

  import Towerops.AccountsFixtures

  describe "GET /users/sudo-verify" do
    test "shows verification form for authenticated users"
    test "redirects unauthenticated users to login"
    test "redirects users already in sudo mode to return path"
    test "shows error if user has no TOTP devices"
  end

  describe "POST /users/sudo-verify" do
    test "grants sudo mode with valid TOTP code"
    test "shows error with invalid TOTP code"
    test "rejects recovery codes with specific error"
    test "redirects to return path from session after success"
    test "defaults to /users/settings if no return path"
    test "creates audit log entry on success"
  end
end

Updates to test/towerops_web/user_auth_test.exs

describe "require_sudo_mode/2" do
  test "redirects to /users/sudo-verify when sudo mode expired and TOTP enabled"
  test "redirects to /account/totp-enrollment when sudo mode expired and no TOTP"
  test "allows access when sudo mode is active"
end

describe "on_mount :require_sudo_mode" do
  test "redirects to /users/sudo-verify when sudo mode expired and TOTP enabled"
  test "redirects to /account/totp-enrollment when sudo mode expired and no TOTP"
  test "allows access when sudo mode is active"
end

Updates to test/towerops_web/live/user_settings_live_test.exs

# Add helper to grant sudo mode in setup
setup %{user: user} do
  Accounts.grant_sudo_mode(user)
  :ok
end

# Update tests that expect redirect behavior
test "redirects to sudo verify when sudo mode expired"

New Tests in test/towerops/accounts_test.exs

describe "grant_sudo_mode/1" do
  test "updates user's last_sudo_at timestamp"
  test "returns {:ok, user} on success"
end

describe "verify_totp_only/2" do
  test "accepts valid TOTP codes"
  test "rejects recovery codes with :recovery_code_not_allowed"
  test "rejects invalid codes"
end

Test Helpers

# In test/support/fixtures/accounts_fixtures.ex
def grant_sudo_mode(%User{} = user) do
  {:ok, user} = Accounts.grant_sudo_mode(user)
  user
end

Implementation Checklist

  • Create UserSudoController with new/2 and verify/2 actions
  • Create UserSudoHTML module and verify.html.heex template
  • Add grant_sudo_mode/1 to Accounts context
  • Add verify_totp_only/2 to Accounts context
  • Add sudo_changeset/2 to User schema
  • Update require_sudo_mode/2 plug in UserAuth
  • Update on_mount(:require_sudo_mode) hook in UserAuth
  • Add routes to router.ex
  • Write UserSudoControllerTest
  • Update UserAuthTest
  • Update UserSettingsLiveTest
  • Update AccountsTest
  • Add audit logging to grant_sudo_mode/1
  • Manual testing of complete flow

Success Criteria

  1. User accessing /users/settings without sudo mode is redirected to /users/sudo-verify
  2. User can verify identity with TOTP code only (no password required)
  3. Recovery codes are rejected with clear error message
  4. After successful verification, user is redirected to original destination
  5. Sudo mode timestamp is updated correctly
  6. All tests pass
  7. Edge cases are handled gracefully
  8. Audit logs record sudo mode grants