# 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:
```elixir
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:
```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
```

**2. `lib/towerops_web/router.ex`**

Add routes under `:require_authenticated_user` scope:
```elixir
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:
```elixir
@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`:
```elixir
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

```elixir
# 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`

```elixir
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`

```elixir
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`

```elixir
# 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`

```elixir
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

```elixir
# 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
