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:
:require_sudo_modehook checksAccounts.sudo_mode?(user, -10)- If expired → redirect to
/users/log-in(full login page requiring email + password + TOTP) - User must complete full authentication flow
New Behavior
When a user tries to access a sudo-protected page without active sudo mode:
:require_sudo_modehook checksAccounts.sudo_mode?(user, -10)andAccounts.totp_enabled?(user)- If sudo expired + TOTP enabled → redirect to
/users/sudo-verify - User enters TOTP code only
- On success → grant sudo mode + redirect to original page
- 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
- Dedicated verification page - Use
/users/sudo-verifyinstead of modal or inline form - Return path handling - Store original destination in session, redirect after verification
- TOTP only - Accept only TOTP codes, reject recovery codes for sudo verification
- Edge case handling - If user has no TOTP (shouldn't happen), show error and block access
- Reuse existing logic - Leverage
Accounts.verify_user_mfa/2for 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 inuser_auth.ex) - No new session variables needed
Sudo Verification Flow
- User accesses sudo-protected page (e.g.,
/users/settings) - Return path stored in session via
store_return_to_for_liveview/2plug :require_sudo_modehook redirects to/users/sudo-verifyUserSudoController.new/2renders TOTP form- User submits TOTP code
UserSudoController.verify/2validates code- On success:
grant_sudo_mode/1+ redirect touser_return_toor/users/settings - 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
-
User not authenticated
- Handled by
:require_authenticated_userpipeline - Redirects to
/users/log-in
- Handled by
-
User has no TOTP devices
- Should never happen (TOTP is mandatory)
- If it does: redirect to
/account/totp-enrollment - Log warning for investigation
-
Invalid TOTP code
- Show error: "Invalid verification code"
- Re-render form
- Allow unlimited retries (rate limiting handled elsewhere)
-
Recovery code submitted
verify_totp_only/2rejects with:recovery_code_not_allowed- Show error: "Please use your authenticator app code, not a recovery code"
-
No return path in session
- Default to
/users/settings
- Default to
-
User already in sudo mode
- Redirect to return path or
/users/settings - No need to re-verify
- Redirect to return path or
-
Session expires during verification
- Handled by
:require_authenticated_user - Redirects to login
- Handled by
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
UserSudoControllerwithnew/2andverify/2actions - Create
UserSudoHTMLmodule andverify.html.heextemplate - Add
grant_sudo_mode/1toAccountscontext - Add
verify_totp_only/2toAccountscontext - Add
sudo_changeset/2toUserschema - Update
require_sudo_mode/2plug inUserAuth - Update
on_mount(:require_sudo_mode)hook inUserAuth - 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
- User accessing
/users/settingswithout sudo mode is redirected to/users/sudo-verify - User can verify identity with TOTP code only (no password required)
- Recovery codes are rejected with clear error message
- After successful verification, user is redirected to original destination
- Sudo mode timestamp is updated correctly
- All tests pass
- Edge cases are handled gracefully
- Audit logs record sudo mode grants