Add design doc for sudo mode MFA-only verification

This commit is contained in:
Graham McIntire 2026-02-01 13:50:34 -06:00
parent b241d9df91
commit 303196bb8e
No known key found for this signature in database

View file

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