- Keep your device safe:
- You'll need to enter a code from your authenticator app each time you log in.
- Make sure to keep your phone secure and backed up.
-
+ Keep your device safe:
+ You'll need to enter a code from your authenticator app each time you log in.
+ Make sure to keep your phone secure and backed up.
+
+
+
+
+ <% end %>
diff --git a/lib/towerops_web/live/user_settings_live.ex b/lib/towerops_web/live/user_settings_live.ex
index 73ca1f6f..2eb8b022 100644
--- a/lib/towerops_web/live/user_settings_live.ex
+++ b/lib/towerops_web/live/user_settings_live.ex
@@ -7,6 +7,7 @@ defmodule ToweropsWeb.UserSettingsLive do
use ToweropsWeb, :live_view
alias Towerops.Accounts
+ alias Towerops.Accounts.UserTotpDevice
alias Towerops.Admin.AuditLogger
alias Towerops.MobileSessions
@@ -28,11 +29,20 @@ defmodule ToweropsWeb.UserSettingsLive do
|> assign_browser_sessions()
|> assign_login_history()
|> assign_security_alerts()
+ |> assign_totp_devices()
+ |> assign_recovery_codes_count()
|> assign(:show_add_token_modal, false)
|> assign(:show_token_modal, false)
|> assign(:created_token, nil)
|> assign(:show_revoke_all_modal, false)
|> assign(:login_history_page, 1)
+ |> assign(:show_add_device_modal, false)
+ |> assign(:show_device_qr_modal, false)
+ |> assign(:show_recovery_codes_modal, false)
+ |> assign(:new_device_id, nil)
+ |> assign(:new_device_secret, nil)
+ |> assign(:new_device_qr_code, nil)
+ |> assign(:generated_recovery_codes, nil)
{:ok, socket}
end
@@ -286,6 +296,140 @@ defmodule ToweropsWeb.UserSettingsLive do
{:noreply, socket}
end
+ # Security tab - TOTP Device Management
+
+ @impl true
+ def handle_event("show_add_device_modal", _params, socket) do
+ {:noreply, assign(socket, :show_add_device_modal, true)}
+ end
+
+ @impl true
+ def handle_event("cancel_add_device", _params, socket) do
+ {:noreply, assign(socket, :show_add_device_modal, false)}
+ end
+
+ @impl true
+ def handle_event("create_device", %{"name" => name}, socket) do
+ user = socket.assigns.current_scope.user
+
+ case Accounts.create_totp_device(user.id, name) do
+ {:ok, device, secret} ->
+ qr_code = Accounts.generate_totp_qr_code(user, secret)
+
+ socket =
+ socket
+ |> assign(:show_add_device_modal, false)
+ |> assign(:show_device_qr_modal, true)
+ |> assign(:new_device_id, device.id)
+ |> assign(:new_device_secret, secret)
+ |> assign(:new_device_qr_code, qr_code)
+
+ {:noreply, socket}
+
+ {:error, _changeset} ->
+ {:noreply, put_flash(socket, :error, "Failed to create device.")}
+ end
+ end
+
+ @impl true
+ def handle_event("verify_new_device", %{"code" => code}, socket) do
+ secret = socket.assigns.new_device_secret
+ device_id = socket.assigns.new_device_id
+
+ if Accounts.verify_totp(secret, code) do
+ # Device already created, just mark as verified by touching it
+ device = Towerops.Repo.get!(UserTotpDevice, device_id)
+
+ device
+ |> UserTotpDevice.touch_changeset()
+ |> Towerops.Repo.update!()
+
+ socket =
+ socket
+ |> put_flash(:info, "Device added successfully!")
+ |> assign(:show_device_qr_modal, false)
+ |> assign(:new_device_id, nil)
+ |> assign(:new_device_secret, nil)
+ |> assign(:new_device_qr_code, nil)
+ |> assign_totp_devices()
+
+ {:noreply, socket}
+ else
+ {:noreply, put_flash(socket, :error, "Invalid code. Please try again.")}
+ end
+ end
+
+ @impl true
+ def handle_event("close_device_qr_modal", _params, socket) do
+ # Delete the unverified device if user cancels
+ if device_id = socket.assigns.new_device_id do
+ user = socket.assigns.current_scope.user
+ Accounts.delete_totp_device(device_id, user.id)
+ end
+
+ socket =
+ socket
+ |> assign(:show_device_qr_modal, false)
+ |> assign(:new_device_id, nil)
+ |> assign(:new_device_secret, nil)
+ |> assign(:new_device_qr_code, nil)
+ |> assign_totp_devices()
+
+ {:noreply, socket}
+ end
+
+ @impl true
+ def handle_event("delete_device", %{"device-id" => device_id}, socket) do
+ user = socket.assigns.current_scope.user
+
+ case Accounts.delete_totp_device(device_id, user.id) do
+ {:ok, _} ->
+ socket =
+ socket
+ |> put_flash(:info, "Device removed successfully.")
+ |> assign_totp_devices()
+
+ {:noreply, socket}
+
+ {:error, :last_device} ->
+ {:noreply, put_flash(socket, :error, "Cannot remove last device. You must have at least one.")}
+
+ {:error, _} ->
+ {:noreply, put_flash(socket, :error, "Failed to remove device.")}
+ end
+ end
+
+ # Security tab - Recovery Codes
+
+ @impl true
+ def handle_event("regenerate_recovery_codes", _params, socket) do
+ user = socket.assigns.current_scope.user
+
+ case Accounts.generate_recovery_codes(user.id) do
+ {:ok, codes} ->
+ socket =
+ socket
+ |> assign(:show_recovery_codes_modal, true)
+ |> assign(:generated_recovery_codes, codes)
+ |> assign_recovery_codes_count()
+
+ {:noreply, socket}
+
+ {:error, _} ->
+ {:noreply, put_flash(socket, :error, "Failed to generate recovery codes.")}
+ end
+ end
+
+ @impl true
+ def handle_event("close_recovery_codes_modal", _params, socket) do
+ socket =
+ socket
+ |> assign(:show_recovery_codes_modal, false)
+ |> assign(:generated_recovery_codes, nil)
+
+ {:noreply, socket}
+ end
+
defp get_current_token_id(%{current_token: token}) when is_binary(token) do
Accounts.get_user_token_id_by_value(token)
end
@@ -356,6 +500,18 @@ defmodule ToweropsWeb.UserSettingsLive do
|> assign(:show_security_alert, failed_count >= 3)
end
+ defp assign_totp_devices(socket) do
+ user = socket.assigns.current_scope.user
+ devices = Accounts.list_user_totp_devices(user.id)
+ assign(socket, :totp_devices, devices)
+ end
+
+ defp assign_recovery_codes_count(socket) do
+ user = socket.assigns.current_scope.user
+ count = Accounts.count_unused_recovery_codes(user.id)
+ assign(socket, :unused_recovery_codes_count, count)
+ end
+
# Sessions tab helper functions
defp current_session?(session, current_token_id) do
@@ -517,6 +673,20 @@ defmodule ToweropsWeb.UserSettingsLive do
Notifications
+
+ These codes won't be shown again. Store them securely (password manager, encrypted file, etc.).
+ If you lose your authenticator and these codes, you won't be able to access your account.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <% end %>
+
+ assert Regex.match?(~r/^[A-Z2-9]{4}-[A-Z2-9]{4}$/, code)
+ # No ambiguous chars
+ refute String.contains?(code, ["O", "I", "0", "1"])
+ end)
+ end
+
+ test "generate_recovery_codes/1 deletes existing unused codes before generating", %{user: user} do
+ {:ok, old_codes} = Accounts.generate_recovery_codes(user.id)
+ old_code = List.first(old_codes)
+
+ {:ok, new_codes} = Accounts.generate_recovery_codes(user.id)
+
+ # Old codes should be invalid
+ assert {:error, :invalid_code} = Accounts.verify_recovery_code(user.id, old_code)
+
+ # New codes should work
+ new_code = List.first(new_codes)
+ assert {:ok, _} = Accounts.verify_recovery_code(user.id, new_code)
+ end
+
+ test "generate_recovery_codes/1 preserves used codes when regenerating", %{user: user} do
+ {:ok, codes} = Accounts.generate_recovery_codes(user.id)
+ used_code = List.first(codes)
+
+ # Use one code
+ {:ok, _} = Accounts.verify_recovery_code(user.id, used_code)
+
+ # Generate new codes
+ {:ok, _new_codes} = Accounts.generate_recovery_codes(user.id)
+
+ # Used code should still be marked as used (not deleted)
+ assert 12 = Accounts.count_unused_recovery_codes(user.id)
+ end
+
+ test "verify_recovery_code/2 verifies valid unused code", %{user: user} do
+ {:ok, codes} = Accounts.generate_recovery_codes(user.id)
+ code = List.first(codes)
+
+ assert {:ok, record} = Accounts.verify_recovery_code(user.id, code)
+ assert is_nil(record.used_at) == false
+ end
+
+ test "verify_recovery_code/2 marks code as used after verification", %{user: user} do
+ {:ok, codes} = Accounts.generate_recovery_codes(user.id)
+ code = List.first(codes)
+
+ {:ok, _} = Accounts.verify_recovery_code(user.id, code)
+
+ # Second attempt should fail
+ assert {:error, :invalid_code} = Accounts.verify_recovery_code(user.id, code)
+ end
+
+ test "verify_recovery_code/2 rejects invalid code format", %{user: user} do
+ assert {:error, :invalid_code} = Accounts.verify_recovery_code(user.id, "INVALID")
+ assert {:error, :invalid_code} = Accounts.verify_recovery_code(user.id, "1234-5678")
+ end
+
+ test "count_unused_recovery_codes/1 returns correct count", %{user: user} do
+ assert 0 = Accounts.count_unused_recovery_codes(user.id)
+
+ {:ok, codes} = Accounts.generate_recovery_codes(user.id)
+ assert 12 = Accounts.count_unused_recovery_codes(user.id)
+
+ # Use one code
+ code = List.first(codes)
+ {:ok, _} = Accounts.verify_recovery_code(user.id, code)
+ assert 11 = Accounts.count_unused_recovery_codes(user.id)
+ end
+
+ test "list_user_recovery_codes/1 returns all codes with status", %{user: user} do
+ {:ok, codes} = Accounts.generate_recovery_codes(user.id)
+
+ all_codes = Accounts.list_user_recovery_codes(user.id)
+ assert length(all_codes) == 12
+
+ # Use one code
+ code = List.first(codes)
+ {:ok, _} = Accounts.verify_recovery_code(user.id, code)
+
+ all_codes = Accounts.list_user_recovery_codes(user.id)
+ used_codes = Enum.filter(all_codes, & &1.used_at)
+ unused_codes = Enum.filter(all_codes, &is_nil(&1.used_at))
+
+ assert length(used_codes) == 1
+ assert length(unused_codes) == 11
+ end
+ end
+
+ describe "verify_user_mfa/2" do
+ setup do
+ user = user_fixture()
+ {:ok, user: user}
+ end
+
+ test "accepts TOTP code from any device", %{user: user} do
+ {:ok, _device, secret} = Accounts.create_totp_device(user.id, "Device")
+ code = NimbleTOTP.verification_code(secret)
+
+ assert {:ok, ^user, :totp} = Accounts.verify_user_mfa(user, code)
+ end
+
+ test "accepts recovery code as fallback", %{user: user} do
+ {:ok, _device, _secret} = Accounts.create_totp_device(user.id, "Device")
+ {:ok, codes} = Accounts.generate_recovery_codes(user.id)
+ recovery_code = List.first(codes)
+
+ assert {:ok, ^user, :recovery_code} = Accounts.verify_user_mfa(user, recovery_code)
+ end
+
+ test "prefers TOTP over recovery code when both would be valid", %{user: user} do
+ {:ok, _device, secret} = Accounts.create_totp_device(user.id, "Device")
+ totp_code = NimbleTOTP.verification_code(secret)
+
+ # Should return :totp, not :recovery_code
+ assert {:ok, ^user, :totp} = Accounts.verify_user_mfa(user, totp_code)
+ end
+
+ test "rejects invalid code that matches neither TOTP nor recovery", %{user: user} do
+ {:ok, _device, _secret} = Accounts.create_totp_device(user.id, "Device")
+ {:ok, _codes} = Accounts.generate_recovery_codes(user.id)
+
+ assert {:error, :invalid_code} = Accounts.verify_user_mfa(user, "000000")
+ end
+
+ test "marks recovery code as used when verified via MFA", %{user: user} do
+ {:ok, _device, _secret} = Accounts.create_totp_device(user.id, "Device")
+ {:ok, codes} = Accounts.generate_recovery_codes(user.id)
+ recovery_code = List.first(codes)
+
+ assert {:ok, ^user, :recovery_code} = Accounts.verify_user_mfa(user, recovery_code)
+
+ # Code should be marked as used
+ assert 11 = Accounts.count_unused_recovery_codes(user.id)
+ end
+ end
end
diff --git a/test/towerops_web/controllers/user_session_controller_test.exs b/test/towerops_web/controllers/user_session_controller_test.exs
index c2f04913..b9a5b16f 100644
--- a/test/towerops_web/controllers/user_session_controller_test.exs
+++ b/test/towerops_web/controllers/user_session_controller_test.exs
@@ -216,6 +216,124 @@ defmodule ToweropsWeb.UserSessionControllerTest do
end
end
+ describe "POST /users/log-in/totp - recovery codes" do
+ test "logs in with valid recovery code", %{conn: conn, user_with_totp: user} do
+ # Generate recovery codes
+ {:ok, codes} = Accounts.generate_recovery_codes(user.id)
+ recovery_code = List.first(codes)
+
+ conn =
+ conn
+ |> init_test_session(pending_totp_user_id: user.id)
+ |> post(~p"/users/log-in/totp", %{"user" => %{"totp_code" => recovery_code}})
+
+ # Should create session
+ assert get_session(conn, :user_token)
+ # Should clear pending state
+ refute get_session(conn, :pending_totp_user_id)
+ # Should show warning about recovery code usage
+ assert Phoenix.Flash.get(conn.assigns.flash, :warning) =~
+ "You used a recovery code"
+
+ assert Phoenix.Flash.get(conn.assigns.flash, :warning) =~
+ "Consider regenerating codes"
+
+ assert redirected_to(conn) == ~p"/orgs"
+ end
+
+ test "marks recovery code as used after login", %{conn: conn, user_with_totp: user} do
+ {:ok, codes} = Accounts.generate_recovery_codes(user.id)
+ recovery_code = List.first(codes)
+
+ # Verify code is unused
+ assert Accounts.count_unused_recovery_codes(user.id) == 12
+
+ conn =
+ conn
+ |> init_test_session(pending_totp_user_id: user.id)
+ |> post(~p"/users/log-in/totp", %{"user" => %{"totp_code" => recovery_code}})
+
+ assert get_session(conn, :user_token)
+
+ # Verify code was marked as used
+ assert Accounts.count_unused_recovery_codes(user.id) == 11
+ end
+
+ test "rejects already-used recovery code", %{conn: conn, user_with_totp: user} do
+ {:ok, codes} = Accounts.generate_recovery_codes(user.id)
+ recovery_code = List.first(codes)
+
+ # Use the code once
+ conn =
+ conn
+ |> init_test_session(pending_totp_user_id: user.id)
+ |> post(~p"/users/log-in/totp", %{"user" => %{"totp_code" => recovery_code}})
+
+ assert get_session(conn, :user_token)
+
+ # Try to use it again
+ conn =
+ build_conn()
+ |> init_test_session(pending_totp_user_id: user.id)
+ |> post(~p"/users/log-in/totp", %{"user" => %{"totp_code" => recovery_code}})
+
+ response = html_response(conn, 200)
+ assert response =~ "Invalid authentication code"
+ refute get_session(conn, :user_token)
+ end
+
+ test "rejects invalid recovery code format", %{conn: conn, user_with_totp: user} do
+ conn =
+ conn
+ |> init_test_session(pending_totp_user_id: user.id)
+ |> post(~p"/users/log-in/totp", %{"user" => %{"totp_code" => "INVALID-CODE"}})
+
+ response = html_response(conn, 200)
+ assert response =~ "Invalid authentication code"
+ refute get_session(conn, :user_token)
+ end
+
+ test "accepts both TOTP and recovery codes", %{conn: conn, user_with_totp: user} do
+ {:ok, codes} = Accounts.generate_recovery_codes(user.id)
+ recovery_code = List.first(codes)
+
+ # First login with TOTP code
+ totp_code = NimbleTOTP.verification_code(user.totp_secret)
+
+ conn =
+ conn
+ |> init_test_session(pending_totp_user_id: user.id)
+ |> post(~p"/users/log-in/totp", %{"user" => %{"totp_code" => totp_code}})
+
+ assert get_session(conn, :user_token)
+ # No warning for TOTP
+ refute Phoenix.Flash.get(conn.assigns.flash, :warning)
+
+ # Second login with recovery code
+ conn =
+ build_conn()
+ |> init_test_session(pending_totp_user_id: user.id)
+ |> post(~p"/users/log-in/totp", %{"user" => %{"totp_code" => recovery_code}})
+
+ assert get_session(conn, :user_token)
+ # Warning shown for recovery code
+ assert Phoenix.Flash.get(conn.assigns.flash, :warning) =~ "recovery code"
+ end
+
+ test "respects remember_me with recovery code", %{conn: conn, user_with_totp: user} do
+ {:ok, codes} = Accounts.generate_recovery_codes(user.id)
+ recovery_code = List.first(codes)
+
+ conn =
+ conn
+ |> init_test_session(pending_totp_user_id: user.id, pending_totp_remember_me: true)
+ |> post(~p"/users/log-in/totp", %{"user" => %{"totp_code" => recovery_code}})
+
+ assert conn.resp_cookies["_towerops_web_user_remember_me"]
+ assert get_session(conn, :user_token)
+ end
+ end
+
describe "POST /users/log-in - magic link" do
test "sends magic link email when user exists", %{conn: conn, user: user} do
conn =
diff --git a/test/towerops_web/live/account_live/totp_enrollment_test.exs b/test/towerops_web/live/account_live/totp_enrollment_test.exs
index c4bfce1f..0b982196 100644
--- a/test/towerops_web/live/account_live/totp_enrollment_test.exs
+++ b/test/towerops_web/live/account_live/totp_enrollment_test.exs
@@ -13,6 +13,24 @@ defmodule ToweropsWeb.AccountLive.TotpEnrollmentTest do
%{conn: log_in_user(build_conn(), user), user: user}
end
+ test "generates valid 20-byte secret", %{conn: conn} do
+ {:ok, view, _html} = live(conn, ~p"/account/totp-enrollment")
+
+ # Get the base32 secret from the view's assigns
+ secret_base32 = :sys.get_state(view.pid).socket.assigns.secret_base32
+
+ # Decode to binary
+ secret = Base.decode32!(secret_base32, padding: false, case: :upper)
+
+ # Verify secret is 20 bytes (NimbleTOTP default)
+ assert byte_size(secret) == 20
+
+ # Verify we can generate valid codes from it
+ code = NimbleTOTP.verification_code(secret)
+ assert String.length(code) == 6
+ assert NimbleTOTP.valid?(secret, code)
+ end
+
test "renders enrollment page with QR code", %{conn: conn} do
{:ok, view, html} = live(conn, ~p"/account/totp-enrollment")
@@ -31,8 +49,9 @@ defmodule ToweropsWeb.AccountLive.TotpEnrollmentTest do
{:ok, view, _html} = live(conn, ~p"/account/totp-enrollment")
- # Get the secret from the view's assigns (via testing API)
- secret = :sys.get_state(view.pid).socket.assigns.secret
+ # Get the base32 secret from the view's assigns and decode it
+ secret_base32 = :sys.get_state(view.pid).socket.assigns.secret_base32
+ secret = Base.decode32!(secret_base32, padding: false, case: :upper)
# Generate a valid code
code = NimbleTOTP.verification_code(secret)
@@ -42,20 +61,41 @@ defmodule ToweropsWeb.AccountLive.TotpEnrollmentTest do
|> form("form", %{code: code})
|> render_submit()
- # Should redirect to devices page (user has organization)
- assert_redirect(view, ~p"/devices")
+ # Render the view to see the recovery codes page
+ html = render(view)
- # Verify TOTP was enabled in database
+ # Should show recovery codes page
+ assert html =~ "Save Your Recovery Codes"
+ assert html =~ "These codes will only be shown once"
+ assert html =~ "Your Recovery Codes"
+
+ # Verify TOTP device was created
+ devices = Accounts.list_user_totp_devices(user.id)
+ assert length(devices) == 1
+ assert hd(devices).name == "Primary Device"
+
+ # Verify recovery codes were generated
+ assert Accounts.count_unused_recovery_codes(user.id) == 12
+
+ # Verify TOTP is enabled via new system
updated_user = Accounts.get_user!(user.id)
assert Accounts.totp_enabled?(updated_user)
- assert updated_user.totp_secret == secret
+
+ # Click the confirmation button
+ view
+ |> element("button", "I've Saved My Recovery Codes")
+ |> render_click()
+
+ # Should redirect to devices page (user has organization)
+ assert_redirect(view, ~p"/devices")
end
test "redirects to orgs page when user has no organizations", %{conn: conn, user: user} do
{:ok, view, _html} = live(conn, ~p"/account/totp-enrollment")
- # Get the secret from the view's assigns
- secret = :sys.get_state(view.pid).socket.assigns.secret
+ # Get the base32 secret from the view's assigns and decode it
+ secret_base32 = :sys.get_state(view.pid).socket.assigns.secret_base32
+ secret = Base.decode32!(secret_base32, padding: false, case: :upper)
# Generate a valid code
code = NimbleTOTP.verification_code(secret)
@@ -65,12 +105,23 @@ defmodule ToweropsWeb.AccountLive.TotpEnrollmentTest do
|> form("form", %{code: code})
|> render_submit()
- # Should redirect to orgs page (user has no organizations)
- assert_redirect(view, ~p"/orgs")
+ # Render the view to see the recovery codes page
+ html = render(view)
+
+ # Should show recovery codes page
+ assert html =~ "Save Your Recovery Codes"
# Verify TOTP was enabled in database
updated_user = Accounts.get_user!(user.id)
assert Accounts.totp_enabled?(updated_user)
+
+ # Click the confirmation button
+ view
+ |> element("button", "I've Saved My Recovery Codes")
+ |> render_click()
+
+ # Should redirect to orgs page (user has no organizations)
+ assert_redirect(view, ~p"/orgs")
end
test "rejects invalid TOTP code", %{conn: conn} do