feat: implement sudo mode MFA-only verification controller
- Add UserSudoController with GET and POST /users/sudo/verify routes - Create verify.html.heex template for TOTP verification form - Only accept TOTP codes (6 numeric digits), reject recovery codes - Update grant_sudo_mode to set authenticated_at virtual field - Exclude /users/sudo paths from return_to overwriting - Add comprehensive controller tests (12 test cases) - Verify redirect behavior, error handling, and session management 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
d7234e02a3
commit
264154a3d8
8 changed files with 1200 additions and 2 deletions
898
docs/plans/2026-02-01-sudo-mode-mfa-only.md
Normal file
898
docs/plans/2026-02-01-sudo-mode-mfa-only.md
Normal file
|
|
@ -0,0 +1,898 @@
|
|||
# Sudo Mode MFA-Only Verification Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Replace full login requirement for sudo mode with TOTP-only verification at dedicated `/users/sudo-verify` endpoint.
|
||||
|
||||
**Architecture:** Add new controller for sudo verification, modify UserAuth plugs to redirect to TOTP-only page instead of full login, add Accounts functions for granting sudo mode and verifying TOTP without recovery codes.
|
||||
|
||||
**Tech Stack:** Phoenix 1.8, Ecto, Phoenix.Controller, Plug, ExUnit
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add Accounts.grant_sudo_mode/1 function
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/towerops/accounts/user.ex`
|
||||
- Modify: `lib/towerops/accounts.ex`
|
||||
- Create: `test/towerops/accounts/grant_sudo_mode_test.exs`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Create `test/towerops/accounts/grant_sudo_mode_test.exs`:
|
||||
|
||||
```elixir
|
||||
defmodule Towerops.Accounts.GrantSudoModeTest do
|
||||
use Towerops.DataCase, async: true
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
|
||||
alias Towerops.Accounts
|
||||
|
||||
describe "grant_sudo_mode/1" do
|
||||
test "updates user's last_sudo_at timestamp" do
|
||||
user = user_fixture()
|
||||
assert is_nil(user.last_sudo_at)
|
||||
|
||||
{:ok, updated_user} = Accounts.grant_sudo_mode(user)
|
||||
|
||||
assert %DateTime{} = updated_user.last_sudo_at
|
||||
assert DateTime.diff(DateTime.utc_now(), updated_user.last_sudo_at, :second) < 2
|
||||
end
|
||||
|
||||
test "returns error if user is invalid" do
|
||||
user = user_fixture()
|
||||
# Pass invalid user struct
|
||||
assert {:error, _changeset} = Accounts.grant_sudo_mode(%{user | id: nil})
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `mix test test/towerops/accounts/grant_sudo_mode_test.exs`
|
||||
|
||||
Expected: FAIL with "function Accounts.grant_sudo_mode/1 is undefined"
|
||||
|
||||
**Step 3: Add sudo_changeset to User schema**
|
||||
|
||||
In `lib/towerops/accounts/user.ex`, add after the existing changesets:
|
||||
|
||||
```elixir
|
||||
@doc """
|
||||
Changeset for updating sudo mode timestamp.
|
||||
"""
|
||||
def sudo_changeset(user, attrs) do
|
||||
user
|
||||
|> cast(attrs, [:last_sudo_at])
|
||||
|> validate_required([:last_sudo_at])
|
||||
end
|
||||
```
|
||||
|
||||
**Step 4: Add grant_sudo_mode/1 to Accounts context**
|
||||
|
||||
In `lib/towerops/accounts.ex`, add after the `sudo_mode?/2` function:
|
||||
|
||||
```elixir
|
||||
@doc """
|
||||
Grants sudo mode to a user by updating their last_sudo_at timestamp.
|
||||
|
||||
Returns `{:ok, user}` on success or `{:error, changeset}` on failure.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> grant_sudo_mode(user)
|
||||
{:ok, %User{last_sudo_at: ~U[2026-02-01 12:00:00Z]}}
|
||||
"""
|
||||
def grant_sudo_mode(%User{} = user) do
|
||||
now = DateTime.utc_now()
|
||||
|
||||
user
|
||||
|> User.sudo_changeset(%{last_sudo_at: now})
|
||||
|> Repo.update()
|
||||
end
|
||||
```
|
||||
|
||||
**Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `mix test test/towerops/accounts/grant_sudo_mode_test.exs`
|
||||
|
||||
Expected: PASS (2 tests)
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add lib/towerops/accounts/user.ex lib/towerops/accounts.ex test/towerops/accounts/grant_sudo_mode_test.exs
|
||||
git commit -m "feat: add Accounts.grant_sudo_mode/1 function"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add Accounts.verify_totp_only/2 function
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/towerops/accounts.ex`
|
||||
- Create: `test/towerops/accounts/verify_totp_only_test.exs`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Create `test/towerops/accounts/verify_totp_only_test.exs`:
|
||||
|
||||
```elixir
|
||||
defmodule Towerops.Accounts.VerifyTotpOnlyTest do
|
||||
use Towerops.DataCase, async: true
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
|
||||
alias Towerops.Accounts
|
||||
|
||||
describe "verify_totp_only/2" do
|
||||
setup do
|
||||
user = user_fixture()
|
||||
|
||||
# Create TOTP device
|
||||
{:ok, device} =
|
||||
Accounts.create_user_totp_device(user, %{
|
||||
name: "Test Device",
|
||||
secret: NimbleTOTP.secret()
|
||||
})
|
||||
|
||||
%{user: user, device: device}
|
||||
end
|
||||
|
||||
test "accepts valid TOTP codes", %{user: user, device: device} do
|
||||
code = NimbleTOTP.verification_code(device.secret)
|
||||
|
||||
assert {:ok, returned_user} = Accounts.verify_totp_only(user, code)
|
||||
assert returned_user.id == user.id
|
||||
end
|
||||
|
||||
test "rejects invalid TOTP codes", %{user: user} do
|
||||
assert {:error, :invalid_code} = Accounts.verify_totp_only(user, "000000")
|
||||
end
|
||||
|
||||
test "rejects recovery codes", %{user: user} do
|
||||
# Recovery codes are longer than 6 digits
|
||||
recovery_code = "ABCD-EFGH-IJKL"
|
||||
|
||||
assert {:error, :recovery_code_not_allowed} =
|
||||
Accounts.verify_totp_only(user, recovery_code)
|
||||
end
|
||||
|
||||
test "rejects codes longer than 6 digits", %{user: user} do
|
||||
assert {:error, :recovery_code_not_allowed} =
|
||||
Accounts.verify_totp_only(user, "1234567890")
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `mix test test/towerops/accounts/verify_totp_only_test.exs`
|
||||
|
||||
Expected: FAIL with "function Accounts.verify_totp_only/2 is undefined"
|
||||
|
||||
**Step 3: Add verify_totp_only/2 to Accounts context**
|
||||
|
||||
In `lib/towerops/accounts.ex`, add after the `verify_user_mfa/2` function:
|
||||
|
||||
```elixir
|
||||
@doc """
|
||||
Verifies a TOTP code for a user, rejecting recovery codes.
|
||||
|
||||
This is used for sudo mode verification where we want to ensure
|
||||
the user has access to their authenticator device, not just a recovery code.
|
||||
|
||||
Returns `{:ok, user}` on success with valid TOTP code.
|
||||
Returns `{:error, :recovery_code_not_allowed}` if a recovery code is provided.
|
||||
Returns `{:error, reason}` for invalid TOTP codes.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> verify_totp_only(user, "123456")
|
||||
{:ok, %User{}}
|
||||
|
||||
iex> verify_totp_only(user, "ABCD-EFGH-IJKL")
|
||||
{:error, :recovery_code_not_allowed}
|
||||
|
||||
iex> verify_totp_only(user, "000000")
|
||||
{:error, :invalid_code}
|
||||
"""
|
||||
def verify_totp_only(%User{} = user, code) when is_binary(code) do
|
||||
# Reject codes longer than 6 digits (likely recovery codes)
|
||||
if String.length(String.replace(code, ~r/[^0-9]/, "")) > 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
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `mix test test/towerops/accounts/verify_totp_only_test.exs`
|
||||
|
||||
Expected: PASS (4 tests)
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add lib/towerops/accounts.ex test/towerops/accounts/verify_totp_only_test.exs
|
||||
git commit -m "feat: add Accounts.verify_totp_only/2 function"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Create UserSudoController and HTML module
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/towerops_web/controllers/user_sudo_controller.ex`
|
||||
- Create: `lib/towerops_web/controllers/user_sudo_html.ex`
|
||||
- Create: `lib/towerops_web/controllers/user_sudo_html/verify.html.heex`
|
||||
- Create: `test/towerops_web/controllers/user_sudo_controller_test.exs`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Create `test/towerops_web/controllers/user_sudo_controller_test.exs`:
|
||||
|
||||
```elixir
|
||||
defmodule ToweropsWeb.UserSudoControllerTest do
|
||||
use ToweropsWeb.ConnCase, async: true
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
|
||||
alias Towerops.Accounts
|
||||
|
||||
setup do
|
||||
user = user_fixture()
|
||||
|
||||
# Create TOTP device
|
||||
{:ok, device} =
|
||||
Accounts.create_user_totp_device(user, %{
|
||||
name: "Test Device",
|
||||
secret: NimbleTOTP.secret()
|
||||
})
|
||||
|
||||
%{user: user, device: device}
|
||||
end
|
||||
|
||||
describe "GET /users/sudo-verify" do
|
||||
test "redirects to login when not authenticated", %{conn: conn} do
|
||||
conn = get(conn, ~p"/users/sudo-verify")
|
||||
assert redirected_to(conn) == ~p"/users/log-in"
|
||||
end
|
||||
|
||||
test "shows verification form for authenticated users", %{conn: conn, user: user} do
|
||||
conn = conn |> log_in_user(user) |> get(~p"/users/sudo-verify")
|
||||
|
||||
response = html_response(conn, 200)
|
||||
assert response =~ "Verify Your Identity"
|
||||
assert response =~ "Enter the code from your authenticator app"
|
||||
end
|
||||
|
||||
test "redirects to settings if already in sudo mode", %{conn: conn, user: user} do
|
||||
{:ok, user} = Accounts.grant_sudo_mode(user)
|
||||
|
||||
conn = conn |> log_in_user(user) |> get(~p"/users/sudo-verify")
|
||||
|
||||
assert redirected_to(conn) == ~p"/users/settings"
|
||||
end
|
||||
|
||||
test "redirects to return path if already in sudo mode", %{conn: conn, user: user} do
|
||||
{:ok, user} = Accounts.grant_sudo_mode(user)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> log_in_user(user)
|
||||
|> put_session(:user_return_to, "/some/path")
|
||||
|> get(~p"/users/sudo-verify")
|
||||
|
||||
assert redirected_to(conn) == "/some/path"
|
||||
end
|
||||
|
||||
test "shows error if user has no TOTP devices", %{conn: conn} do
|
||||
user_no_totp = user_fixture(%{email: "nototp@example.com"})
|
||||
|
||||
conn = conn |> log_in_user(user_no_totp) |> get(~p"/users/sudo-verify")
|
||||
|
||||
assert redirected_to(conn) == ~p"/account/totp-enrollment"
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Two-factor authentication"
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST /users/sudo-verify" do
|
||||
test "redirects to login when not authenticated", %{conn: conn} do
|
||||
conn = post(conn, ~p"/users/sudo-verify", %{"user" => %{"totp_code" => "123456"}})
|
||||
assert redirected_to(conn) == ~p"/users/log-in"
|
||||
end
|
||||
|
||||
test "grants sudo mode with valid TOTP code", %{conn: conn, user: user, device: device} do
|
||||
code = NimbleTOTP.verification_code(device.secret)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> log_in_user(user)
|
||||
|> post(~p"/users/sudo-verify", %{"user" => %{"totp_code" => code}})
|
||||
|
||||
assert redirected_to(conn) == ~p"/users/settings"
|
||||
|
||||
# Verify sudo mode was granted
|
||||
updated_user = Accounts.get_user!(user.id)
|
||||
assert Accounts.sudo_mode?(updated_user, -10)
|
||||
end
|
||||
|
||||
test "redirects to return path after verification", %{conn: conn, user: user, device: device} do
|
||||
code = NimbleTOTP.verification_code(device.secret)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> log_in_user(user)
|
||||
|> put_session(:user_return_to, "/custom/path")
|
||||
|> post(~p"/users/sudo-verify", %{"user" => %{"totp_code" => code}})
|
||||
|
||||
assert redirected_to(conn) == "/custom/path"
|
||||
end
|
||||
|
||||
test "shows error with invalid TOTP code", %{conn: conn, user: user} do
|
||||
conn =
|
||||
conn
|
||||
|> log_in_user(user)
|
||||
|> post(~p"/users/sudo-verify", %{"user" => %{"totp_code" => "000000"}})
|
||||
|
||||
response = html_response(conn, 200)
|
||||
assert response =~ "Invalid verification code"
|
||||
end
|
||||
|
||||
test "rejects recovery codes with specific error", %{conn: conn, user: user} do
|
||||
# Create a recovery code
|
||||
{:ok, codes} = Accounts.generate_recovery_codes(user)
|
||||
recovery_code = hd(codes)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> log_in_user(user)
|
||||
|> post(~p"/users/sudo-verify", %{"user" => %{"totp_code" => recovery_code}})
|
||||
|
||||
response = html_response(conn, 200)
|
||||
assert response =~ "Please use your authenticator app code"
|
||||
assert response =~ "not a recovery code"
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `mix test test/towerops_web/controllers/user_sudo_controller_test.exs`
|
||||
|
||||
Expected: FAIL with "no route found" or "module not found"
|
||||
|
||||
**Step 3: Create UserSudoController**
|
||||
|
||||
Create `lib/towerops_web/controllers/user_sudo_controller.ex`:
|
||||
|
||||
```elixir
|
||||
defmodule ToweropsWeb.UserSudoController do
|
||||
@moduledoc """
|
||||
Controller for sudo mode verification via TOTP.
|
||||
|
||||
Provides a lightweight re-authentication flow that only requires TOTP
|
||||
instead of full email + password + TOTP login.
|
||||
"""
|
||||
use ToweropsWeb, :controller
|
||||
|
||||
alias Towerops.Accounts
|
||||
|
||||
plug :require_authenticated_user
|
||||
|
||||
@doc """
|
||||
Shows the TOTP verification form for sudo mode.
|
||||
|
||||
If user is already in sudo mode, redirects to return path or settings.
|
||||
If user has no TOTP devices, redirects to enrollment.
|
||||
"""
|
||||
def new(conn, _params) do
|
||||
user = conn.assigns.current_scope.user
|
||||
|
||||
cond do
|
||||
# Already in sudo mode, redirect to destination
|
||||
Accounts.sudo_mode?(user, -10) ->
|
||||
redirect_to_return_path(conn)
|
||||
|
||||
# No TOTP devices, must enroll first
|
||||
!Accounts.totp_enabled?(user) ->
|
||||
conn
|
||||
|> put_flash(:error, "Two-factor authentication is required for this action.")
|
||||
|> redirect(to: ~p"/account/totp-enrollment")
|
||||
|
||||
# Show verification form
|
||||
true ->
|
||||
form = Phoenix.Component.to_form(%{}, as: "user")
|
||||
render(conn, :verify, form: form)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Verifies the TOTP code and grants sudo mode.
|
||||
|
||||
On success, redirects to return path or settings.
|
||||
On failure, re-renders form with error.
|
||||
"""
|
||||
def verify(conn, %{"user" => %{"totp_code" => code}}) do
|
||||
user = conn.assigns.current_scope.user
|
||||
|
||||
case Accounts.verify_totp_only(user, code) do
|
||||
{:ok, _user} ->
|
||||
# Grant sudo mode
|
||||
{:ok, _updated_user} = Accounts.grant_sudo_mode(user)
|
||||
|
||||
conn
|
||||
|> put_flash(:info, "Identity verified successfully.")
|
||||
|> redirect_to_return_path()
|
||||
|
||||
{:error, :recovery_code_not_allowed} ->
|
||||
form = Phoenix.Component.to_form(%{"totp_code" => code}, as: "user")
|
||||
|
||||
conn
|
||||
|> put_flash(:error, "Please use your authenticator app code, not a recovery code.")
|
||||
|> render(:verify, form: form)
|
||||
|
||||
{:error, _reason} ->
|
||||
form = Phoenix.Component.to_form(%{"totp_code" => code}, as: "user")
|
||||
|
||||
conn
|
||||
|> put_flash(:error, "Invalid verification code. Please try again.")
|
||||
|> render(:verify, form: form)
|
||||
end
|
||||
end
|
||||
|
||||
defp redirect_to_return_path(conn) do
|
||||
return_path = get_session(conn, :user_return_to) || ~p"/users/settings"
|
||||
conn = delete_session(conn, :user_return_to)
|
||||
redirect(conn, to: return_path)
|
||||
end
|
||||
|
||||
defp require_authenticated_user(conn, _opts) do
|
||||
if conn.assigns.current_scope && conn.assigns.current_scope.user do
|
||||
conn
|
||||
else
|
||||
conn
|
||||
|> put_flash(:error, "You must log in to access this page.")
|
||||
|> redirect(to: ~p"/users/log-in")
|
||||
|> halt()
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Step 4: Create UserSudoHTML module**
|
||||
|
||||
Create `lib/towerops_web/controllers/user_sudo_html.ex`:
|
||||
|
||||
```elixir
|
||||
defmodule ToweropsWeb.UserSudoHTML do
|
||||
@moduledoc """
|
||||
HTML templates for sudo mode verification.
|
||||
"""
|
||||
use ToweropsWeb, :html
|
||||
|
||||
embed_templates "user_sudo_html/*"
|
||||
end
|
||||
```
|
||||
|
||||
**Step 5: Create verify.html.heex template**
|
||||
|
||||
Create `lib/towerops_web/controllers/user_sudo_html/verify.html.heex`:
|
||||
|
||||
```heex
|
||||
<div class="mx-auto max-w-sm">
|
||||
<.header class="text-center">
|
||||
Verify Your Identity
|
||||
<:subtitle>Enter the code from your authenticator app to continue</:subtitle>
|
||||
</.header>
|
||||
|
||||
<.simple_form :let={f} for={@form} action={~p"/users/sudo-verify"} phx-update="ignore">
|
||||
<.error :if={@flash["error"]}>
|
||||
<%= @flash["error"] %>
|
||||
</.error>
|
||||
|
||||
<.input
|
||||
field={f[:totp_code]}
|
||||
type="text"
|
||||
label="Verification Code"
|
||||
placeholder="000000"
|
||||
required
|
||||
autofocus
|
||||
autocomplete="off"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxlength="6"
|
||||
/>
|
||||
|
||||
<:actions>
|
||||
<.button phx-disable-with="Verifying..." class="w-full">
|
||||
Verify
|
||||
</.button>
|
||||
</:actions>
|
||||
</.simple_form>
|
||||
|
||||
<p class="text-center text-sm text-zinc-500 mt-4">
|
||||
Need help? <.link href={~p"/users/settings"} class="text-brand hover:underline">Go back</.link>
|
||||
</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Step 6: Add routes to router**
|
||||
|
||||
In `lib/towerops_web/router.ex`, find the scope with `pipe_through [:browser, :require_authenticated_user]` and add:
|
||||
|
||||
```elixir
|
||||
get "/users/sudo-verify", UserSudoController, :new
|
||||
post "/users/sudo-verify", UserSudoController, :verify
|
||||
```
|
||||
|
||||
**Step 7: Run test to verify it passes**
|
||||
|
||||
Run: `mix test test/towerops_web/controllers/user_sudo_controller_test.exs`
|
||||
|
||||
Expected: PASS (10 tests)
|
||||
|
||||
**Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add lib/towerops_web/controllers/user_sudo_controller.ex lib/towerops_web/controllers/user_sudo_html.ex lib/towerops_web/controllers/user_sudo_html/verify.html.heex lib/towerops_web/router.ex test/towerops_web/controllers/user_sudo_controller_test.exs
|
||||
git commit -m "feat: add UserSudoController for TOTP-only verification"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Update UserAuth plugs to redirect to sudo verify
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/towerops_web/user_auth.ex`
|
||||
- Modify: `test/towerops_web/user_auth_test.exs`
|
||||
|
||||
**Step 1: Write failing tests for updated behavior**
|
||||
|
||||
In `test/towerops_web/user_auth_test.exs`, find the `describe "require_sudo_mode/2"` block and update it:
|
||||
|
||||
```elixir
|
||||
describe "require_sudo_mode/2" do
|
||||
test "redirects to sudo verify when sudo mode expired and TOTP enabled", %{conn: conn} do
|
||||
user = user_fixture()
|
||||
|
||||
# Create TOTP device
|
||||
{:ok, _device} =
|
||||
Accounts.create_user_totp_device(user, %{
|
||||
name: "Test Device",
|
||||
secret: NimbleTOTP.secret()
|
||||
})
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> log_in_user(user)
|
||||
|> UserAuth.require_sudo_mode([])
|
||||
|
||||
assert redirected_to(conn) == ~p"/users/sudo-verify"
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "verify your identity"
|
||||
end
|
||||
|
||||
test "redirects to TOTP enrollment when sudo mode expired and no TOTP", %{conn: conn} do
|
||||
user = user_fixture()
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> log_in_user(user)
|
||||
|> UserAuth.require_sudo_mode([])
|
||||
|
||||
assert redirected_to(conn) == ~p"/account/totp-enrollment"
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Two-factor authentication"
|
||||
end
|
||||
|
||||
test "allows access when sudo mode is active", %{conn: conn} do
|
||||
user = user_fixture()
|
||||
{:ok, user} = Accounts.grant_sudo_mode(user)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> log_in_user(user)
|
||||
|> UserAuth.require_sudo_mode([])
|
||||
|
||||
refute conn.halted
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `mix test test/towerops_web/user_auth_test.exs::<line_number>`
|
||||
|
||||
(Replace `<line_number>` with the line number of the describe block)
|
||||
|
||||
Expected: FAIL - tests expect old redirect behavior
|
||||
|
||||
**Step 3: Update require_sudo_mode/2 plug**
|
||||
|
||||
In `lib/towerops_web/user_auth.ex`, replace the `require_sudo_mode/2` function:
|
||||
|
||||
```elixir
|
||||
@doc """
|
||||
Plug for routes that require sudo mode.
|
||||
|
||||
Checks if the user's sudo mode is active (within 10 minutes).
|
||||
If TOTP is enabled, redirects to TOTP-only verification page.
|
||||
If TOTP is not enabled, redirects to enrollment.
|
||||
"""
|
||||
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
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `mix test test/towerops_web/user_auth_test.exs`
|
||||
|
||||
Expected: PASS for require_sudo_mode tests
|
||||
|
||||
**Step 5: Write failing tests for on_mount hook**
|
||||
|
||||
In `test/towerops_web/user_auth_test.exs`, find or create the `describe "on_mount: :require_sudo_mode"` block:
|
||||
|
||||
```elixir
|
||||
describe "on_mount: :require_sudo_mode" do
|
||||
test "redirects to sudo verify when sudo mode expired and TOTP enabled" do
|
||||
user = user_fixture()
|
||||
|
||||
# Create TOTP device
|
||||
{:ok, _device} =
|
||||
Accounts.create_user_totp_device(user, %{
|
||||
name: "Test Device",
|
||||
secret: NimbleTOTP.secret()
|
||||
})
|
||||
|
||||
socket = %Phoenix.LiveView.Socket{}
|
||||
socket = Phoenix.Component.assign(socket, :current_scope, Scope.for_user(user))
|
||||
|
||||
assert {:halt, redirected_socket} =
|
||||
UserAuth.on_mount(:require_sudo_mode, %{}, %{}, socket)
|
||||
|
||||
assert Phoenix.Flash.get(redirected_socket.assigns.flash, :error) =~ "verify your identity"
|
||||
end
|
||||
|
||||
test "redirects to TOTP enrollment when sudo mode expired and no TOTP" do
|
||||
user = user_fixture()
|
||||
|
||||
socket = %Phoenix.LiveView.Socket{}
|
||||
socket = Phoenix.Component.assign(socket, :current_scope, Scope.for_user(user))
|
||||
|
||||
assert {:halt, redirected_socket} =
|
||||
UserAuth.on_mount(:require_sudo_mode, %{}, %{}, socket)
|
||||
|
||||
assert Phoenix.Flash.get(redirected_socket.assigns.flash, :error) =~ "Two-factor authentication"
|
||||
end
|
||||
|
||||
test "allows access when sudo mode is active" do
|
||||
user = user_fixture()
|
||||
{:ok, user} = Accounts.grant_sudo_mode(user)
|
||||
|
||||
socket = %Phoenix.LiveView.Socket{}
|
||||
socket = Phoenix.Component.assign(socket, :current_scope, Scope.for_user(user))
|
||||
|
||||
assert {:cont, _socket} = UserAuth.on_mount(:require_sudo_mode, %{}, %{}, socket)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Step 6: Run test to verify it fails**
|
||||
|
||||
Run: `mix test test/towerops_web/user_auth_test.exs`
|
||||
|
||||
Expected: FAIL for on_mount tests
|
||||
|
||||
**Step 7: Update on_mount(:require_sudo_mode) hook**
|
||||
|
||||
In `lib/towerops_web/user_auth.ex`, replace the `on_mount(:require_sudo_mode)` clause:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
**Step 8: Run test to verify all tests pass**
|
||||
|
||||
Run: `mix test test/towerops_web/user_auth_test.exs`
|
||||
|
||||
Expected: PASS for all UserAuth tests
|
||||
|
||||
**Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add lib/towerops_web/user_auth.ex test/towerops_web/user_auth_test.exs
|
||||
git commit -m "feat: update UserAuth to redirect to sudo verify page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Update UserSettingsLive tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `test/towerops_web/live/user_settings_live_test.exs`
|
||||
|
||||
**Step 1: Add helper to grant sudo mode in setup**
|
||||
|
||||
In `test/towerops_web/live/user_settings_live_test.exs`, update the setup block:
|
||||
|
||||
```elixir
|
||||
setup %{conn: conn} do
|
||||
user = user_fixture()
|
||||
|
||||
# Grant sudo mode so tests can access settings page
|
||||
{:ok, user} = Accounts.grant_sudo_mode(user)
|
||||
|
||||
%{conn: log_in_user(conn, user), user: user}
|
||||
end
|
||||
```
|
||||
|
||||
**Step 2: Add test for sudo mode redirect**
|
||||
|
||||
Add a new describe block in the same file:
|
||||
|
||||
```elixir
|
||||
describe "sudo mode requirement" do
|
||||
test "redirects to sudo verify when sudo mode expired", %{conn: conn} do
|
||||
user = user_fixture()
|
||||
# Don't grant sudo mode
|
||||
|
||||
{:error, {:redirect, %{to: path}}} =
|
||||
live(log_in_user(conn, user), ~p"/users/settings")
|
||||
|
||||
assert path == "/users/sudo-verify"
|
||||
end
|
||||
|
||||
test "allows access when sudo mode is active", %{conn: conn, user: user} do
|
||||
# Sudo mode already granted in setup
|
||||
{:ok, _view, html} = live(conn, ~p"/users/settings")
|
||||
|
||||
assert html =~ "Account Settings"
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Step 3: Run tests to verify they pass**
|
||||
|
||||
Run: `mix test test/towerops_web/live/user_settings_live_test.exs`
|
||||
|
||||
Expected: PASS for all UserSettingsLive tests
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add test/towerops_web/live/user_settings_live_test.exs
|
||||
git commit -m "test: update UserSettingsLive tests for sudo mode"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Run full test suite and format
|
||||
|
||||
**Step 1: Run all tests**
|
||||
|
||||
Run: `mix test`
|
||||
|
||||
Expected: All tests pass
|
||||
|
||||
**Step 2: Format code**
|
||||
|
||||
Run: `mix format`
|
||||
|
||||
Expected: All files formatted
|
||||
|
||||
**Step 3: Run precommit checks**
|
||||
|
||||
Run: `mix precommit`
|
||||
|
||||
Expected: All checks pass (compile, format, test)
|
||||
|
||||
**Step 4: Commit if any formatting changes**
|
||||
|
||||
```bash
|
||||
git add -u
|
||||
git commit -m "chore: format code"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Manual testing
|
||||
|
||||
**Step 1: Start the server**
|
||||
|
||||
Run: `mix phx.server`
|
||||
|
||||
**Step 2: Test the flow**
|
||||
|
||||
1. Log in as a user with TOTP enabled
|
||||
2. Navigate to `/users/settings`
|
||||
3. Should redirect to `/users/sudo-verify` (if sudo mode expired)
|
||||
4. Enter valid TOTP code
|
||||
5. Should redirect back to `/users/settings`
|
||||
6. Verify you can access settings without re-verifying for 10 minutes
|
||||
|
||||
**Step 3: Test error cases**
|
||||
|
||||
1. Try entering invalid TOTP code - should show error
|
||||
2. Try entering recovery code - should show specific error message
|
||||
3. Navigate to `/users/sudo-verify` while already in sudo mode - should redirect to settings
|
||||
|
||||
**Step 4: Document any issues found**
|
||||
|
||||
If any issues are found, create new tasks to fix them.
|
||||
|
||||
---
|
||||
|
||||
## Completion Checklist
|
||||
|
||||
- [ ] Task 1: Add Accounts.grant_sudo_mode/1 function
|
||||
- [ ] Task 2: Add Accounts.verify_totp_only/2 function
|
||||
- [ ] Task 3: Create UserSudoController and HTML module
|
||||
- [ ] Task 4: Update UserAuth plugs to redirect to sudo verify
|
||||
- [ ] Task 5: Update UserSettingsLive tests
|
||||
- [ ] Task 6: Run full test suite and format
|
||||
- [ ] Task 7: Manual testing
|
||||
|
||||
## Success Criteria
|
||||
|
||||
✅ All tests pass
|
||||
✅ Code formatted and precommit checks pass
|
||||
✅ User can verify sudo mode with TOTP only (no password)
|
||||
✅ Recovery codes are rejected with clear error message
|
||||
✅ Return path redirection works correctly
|
||||
✅ Edge cases handled gracefully
|
||||
|
|
@ -677,7 +677,7 @@ defmodule Towerops.Accounts do
|
|||
def grant_sudo_mode(%User{} = user) do
|
||||
now = DateTime.utc_now()
|
||||
|
||||
with {:ok, _updated_user} = result <-
|
||||
with {:ok, updated_user} <-
|
||||
user
|
||||
|> User.sudo_changeset(%{last_sudo_at: now})
|
||||
|> Repo.update() do
|
||||
|
|
@ -688,7 +688,8 @@ defmodule Towerops.Accounts do
|
|||
metadata: %{granted_at: now}
|
||||
)
|
||||
|
||||
result
|
||||
# Set authenticated_at virtual field for sudo_mode? check
|
||||
{:ok, %{updated_user | authenticated_at: now}}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
71
lib/towerops_web/controllers/user_sudo_controller.ex
Normal file
71
lib/towerops_web/controllers/user_sudo_controller.ex
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
defmodule ToweropsWeb.UserSudoController do
|
||||
@moduledoc """
|
||||
Controller for sudo mode verification.
|
||||
|
||||
Handles re-authentication for sensitive operations by requiring TOTP verification
|
||||
(no recovery codes allowed).
|
||||
"""
|
||||
|
||||
use ToweropsWeb, :controller
|
||||
|
||||
import ToweropsWeb.UserAuth, only: [require_authenticated_user: 2]
|
||||
|
||||
alias Towerops.Accounts
|
||||
|
||||
plug :require_authenticated_user
|
||||
|
||||
def verify(conn, _params) do
|
||||
# Render the sudo verification form
|
||||
form = Phoenix.Component.to_form(%{}, as: "user")
|
||||
render(conn, :verify, form: form)
|
||||
end
|
||||
|
||||
def create(conn, %{"user" => %{"totp_code" => totp_code}}) do
|
||||
user = conn.assigns.current_scope.user
|
||||
|
||||
case Accounts.verify_totp_only(user, totp_code) do
|
||||
{:ok, _verified_user} ->
|
||||
# Grant sudo mode
|
||||
case Accounts.grant_sudo_mode(user) do
|
||||
{:ok, _updated_user} ->
|
||||
# Redirect to return_to path or default
|
||||
return_to = get_session(conn, :user_return_to) || ~p"/orgs"
|
||||
|
||||
conn
|
||||
|> delete_session(:user_return_to)
|
||||
|> redirect(to: return_to)
|
||||
|
||||
{:error, _changeset} ->
|
||||
# Shouldn't happen, but handle gracefully
|
||||
form = Phoenix.Component.to_form(%{"totp_code" => totp_code}, as: "user")
|
||||
|
||||
conn
|
||||
|> put_flash(:error, "Failed to grant sudo mode. Please try again.")
|
||||
|> render(:verify, form: form)
|
||||
end
|
||||
|
||||
{:error, :recovery_code_not_allowed} ->
|
||||
form = Phoenix.Component.to_form(%{"totp_code" => totp_code}, as: "user")
|
||||
|
||||
conn
|
||||
|> put_flash(
|
||||
:error,
|
||||
"Recovery codes are not allowed for sudo mode verification. Please use your authenticator app."
|
||||
)
|
||||
|> render(:verify, form: form)
|
||||
|
||||
{:error, :invalid_code} ->
|
||||
form = Phoenix.Component.to_form(%{"totp_code" => totp_code}, as: "user")
|
||||
|
||||
conn
|
||||
|> put_flash(:error, "Invalid authentication code. Please try again.")
|
||||
|> render(:verify, form: form)
|
||||
end
|
||||
end
|
||||
|
||||
def create(conn, _params) do
|
||||
# Handle missing totp_code parameter
|
||||
form = Phoenix.Component.to_form(%{}, as: "user")
|
||||
render(conn, :verify, form: form)
|
||||
end
|
||||
end
|
||||
7
lib/towerops_web/controllers/user_sudo_html.ex
Normal file
7
lib/towerops_web/controllers/user_sudo_html.ex
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
defmodule ToweropsWeb.UserSudoHTML do
|
||||
@moduledoc false
|
||||
|
||||
use ToweropsWeb, :html
|
||||
|
||||
embed_templates "user_sudo_html/*"
|
||||
end
|
||||
47
lib/towerops_web/controllers/user_sudo_html/verify.html.heex
Normal file
47
lib/towerops_web/controllers/user_sudo_html/verify.html.heex
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<div class="mx-auto max-w-sm">
|
||||
<div class="text-center">
|
||||
<.header>
|
||||
Re-authenticate
|
||||
<:subtitle>
|
||||
Enter the 6-digit code from your authenticator app to continue
|
||||
</:subtitle>
|
||||
</.header>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<.form :let={f} for={@form} as={:user} id="sudo_verify_form" action={~p"/users/sudo/verify"}>
|
||||
<.input
|
||||
field={f[:totp_code]}
|
||||
type="text"
|
||||
label="Authentication Code"
|
||||
placeholder="000000"
|
||||
autocomplete="one-time-code"
|
||||
inputmode="numeric"
|
||||
required
|
||||
phx-mounted={JS.focus()}
|
||||
/>
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Enter the 6-digit code from your authenticator app. Recovery codes are not allowed for sensitive operations.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<.button class="w-full" variant="primary">
|
||||
Verify <span aria-hidden="true">→</span>
|
||||
</.button>
|
||||
</div>
|
||||
</.form>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<.link
|
||||
href={~p"/users/log-out"}
|
||||
method="delete"
|
||||
class="text-sm font-semibold text-gray-600 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
|
||||
>
|
||||
← Log out instead
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
|
|
@ -185,6 +185,14 @@ defmodule ToweropsWeb.Router do
|
|||
live "/users/reset-password/:token", UserResetPasswordLive, :edit
|
||||
end
|
||||
|
||||
# Sudo mode verification routes
|
||||
scope "/", ToweropsWeb do
|
||||
pipe_through [:browser, :require_authenticated_user]
|
||||
|
||||
get "/users/sudo/verify", UserSudoController, :verify
|
||||
post "/users/sudo/verify", UserSudoController, :create
|
||||
end
|
||||
|
||||
## Admin routes (superuser only)
|
||||
|
||||
scope "/", ToweropsWeb do
|
||||
|
|
|
|||
|
|
@ -372,6 +372,7 @@ defmodule ToweropsWeb.UserAuth do
|
|||
"/users/register",
|
||||
"/users/reset-password",
|
||||
"/users/confirm",
|
||||
"/users/sudo",
|
||||
"/docs/api",
|
||||
"/privacy",
|
||||
"/terms",
|
||||
|
|
|
|||
165
test/towerops_web/controllers/user_sudo_controller_test.exs
Normal file
165
test/towerops_web/controllers/user_sudo_controller_test.exs
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
defmodule ToweropsWeb.UserSudoControllerTest do
|
||||
use ToweropsWeb.ConnCase, async: true
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
|
||||
alias Towerops.Accounts
|
||||
|
||||
setup do
|
||||
# Create user with TOTP enabled (required for sudo mode)
|
||||
user = user_fixture(enable_totp: true)
|
||||
%{user: user}
|
||||
end
|
||||
|
||||
describe "GET /users/sudo/verify" do
|
||||
test "renders sudo verification page for authenticated user", %{conn: conn, user: user} do
|
||||
conn =
|
||||
conn
|
||||
|> log_in_user(user)
|
||||
|> get(~p"/users/sudo/verify")
|
||||
|
||||
response = html_response(conn, 200)
|
||||
assert response =~ "Re-authenticate"
|
||||
assert response =~ "Enter the 6-digit code from your authenticator app"
|
||||
end
|
||||
|
||||
test "redirects unauthenticated users to login", %{conn: conn} do
|
||||
conn = get(conn, ~p"/users/sudo/verify")
|
||||
assert redirected_to(conn) == ~p"/users/log-in"
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You must log in to access this page."
|
||||
end
|
||||
|
||||
test "preserves return_to path from session", %{conn: conn, user: user} do
|
||||
conn =
|
||||
conn
|
||||
|> log_in_user(user)
|
||||
|> init_test_session(user_return_to: "/users/settings")
|
||||
|> get(~p"/users/sudo/verify")
|
||||
|
||||
assert html_response(conn, 200) =~ "Re-authenticate"
|
||||
assert get_session(conn, :user_return_to) == "/users/settings"
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST /users/sudo/verify" do
|
||||
test "grants sudo mode and redirects on valid TOTP code", %{conn: conn, user: user} do
|
||||
# Get valid TOTP code for user
|
||||
code = NimbleTOTP.verification_code(user.totp_secret)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> log_in_user(user)
|
||||
|> init_test_session(user_return_to: "/users/settings")
|
||||
|> post(~p"/users/sudo/verify", %{"user" => %{"totp_code" => code}})
|
||||
|
||||
# Should redirect to return_to path (implies sudo mode was granted)
|
||||
assert redirected_to(conn) == "/users/settings"
|
||||
|
||||
# Should clear return_to from session
|
||||
assert get_session(conn, :user_return_to) == nil
|
||||
|
||||
# Should update last_sudo_at timestamp
|
||||
updated_user = Accounts.get_user!(user.id)
|
||||
assert updated_user.last_sudo_at
|
||||
assert DateTime.diff(updated_user.last_sudo_at, DateTime.utc_now(), :second) <= 1
|
||||
end
|
||||
|
||||
test "grants sudo mode and redirects to /orgs if no return_to", %{conn: conn, user: user} do
|
||||
code = NimbleTOTP.verification_code(user.totp_secret)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> log_in_user(user)
|
||||
|> post(~p"/users/sudo/verify", %{"user" => %{"totp_code" => code}})
|
||||
|
||||
# Should redirect to /orgs (implies sudo mode was granted)
|
||||
assert redirected_to(conn) == ~p"/orgs"
|
||||
|
||||
# Should update last_sudo_at timestamp
|
||||
updated_user = Accounts.get_user!(user.id)
|
||||
assert updated_user.last_sudo_at
|
||||
assert DateTime.diff(updated_user.last_sudo_at, DateTime.utc_now(), :second) <= 1
|
||||
end
|
||||
|
||||
test "shows error on invalid TOTP code", %{conn: conn, user: user} do
|
||||
conn =
|
||||
conn
|
||||
|> log_in_user(user)
|
||||
|> post(~p"/users/sudo/verify", %{"user" => %{"totp_code" => "000000"}})
|
||||
|
||||
response = html_response(conn, 200)
|
||||
assert response =~ "Invalid authentication code"
|
||||
assert response =~ "Re-authenticate"
|
||||
|
||||
# Should not grant sudo mode
|
||||
user = Accounts.get_user!(user.id)
|
||||
refute Accounts.sudo_mode?(user)
|
||||
end
|
||||
|
||||
test "shows error when recovery code is attempted", %{conn: conn, user: user} do
|
||||
conn =
|
||||
conn
|
||||
|> log_in_user(user)
|
||||
|> post(~p"/users/sudo/verify", %{"user" => %{"totp_code" => "ABCD-EFGH"}})
|
||||
|
||||
response = html_response(conn, 200)
|
||||
assert response =~ "Recovery codes are not allowed for sudo mode verification"
|
||||
assert response =~ "Re-authenticate"
|
||||
|
||||
# Should not grant sudo mode
|
||||
user = Accounts.get_user!(user.id)
|
||||
refute Accounts.sudo_mode?(user)
|
||||
end
|
||||
|
||||
test "shows error when TOTP code is too long", %{conn: conn, user: user} do
|
||||
conn =
|
||||
conn
|
||||
|> log_in_user(user)
|
||||
|> post(~p"/users/sudo/verify", %{"user" => %{"totp_code" => "1234567"}})
|
||||
|
||||
response = html_response(conn, 200)
|
||||
assert response =~ "Recovery codes are not allowed for sudo mode verification"
|
||||
assert response =~ "Re-authenticate"
|
||||
end
|
||||
|
||||
test "shows error when TOTP code contains letters", %{conn: conn, user: user} do
|
||||
conn =
|
||||
conn
|
||||
|> log_in_user(user)
|
||||
|> post(~p"/users/sudo/verify", %{"user" => %{"totp_code" => "12AB34"}})
|
||||
|
||||
response = html_response(conn, 200)
|
||||
assert response =~ "Recovery codes are not allowed for sudo mode verification"
|
||||
assert response =~ "Re-authenticate"
|
||||
end
|
||||
|
||||
test "redirects unauthenticated users to login", %{conn: conn} do
|
||||
conn = post(conn, ~p"/users/sudo/verify", %{"user" => %{"totp_code" => "123456"}})
|
||||
assert redirected_to(conn) == ~p"/users/log-in"
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You must log in to access this page."
|
||||
end
|
||||
|
||||
test "preserves return_to path on verification failure", %{conn: conn, user: user} do
|
||||
conn =
|
||||
conn
|
||||
|> log_in_user(user)
|
||||
|> init_test_session(user_return_to: "/users/settings")
|
||||
|> post(~p"/users/sudo/verify", %{"user" => %{"totp_code" => "000000"}})
|
||||
|
||||
assert html_response(conn, 200) =~ "Invalid authentication code"
|
||||
# return_to should be preserved for next attempt
|
||||
assert get_session(conn, :user_return_to) == "/users/settings"
|
||||
end
|
||||
|
||||
test "handles missing TOTP code parameter", %{conn: conn, user: user} do
|
||||
conn =
|
||||
conn
|
||||
|> log_in_user(user)
|
||||
|> post(~p"/users/sudo/verify", %{"user" => %{}})
|
||||
|
||||
response = html_response(conn, 200)
|
||||
assert response =~ "Re-authenticate"
|
||||
# Should render form again (no crash)
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue