From 303196bb8e842ae14af41f3710faec3dc7af6186 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 1 Feb 2026 13:50:34 -0600 Subject: [PATCH] Add design doc for sudo mode MFA-only verification --- .../2026-02-01-sudo-mode-mfa-only-design.md | 385 ++++++++++++++++++ 1 file changed, 385 insertions(+) create mode 100644 docs/plans/2026-02-01-sudo-mode-mfa-only-design.md diff --git a/docs/plans/2026-02-01-sudo-mode-mfa-only-design.md b/docs/plans/2026-02-01-sudo-mode-mfa-only-design.md new file mode 100644 index 00000000..0d0d38af --- /dev/null +++ b/docs/plans/2026-02-01-sudo-mode-mfa-only-design.md @@ -0,0 +1,385 @@ +# 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