Add design doc for sudo mode MFA-only verification
This commit is contained in:
parent
b241d9df91
commit
303196bb8e
1 changed files with 385 additions and 0 deletions
385
docs/plans/2026-02-01-sudo-mode-mfa-only-design.md
Normal file
385
docs/plans/2026-02-01-sudo-mode-mfa-only-design.md
Normal 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
|
||||
Loading…
Add table
Reference in a new issue