fix TOTP enrollment with recovery codes

This commit is contained in:
Graham McIntire 2026-01-31 14:54:44 -06:00
parent c690827ee0
commit dff9c26905
No known key found for this signature in database
15 changed files with 1824 additions and 169 deletions

2
.gitignore vendored
View file

@ -69,3 +69,5 @@ profiles.json
/priv/*.so
/priv/*.dylib
/c_src/*.o
/.expert/

View file

@ -12,7 +12,9 @@ defmodule Towerops.Accounts do
alias Towerops.Accounts.UserAgentParser
alias Towerops.Accounts.UserConsent
alias Towerops.Accounts.UserNotifier
alias Towerops.Accounts.UserRecoveryCode
alias Towerops.Accounts.UserToken
alias Towerops.Accounts.UserTotpDevice
alias Towerops.GeoIP
alias Towerops.Repo
@ -298,16 +300,43 @@ defmodule Towerops.Accounts do
@doc """
Checks if a user has TOTP enabled.
Checks both the new multi-device system (user_totp_devices table)
and the legacy totp_secret field for backward compatibility.
"""
def totp_enabled?(%User{totp_secret: secret}) when is_binary(secret), do: true
def totp_enabled?(%User{id: user_id, totp_secret: secret}) do
# Check new multi-device system first
device_count = count_user_totp_devices(user_id)
cond do
device_count > 0 -> true
is_binary(secret) -> true
true -> false
end
end
def totp_enabled?(_user), do: false
@doc """
Verifies a TOTP code for a user who has TOTP enabled.
Now checks ALL user devices (multi-device support), not just single secret.
Falls back to legacy single secret for backward compatibility.
Returns {:ok, user} if valid, {:error, :invalid_code} otherwise.
"""
def verify_user_totp(%User{totp_secret: secret} = user, code) when is_binary(secret) and is_binary(code) do
def verify_user_totp(%User{} = user, code) when is_binary(code) do
# First try new multi-device approach
case verify_user_totp_any_device(user, code) do
{:ok, _device} ->
{:ok, user}
{:error, :invalid_code} ->
# Fallback to legacy single secret (backward compatibility during migration)
verify_legacy_totp(user, code)
end
end
defp verify_legacy_totp(%User{totp_secret: secret} = user, code) when is_binary(secret) do
if verify_totp(secret, code) do
{:ok, user}
else
@ -315,10 +344,235 @@ defmodule Towerops.Accounts do
end
end
def verify_user_totp(%User{}, _code) do
defp verify_legacy_totp(%User{}, _code) do
{:error, :totp_not_enabled}
end
## TOTP Device Management
@doc """
Lists all TOTP devices for a user, ordered by most recently used.
"""
def list_user_totp_devices(user_id) do
UserTotpDevice
|> where([d], d.user_id == ^user_id)
|> order_by([d], desc_nulls_last: d.last_used_at, desc: d.inserted_at)
|> Repo.all()
end
@doc """
Counts TOTP devices for a user.
"""
def count_user_totp_devices(user_id) do
UserTotpDevice
|> where([d], d.user_id == ^user_id)
|> Repo.aggregate(:count, :id)
end
@doc """
Creates a new TOTP device for a user.
Returns {:ok, device, secret} where secret is the plain-text TOTP secret
that should be displayed once to the user as a QR code.
## Examples
iex> create_totp_device(user_id, "iPhone 15 Pro")
{:ok, %UserTotpDevice{}, "base32secret"}
"""
def create_totp_device(user_id, device_name) do
secret = generate_totp_secret()
create_totp_device(user_id, device_name, secret)
end
@doc """
Creates a TOTP device with a pre-existing secret.
Used during initial enrollment when the secret has already been generated and shown to the user.
"""
def create_totp_device(user_id, device_name, secret) when is_binary(secret) do
changeset =
UserTotpDevice.changeset(%UserTotpDevice{}, %{
user_id: user_id,
name: device_name,
totp_secret: secret,
created_at: DateTime.utc_now(:second)
})
case Repo.insert(changeset) do
{:ok, device} -> {:ok, device, secret}
{:error, changeset} -> {:error, changeset}
end
end
@doc """
Verifies a TOTP code against ANY of the user's devices.
Returns {:ok, device} if valid, updating last_used_at.
Returns {:error, :invalid_code} if no device matches.
"""
def verify_user_totp_any_device(%User{id: user_id}, code) when is_binary(code) do
devices = list_user_totp_devices(user_id)
Enum.find_value(devices, {:error, :invalid_code}, fn device ->
if verify_totp(device.totp_secret, code) do
# Update last_used_at and return updated device
updated_device =
device
|> UserTotpDevice.touch_changeset()
|> Repo.update!()
{:ok, updated_device}
end
end)
end
@doc """
Deletes a TOTP device.
Enforces "at least one device" rule - returns error if last device.
"""
def delete_totp_device(device_id, user_id) do
device = Repo.get(UserTotpDevice, device_id)
cond do
is_nil(device) ->
{:error, :not_found}
device.user_id != user_id ->
{:error, :unauthorized}
count_user_totp_devices(user_id) == 1 ->
{:error, :last_device}
true ->
Repo.delete(device)
end
end
@doc """
Renames a TOTP device.
"""
def rename_totp_device(device_id, user_id, new_name) do
device = Repo.get(UserTotpDevice, device_id)
cond do
is_nil(device) ->
{:error, :not_found}
device.user_id != user_id ->
{:error, :unauthorized}
true ->
device
|> UserTotpDevice.changeset(%{name: new_name})
|> Repo.update()
end
end
## Recovery Codes
@doc """
Generates 12 recovery codes for a user.
Returns {:ok, codes} where codes is a list of plain-text codes.
Stores hashed versions in database.
"""
def generate_recovery_codes(user_id) do
# Delete any existing unused recovery codes
UserRecoveryCode
|> where([rc], rc.user_id == ^user_id and is_nil(rc.used_at))
|> Repo.delete_all()
now = DateTime.utc_now(:second)
codes = for _ <- 1..12, do: UserRecoveryCode.generate_code()
# Insert hashed codes
records =
Enum.map(codes, fn code ->
%{
id: Ecto.UUID.generate(),
user_id: user_id,
code_hash: UserRecoveryCode.hash_code(code),
created_at: now,
inserted_at: now
}
end)
case Repo.insert_all(UserRecoveryCode, records) do
{12, _} -> {:ok, codes}
_ -> {:error, :generation_failed}
end
end
@doc """
Verifies a recovery code for a user.
Returns {:ok, code_record} if valid and unused.
Marks code as used on successful verification.
"""
def verify_recovery_code(user_id, code) when is_binary(code) do
code_hash = UserRecoveryCode.hash_code(code)
recovery_code =
UserRecoveryCode
|> where([rc], rc.user_id == ^user_id)
|> where([rc], rc.code_hash == ^code_hash)
|> where([rc], is_nil(rc.used_at))
|> Repo.one()
case recovery_code do
nil ->
{:error, :invalid_code}
code_record ->
code_record
|> UserRecoveryCode.use_changeset()
|> Repo.update()
end
end
@doc """
Counts unused recovery codes for a user.
"""
def count_unused_recovery_codes(user_id) do
UserRecoveryCode
|> where([rc], rc.user_id == ^user_id)
|> where([rc], is_nil(rc.used_at))
|> Repo.aggregate(:count, :id)
end
@doc """
Lists all recovery codes for a user (for display in UI).
Returns list with status (used/unused) and creation date.
"""
def list_user_recovery_codes(user_id) do
UserRecoveryCode
|> where([rc], rc.user_id == ^user_id)
|> order_by([rc], desc: rc.inserted_at)
|> Repo.all()
end
@doc """
Verifies a TOTP code OR recovery code during login.
Tries TOTP first, falls back to recovery code.
Returns {:ok, user, :totp} or {:ok, user, :recovery_code} or {:error, :invalid_code}.
"""
def verify_user_mfa(%User{} = user, code) when is_binary(code) do
case verify_user_totp(user, code) do
{:ok, user} ->
{:ok, user, :totp}
{:error, :invalid_code} ->
# Try recovery code as fallback
case verify_recovery_code(user.id, code) do
{:ok, _code_record} -> {:ok, user, :recovery_code}
{:error, _} -> {:error, :invalid_code}
end
end
end
## Settings
@doc """

View file

@ -0,0 +1,76 @@
defmodule Towerops.Accounts.UserRecoveryCode do
@moduledoc """
Schema for user recovery codes (backup codes for account recovery).
Recovery codes are single-use backup codes that can be used to access
an account if all TOTP devices are lost. Codes are stored as SHA-256 hashes.
"""
use Ecto.Schema
import Ecto.Changeset
alias Towerops.Accounts.User
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "user_recovery_codes" do
field :code_hash, :string
field :used_at, :utc_datetime
field :created_at, :utc_datetime
belongs_to :user, User
timestamps(type: :utc_datetime, updated_at: false)
end
@doc """
Generates a cryptographically secure 8-character recovery code.
Format: XXXX-XXXX (uppercase letters and numbers, no ambiguous chars).
Uses base32 alphabet without 0, O, I, 1 to avoid confusion.
"""
def generate_code do
# Use base32 alphabet without ambiguous characters (0, O, I, 1)
alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
for_result =
for _ <- 1..8 do
Enum.random(String.graphemes(alphabet))
end
code = Enum.join(for_result)
# Format: XXXX-XXXX
String.slice(code, 0..3) <> "-" <> String.slice(code, 4..7)
end
@doc """
Hashes a recovery code using SHA-256.
## Examples
iex> hash_code("ABCD-EFGH")
"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
"""
def hash_code(code) when is_binary(code) do
:sha256 |> :crypto.hash(code) |> Base.encode16(case: :lower)
end
@doc """
Changeset for creating a recovery code.
"""
def changeset(recovery_code, attrs) do
recovery_code
|> cast(attrs, [:code_hash, :user_id, :created_at])
|> validate_required([:code_hash, :user_id, :created_at])
|> unique_constraint(:code_hash)
|> foreign_key_constraint(:user_id)
end
@doc """
Changeset for marking a recovery code as used.
"""
def use_changeset(recovery_code) do
change(recovery_code, used_at: DateTime.utc_now(:second))
end
end

View file

@ -0,0 +1,44 @@
defmodule Towerops.Accounts.UserTotpDevice do
@moduledoc """
Schema for user TOTP (Time-based One-Time Password) devices.
Allows users to register multiple authenticator apps (phone, tablet, etc.)
for two-factor authentication.
"""
use Ecto.Schema
import Ecto.Changeset
alias Towerops.Accounts.User
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "user_totp_devices" do
field :name, :string
field :totp_secret, :binary, redact: true
field :last_used_at, :utc_datetime
field :created_at, :utc_datetime
belongs_to :user, User
timestamps(type: :utc_datetime)
end
@doc """
Changeset for creating or updating a TOTP device.
"""
def changeset(device, attrs) do
device
|> cast(attrs, [:name, :totp_secret, :user_id, :created_at])
|> validate_required([:name, :totp_secret, :user_id])
|> validate_length(:name, min: 1, max: 100)
|> foreign_key_constraint(:user_id)
end
@doc """
Changeset for updating last_used_at timestamp.
"""
def touch_changeset(device) do
change(device, last_used_at: DateTime.utc_now(:second))
end
end

View file

@ -49,8 +49,8 @@ defmodule Towerops.Profiles.ProfileWatcher do
Logger.info("ProfileWatcher: Detected change in #{Path.relative_to_cwd(path)}, reloading profiles...")
case YamlProfiles.reload() do
{:ok, count} ->
Logger.info("ProfileWatcher: Successfully reloaded #{count} profiles")
{:ok, profiles} when is_list(profiles) ->
Logger.info("ProfileWatcher: Successfully reloaded #{length(profiles)} profiles")
{:error, reason} ->
Logger.error("ProfileWatcher: Failed to reload profiles: #{inspect(reason)}")

View file

@ -142,10 +142,18 @@ defmodule ToweropsWeb.UserSessionController do
defp handle_totp_verification(conn, user_id, code) do
user = Accounts.get_user!(user_id)
case Accounts.verify_user_totp(user, code) do
{:ok, user} ->
case Accounts.verify_user_mfa(user, code) do
{:ok, user, :totp} ->
complete_totp_login(conn, user)
{:ok, user, :recovery_code} ->
conn
|> put_flash(
:warning,
"You used a recovery code. Consider regenerating codes from your account settings."
)
|> complete_totp_login(user)
{:error, _reason} ->
render_totp_error(conn, code)
end

View file

@ -18,11 +18,14 @@
placeholder="000000"
autocomplete="one-time-code"
inputmode="numeric"
pattern="[0-9]{6}"
maxlength="6"
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, or use a recovery code (XXXX-XXXX format).
</p>
</div>
<div class="mt-6">
<.button class="w-full" variant="primary">
Verify <span aria-hidden="true">→</span>

View file

@ -10,26 +10,64 @@ defmodule ToweropsWeb.AccountLive.TotpEnrollment do
@impl true
def mount(_params, _session, socket) do
require Logger
user = socket.assigns.current_scope.user
# If user already has TOTP enabled, redirect them
if Accounts.totp_enabled?(user) do
{:ok, redirect(socket, to: ~p"/devices")}
else
# Generate secret once and store in process state
# If user refreshes, they'll get a NEW secret - they must scan the LATEST QR code
secret = Accounts.generate_totp_secret()
qr_code = Accounts.generate_totp_qr_code(user, secret)
# Only generate secret when socket is connected
# On initial render (disconnected), use temporary placeholder
if connected?(socket) do
# Generate secret once per LiveView process
# IMPORTANT: If you refresh this page, you'll get a NEW secret
# You must scan the CURRENT QR code that's displayed
secret = Accounts.generate_totp_secret()
uri = Accounts.generate_totp_uri(user, secret)
qr_code = Accounts.generate_totp_qr_code(user, secret)
socket =
socket
|> assign(:page_title, "Set Up Two-Factor Authentication")
|> assign(:secret, secret)
|> assign(:qr_code, qr_code)
|> assign(:code, "")
|> assign(:error, nil)
# Base32 encode secret for storage in socket assigns
# This prevents corruption during LiveView serialization
secret_base32 = Base.encode32(secret, padding: false)
{:ok, socket}
Logger.info("TOTP enrollment started",
user_email: user.email,
secret_length: byte_size(secret),
secret_base32: secret_base32,
secret_hex: Base.encode16(secret),
uri: uri
)
socket =
socket
|> assign(:page_title, "Set Up Two-Factor Authentication")
|> assign(:secret_base32, secret_base32)
|> assign(:qr_code, qr_code)
|> assign(:code, "")
|> assign(:error, nil)
|> assign(:enrollment_complete, false)
|> assign(:recovery_codes, [])
|> assign(:show_recovery_codes, false)
{:ok, socket}
else
# Initial disconnected render - use placeholder
# Real secret will be generated when socket connects
socket =
socket
|> assign(:page_title, "Set Up Two-Factor Authentication")
|> assign(:secret_base32, nil)
|> assign(:qr_code, nil)
|> assign(:code, "")
|> assign(:error, nil)
|> assign(:enrollment_complete, false)
|> assign(:recovery_codes, [])
|> assign(:show_recovery_codes, false)
{:ok, socket}
end
end
end
@ -38,12 +76,17 @@ defmodule ToweropsWeb.AccountLive.TotpEnrollment do
require Logger
user = socket.assigns.current_scope.user
secret = socket.assigns.secret
secret_base32 = socket.assigns.secret_base32
# Decode base32 secret back to binary
secret = Base.decode32!(secret_base32, padding: false, case: :upper)
Logger.info("TOTP enrollment verification attempt",
user_email: user.email,
provided_code: code,
secret_length: byte_size(secret)
secret_length: byte_size(secret),
secret_base32_from_assigns: secret_base32,
secret_binary_hex: Base.encode16(secret)
)
if Accounts.verify_totp(secret, code) do
@ -55,15 +98,30 @@ defmodule ToweropsWeb.AccountLive.TotpEnrollment do
end
end
defp handle_valid_code(socket, user, secret) do
case Accounts.enable_totp(user, secret) do
{:ok, updated_user} ->
redirect_path = get_post_enrollment_path(updated_user)
@impl true
def handle_event("confirm_recovery_codes_saved", _params, socket) do
user = socket.assigns.current_scope.user
redirect_path = get_post_enrollment_path(user)
{:noreply,
socket
|> put_flash(:info, "Two-factor authentication enabled successfully!")
|> redirect(to: redirect_path)}
end
defp handle_valid_code(socket, user, secret) do
# Create first TOTP device (new multi-device system)
case Accounts.create_totp_device(user.id, "Primary Device", secret) do
{:ok, _device, _secret} ->
# Generate recovery codes for the user
{:ok, codes} = Accounts.generate_recovery_codes(user.id)
# Show recovery codes to user - they can only be displayed once
{:noreply,
socket
|> put_flash(:info, "Two-factor authentication enabled successfully!")
|> redirect(to: redirect_path)}
|> assign(:enrollment_complete, true)
|> assign(:recovery_codes, codes)
|> assign(:show_recovery_codes, true)}
{:error, _changeset} ->
{:noreply, assign(socket, :error, "Failed to enable two-factor authentication. Please try again.")}
@ -83,146 +141,247 @@ defmodule ToweropsWeb.AccountLive.TotpEnrollment do
<Layouts.authenticated flash={@flash} current_scope={@current_scope}>
<div class="mx-auto max-w-2xl px-4 py-12">
<div class="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-8">
<div class="text-center mb-8">
<.icon
name="hero-shield-check"
class="w-16 h-16 mx-auto mb-4 text-blue-600 dark:text-blue-400"
/>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Set Up Two-Factor Authentication
</h1>
<p class="text-gray-600 dark:text-gray-400">
To keep your account secure, two-factor authentication is required for all users.
</p>
</div>
<div class="space-y-6">
<!-- Step 1: Scan QR Code -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
<span class="flex items-center justify-center w-8 h-8 bg-blue-600 text-white rounded-full mr-3 text-sm">
1
</span>
Install an Authenticator App
</h2>
<p class="text-gray-600 dark:text-gray-400 ml-11 mb-4">
Download an authenticator app on your phone if you haven't already:
<%= if @show_recovery_codes do %>
<!-- Recovery Codes Display -->
<div class="text-center mb-8">
<.icon
name="hero-shield-check"
class="w-16 h-16 mx-auto mb-4 text-green-600 dark:text-green-400"
/>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Save Your Recovery Codes
</h1>
<p class="text-gray-600 dark:text-gray-400">
Two-factor authentication has been enabled successfully!
</p>
<ul class="ml-11 space-y-2 text-gray-700 dark:text-gray-300">
<li class="flex items-center">
<.icon
name="hero-check-circle"
class="w-5 h-5 mr-2 text-green-600 dark:text-green-400"
/> Google Authenticator (iOS, Android)
</li>
<li class="flex items-center">
<.icon
name="hero-check-circle"
class="w-5 h-5 mr-2 text-green-600 dark:text-green-400"
/> Authy (iOS, Android, Desktop)
</li>
<li class="flex items-center">
<.icon
name="hero-check-circle"
class="w-5 h-5 mr-2 text-green-600 dark:text-green-400"
/> 1Password, Bitwarden (with TOTP support)
</li>
</ul>
</div>
<!-- Step 2: Scan QR Code -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
<span class="flex items-center justify-center w-8 h-8 bg-blue-600 text-white rounded-full mr-3 text-sm">
2
</span>
Scan the QR Code
</h2>
<p class="text-gray-600 dark:text-gray-400 ml-11 mb-4">
Open your authenticator app and scan this QR code:
</p>
<div class="ml-11 flex justify-center">
<div class="bg-white p-4 rounded-lg border-2 border-gray-200">
<img src={@qr_code} alt="TOTP QR Code" class="w-64 h-64" />
<div class="space-y-6">
<!-- Warning -->
<div class="rounded-md bg-yellow-50 dark:bg-yellow-900/20 p-4 border border-yellow-200 dark:border-yellow-800">
<div class="flex">
<.icon
name="hero-exclamation-triangle"
class="h-5 w-5 text-yellow-400 mt-0.5"
/>
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-200">
Important: Save these recovery codes now
</h3>
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
<ul class="list-disc pl-5 space-y-1">
<li>These codes will only be shown once and cannot be retrieved later</li>
<li>Store them in a secure location like a password manager</li>
<li>Each code can only be used once</li>
<li>Use these codes if you lose access to your authenticator app</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- Step 3: Enter Code -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
<span class="flex items-center justify-center w-8 h-8 bg-blue-600 text-white rounded-full mr-3 text-sm">
3
</span>
Enter the Code from Your App
</h2>
<.form
for={%{}}
as={:form}
phx-submit="verify_code"
class="ml-11 flex flex-col items-center"
>
<div class="space-y-4 w-full max-w-xs">
<div>
<label
for="code"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 text-center"
>
6-digit code
</label>
<input
type="text"
name="code"
id="code"
inputmode="numeric"
pattern="[0-9]{6}"
maxlength="6"
placeholder="000000"
required
autocomplete="off"
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white shadow-sm focus:border-blue-500 focus:ring-blue-500 text-2xl text-center tracking-widest font-mono"
phx-mounted={JS.focus()}
/>
</div>
<%= if @error do %>
<div class="rounded-md bg-red-50 dark:bg-red-900/30 p-4">
<div class="flex">
<.icon name="hero-exclamation-circle" class="h-5 w-5 text-red-400" />
<div class="ml-3">
<p class="text-sm text-red-800 dark:text-red-200">
{@error}
</p>
</div>
</div>
<!-- Recovery Codes -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 text-center">
Your Recovery Codes
</h2>
<div class="grid grid-cols-2 gap-3 max-w-md mx-auto">
<%= for code <- @recovery_codes do %>
<div class="bg-gray-50 dark:bg-gray-800 rounded-md p-3 text-center">
<code class="text-lg font-mono text-gray-900 dark:text-white font-semibold">
{code}
</code>
</div>
<% end %>
<div>
<.button type="submit" variant="primary" class="w-full">
<.icon name="hero-shield-check" class="w-5 h-5 mr-2" /> Verify and Enable
</.button>
</div>
</div>
</.form>
</div>
</div>
<div class="mt-8 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div class="flex">
<.icon
name="hero-information-circle"
class="h-5 w-5 text-blue-600 dark:text-blue-400 mt-0.5"
/>
<div class="ml-3">
<p class="text-sm text-blue-900 dark:text-blue-100">
<strong>Keep your device safe:</strong>
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.
</p>
<div class="mt-6 flex gap-3 justify-center">
<button
type="button"
phx-click={JS.dispatch("phx:copy", to: "#recovery-codes-text")}
class="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 shadow-sm text-sm font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<.icon name="hero-clipboard-document" class="w-5 h-5 mr-2" /> Copy All
</button>
<a
href={"data:text/plain;charset=utf-8," <> URI.encode(Enum.join(@recovery_codes, "\n"))}
download="towerops-recovery-codes.txt"
class="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 shadow-sm text-sm font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<.icon name="hero-arrow-down-tray" class="w-5 h-5 mr-2" /> Download
</a>
</div>
<!-- Hidden textarea for copy functionality -->
<textarea
id="recovery-codes-text"
class="sr-only"
readonly
>{Enum.join(@recovery_codes, "\n")}</textarea>
</div>
<!-- Continue Button -->
<div class="flex justify-center pt-4">
<button
type="button"
phx-click="confirm_recovery_codes_saved"
class="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
I've Saved My Recovery Codes <.icon name="hero-arrow-right" class="w-5 h-5 ml-2" />
</button>
</div>
</div>
</div>
<% else %>
<!-- Enrollment Form -->
<div class="text-center mb-8">
<.icon
name="hero-shield-check"
class="w-16 h-16 mx-auto mb-4 text-blue-600 dark:text-blue-400"
/>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Set Up Two-Factor Authentication
</h1>
<p class="text-gray-600 dark:text-gray-400">
To keep your account secure, two-factor authentication is required for all users.
</p>
</div>
<div class="space-y-6">
<!-- Step 1: Scan QR Code -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
<span class="flex items-center justify-center w-8 h-8 bg-blue-600 text-white rounded-full mr-3 text-sm">
1
</span>
Install an Authenticator App
</h2>
<p class="text-gray-600 dark:text-gray-400 ml-11 mb-4">
Download an authenticator app on your phone if you haven't already:
</p>
<ul class="ml-11 space-y-2 text-gray-700 dark:text-gray-300">
<li class="flex items-center">
<.icon
name="hero-check-circle"
class="w-5 h-5 mr-2 text-green-600 dark:text-green-400"
/> Google Authenticator (iOS, Android)
</li>
<li class="flex items-center">
<.icon
name="hero-check-circle"
class="w-5 h-5 mr-2 text-green-600 dark:text-green-400"
/> Authy (iOS, Android, Desktop)
</li>
<li class="flex items-center">
<.icon
name="hero-check-circle"
class="w-5 h-5 mr-2 text-green-600 dark:text-green-400"
/> 1Password, Bitwarden (with TOTP support)
</li>
</ul>
</div>
<!-- Step 2: Scan QR Code -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
<span class="flex items-center justify-center w-8 h-8 bg-blue-600 text-white rounded-full mr-3 text-sm">
2
</span>
Scan the QR Code
</h2>
<p class="text-gray-600 dark:text-gray-400 ml-11 mb-4">
Open your authenticator app and scan this QR code:
</p>
<%= if @qr_code do %>
<div class="ml-11 flex justify-center">
<div class="bg-white p-4 rounded-lg border-2 border-gray-200">
<img src={@qr_code} alt="TOTP QR Code" class="w-64 h-64" />
</div>
</div>
<% else %>
<div class="ml-11 flex justify-center">
<div class="bg-gray-100 dark:bg-gray-800 p-4 rounded-lg border-2 border-gray-200 dark:border-gray-700 w-72 h-72 flex items-center justify-center">
<p class="text-gray-500 dark:text-gray-400">Loading QR code...</p>
</div>
</div>
<% end %>
</div>
<!-- Step 3: Enter Code -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
<span class="flex items-center justify-center w-8 h-8 bg-blue-600 text-white rounded-full mr-3 text-sm">
3
</span>
Enter the Code from Your App
</h2>
<.form
for={%{}}
as={:form}
phx-submit="verify_code"
class="ml-11 flex flex-col items-center"
>
<div class="space-y-4 w-full max-w-xs">
<div>
<label
for="code"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 text-center"
>
6-digit code
</label>
<input
type="text"
name="code"
id="code"
inputmode="numeric"
pattern="[0-9]{6}"
maxlength="6"
placeholder="000000"
required
autocomplete="off"
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white shadow-sm focus:border-blue-500 focus:ring-blue-500 text-2xl text-center tracking-widest font-mono"
phx-mounted={JS.focus()}
/>
</div>
<%= if @error do %>
<div class="rounded-md bg-red-50 dark:bg-red-900/30 p-4">
<div class="flex">
<.icon name="hero-exclamation-circle" class="h-5 w-5 text-red-400" />
<div class="ml-3">
<p class="text-sm text-red-800 dark:text-red-200">
{@error}
</p>
</div>
</div>
</div>
<% end %>
<div>
<.button type="submit" variant="primary" class="w-full">
<.icon name="hero-shield-check" class="w-5 h-5 mr-2" /> Verify and Enable
</.button>
</div>
</div>
</.form>
</div>
</div>
<div class="mt-8 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div class="flex">
<.icon
name="hero-information-circle"
class="h-5 w-5 text-blue-600 dark:text-blue-400 mt-0.5"
/>
<div class="ml-3">
<p class="text-sm text-blue-900 dark:text-blue-100">
<strong>Keep your device safe:</strong>
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.
</p>
</div>
</div>
</div>
<% end %>
</div>
</div>
</Layouts.authenticated>

View file

@ -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
</a>
</li>
<li>
<a
href="#"
phx-click="switch_tab"
phx-value-tab="security"
class={
if @active_tab == "security",
do: "text-indigo-600 dark:text-indigo-400",
else: ""
}
>
Security
</a>
</li>
<li>
<.link
navigate={~p"/users/my-data"}
@ -1193,6 +1363,170 @@ defmodule ToweropsWeb.UserSettingsLive do
</div>
</div>
<% end %>
<!-- Security Tab -->
<%= if @active_tab == "security" do %>
<!-- Authenticator Apps Section -->
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-10 px-4 py-16 sm:px-6 md:grid-cols-3 lg:px-8">
<div>
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
Authenticator Apps
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
Manage TOTP devices for two-factor authentication. You must have at least one device configured.
</p>
</div>
<div class="md:col-span-2">
<%= if Enum.empty?(@totp_devices) do %>
<div class="text-center rounded-lg border border-dashed border-gray-300 px-6 py-10 dark:border-gray-700">
<.icon name="hero-device-phone-mobile" class="mx-auto h-12 w-12 text-gray-400" />
<h3 class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
No authenticator apps
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Add your first authenticator app to enable two-factor authentication.
</p>
<div class="mt-6">
<button
type="button"
phx-click="show_add_device_modal"
class="inline-flex items-center gap-x-1.5 rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:hover:bg-indigo-400"
>
<.icon name="hero-plus" class="-ml-0.5 h-5 w-5" /> Add Device
</button>
</div>
</div>
<% else %>
<ul role="list" class="divide-y divide-gray-200 dark:divide-white/10">
<%= for device <- @totp_devices do %>
<li class="flex items-center justify-between gap-x-6 py-5">
<div class="min-w-0 flex-1">
<div class="flex items-start gap-x-3">
<p class="text-sm/6 font-semibold text-gray-900 dark:text-white">
{device.name}
</p>
</div>
<div class="mt-1 flex items-center gap-x-2 text-xs/5 text-gray-500 dark:text-gray-400">
<p class="whitespace-nowrap">
Added {Calendar.strftime(device.inserted_at, "%B %d, %Y")}
</p>
<%= if device.last_used_at do %>
<svg viewBox="0 0 2 2" class="h-0.5 w-0.5 fill-current">
<circle r="1" cx="1" cy="1" />
</svg>
<p class="whitespace-nowrap">
Last used {format_relative_time(device.last_used_at)}
</p>
<% end %>
</div>
</div>
<button
type="button"
phx-click="delete_device"
phx-value-device-id={device.id}
disabled={length(@totp_devices) == 1}
data-confirm={
if length(@totp_devices) > 1,
do: "Are you sure you want to remove this device?"
}
class={
"rounded-md px-2.5 py-1.5 text-sm font-semibold shadow-xs inset-ring-1 " <>
if length(@totp_devices) == 1 do
"bg-gray-100 text-gray-400 inset-ring-gray-200 cursor-not-allowed dark:bg-gray-800 dark:text-gray-600 dark:inset-ring-gray-700"
else
"bg-white text-gray-900 inset-ring-gray-300 hover:bg-gray-100 dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/10 dark:hover:bg-white/20"
end
}
>
Remove
</button>
</li>
<% end %>
</ul>
<div class="mt-6 flex">
<button
type="button"
phx-click="show_add_device_modal"
class="inline-flex items-center gap-x-1.5 rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:hover:bg-indigo-400"
>
<.icon name="hero-plus" class="-ml-0.5 h-5 w-5" /> Add Device
</button>
</div>
<% end %>
</div>
</div>
<!-- Recovery Codes Section -->
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-10 px-4 py-16 sm:px-6 md:grid-cols-3 lg:px-8 border-t border-gray-200 dark:border-white/10">
<div>
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
Recovery Codes
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
Single-use backup codes for account access if you lose your authenticator app.
</p>
</div>
<div class="md:col-span-2">
<div class="rounded-lg border border-gray-200 bg-white px-6 py-5 dark:border-white/10 dark:bg-white/5">
<div class="flex items-center justify-between">
<div class="flex-1">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
Available Recovery Codes
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
<%= if @unused_recovery_codes_count == 0 do %>
<span class="text-red-600 dark:text-red-400 font-medium">
No recovery codes available
</span>
- Generate new codes immediately
<% else %>
You have
<span class="font-medium text-gray-900 dark:text-white">
{@unused_recovery_codes_count}
</span>
unused recovery code{if @unused_recovery_codes_count != 1, do: "s"}
<% end %>
</p>
</div>
<div class="ml-4">
<button
type="button"
phx-click="regenerate_recovery_codes"
data-confirm="This will invalidate all existing unused recovery codes. Continue?"
class="inline-flex items-center gap-x-1.5 rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:hover:bg-indigo-400"
>
<.icon name="hero-arrow-path" class="-ml-0.5 h-5 w-5" /> Regenerate
</button>
</div>
</div>
</div>
<%= if @unused_recovery_codes_count == 0 do %>
<div class="mt-4 rounded-lg bg-amber-50 p-4 dark:bg-amber-900/20">
<div class="flex">
<div class="flex-shrink-0">
<.icon name="hero-exclamation-triangle" class="h-5 w-5 text-amber-400" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-amber-800 dark:text-amber-200">
No Recovery Codes
</h3>
<div class="mt-2 text-sm text-amber-700 dark:text-amber-300">
<p>
You have no recovery codes available. If you lose access to your authenticator app,
you won't be able to log in. Generate new codes now.
</p>
</div>
</div>
</div>
</div>
<% end %>
</div>
</div>
<% end %>
</div>
<!-- Revoke All Sessions Confirmation Modal -->
@ -1437,6 +1771,280 @@ defmodule ToweropsWeb.UserSettingsLive do
</div>
<% end %>
<!-- Add TOTP Device Modal -->
<%= if @show_add_device_modal do %>
<div
class="fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="add-device-modal-title"
role="dialog"
aria-modal="true"
>
<div class="flex min-h-screen items-end justify-center px-4 pb-20 pt-4 text-center sm:block sm:p-0">
<div
phx-click="cancel_add_device"
class="fixed inset-0 z-0 bg-gray-500 bg-opacity-75 transition-opacity dark:bg-gray-950 dark:bg-opacity-75"
aria-hidden="true"
>
</div>
<span
class="relative z-10 hidden sm:inline-block sm:h-screen sm:align-middle"
aria-hidden="true"
>
&#8203;
</span>
<div class="relative z-10 inline-block transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left align-bottom shadow-xl transition-all dark:bg-gray-800/95 sm:my-8 sm:w-full sm:max-w-lg sm:p-6 sm:align-middle">
<.form for={%{}} as={:device} phx-submit="create_device">
<div>
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-indigo-100 dark:bg-indigo-900">
<.icon
name="hero-device-phone-mobile"
class="h-6 w-6 text-indigo-600 dark:text-indigo-400"
/>
</div>
<div class="mt-3 text-center sm:mt-5">
<h3
class="text-lg font-semibold leading-6 text-gray-900 dark:text-white"
id="add-device-modal-title"
>
Add Authenticator Device
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500 dark:text-gray-400">
Give this device a name to help you identify it (e.g., "iPhone 15", "iPad Pro").
</p>
</div>
<div class="mt-4">
<input
type="text"
name="name"
placeholder="e.g., iPhone 15"
required
autofocus
class="block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500"
/>
</div>
</div>
</div>
<div class="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
<button
type="submit"
class="inline-flex w-full justify-center rounded-lg bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 sm:col-start-2"
>
Continue
</button>
<button
type="button"
phx-click="cancel_add_device"
class="mt-3 inline-flex w-full justify-center rounded-lg bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800/50 dark:text-white dark:ring-white/10 dark:hover:bg-gray-800 sm:col-start-1 sm:mt-0"
>
Cancel
</button>
</div>
</.form>
</div>
</div>
</div>
<% end %>
<!-- Device QR Code Modal -->
<%= if @show_device_qr_modal && @new_device_qr_code do %>
<div
class="fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="device-qr-modal-title"
role="dialog"
aria-modal="true"
>
<div class="flex min-h-screen items-end justify-center px-4 pb-20 pt-4 text-center sm:block sm:p-0">
<div
phx-click="close_device_qr_modal"
class="fixed inset-0 z-0 bg-gray-500 bg-opacity-75 transition-opacity dark:bg-gray-950 dark:bg-opacity-75"
aria-hidden="true"
>
</div>
<span
class="relative z-10 hidden sm:inline-block sm:h-screen sm:align-middle"
aria-hidden="true"
>
&#8203;
</span>
<div class="relative z-10 inline-block transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left align-bottom shadow-xl transition-all dark:bg-gray-800/95 sm:my-8 sm:w-full sm:max-w-lg sm:p-6 sm:align-middle">
<div>
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-indigo-100 dark:bg-indigo-900">
<.icon name="hero-qr-code" class="h-6 w-6 text-indigo-600 dark:text-indigo-400" />
</div>
<div class="mt-3 text-center sm:mt-5">
<h3
class="text-lg font-semibold leading-6 text-gray-900 dark:text-white"
id="device-qr-modal-title"
>
Scan QR Code
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500 dark:text-gray-400">
Scan this QR code with your authenticator app (Google Authenticator, Authy, 1Password, etc.).
</p>
</div>
<!-- QR Code -->
<div class="mt-6 flex justify-center">
<div class="inline-block rounded-lg bg-white p-4">
<img src={@new_device_qr_code} alt="TOTP QR Code" class="h-48 w-48" />
</div>
</div>
<!-- Verification Code Input -->
<div class="mt-6">
<p class="mb-2 text-sm font-medium text-gray-900 dark:text-white">
Enter the 6-digit code from your app to verify
</p>
<.form for={%{}} as={:verification} phx-submit="verify_new_device">
<div class="flex justify-center gap-3">
<input
type="text"
name="code"
placeholder="000000"
maxlength="6"
pattern="[0-9]{6}"
inputmode="numeric"
autocomplete="one-time-code"
required
autofocus
class="block w-32 rounded-md bg-white px-3 py-2 text-center text-lg font-mono text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500"
/>
<button
type="submit"
class="inline-flex items-center gap-x-1.5 rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
Verify
</button>
</div>
</.form>
</div>
</div>
</div>
<div class="mt-5 sm:mt-6">
<button
type="button"
phx-click="close_device_qr_modal"
class="inline-flex w-full justify-center rounded-lg bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800/50 dark:text-white dark:ring-white/10 dark:hover:bg-gray-800"
>
Cancel
</button>
</div>
</div>
</div>
</div>
<% end %>
<!-- Recovery Codes Display Modal -->
<%= if @show_recovery_codes_modal && @generated_recovery_codes do %>
<div
class="fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="recovery-codes-modal-title"
role="dialog"
aria-modal="true"
>
<div class="flex min-h-screen items-end justify-center px-4 pb-20 pt-4 text-center sm:block sm:p-0">
<div
class="fixed inset-0 z-0 bg-gray-500 bg-opacity-75 transition-opacity dark:bg-gray-950 dark:bg-opacity-75"
aria-hidden="true"
>
</div>
<span
class="relative z-10 hidden sm:inline-block sm:h-screen sm:align-middle"
aria-hidden="true"
>
&#8203;
</span>
<div class="relative z-10 inline-block transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left align-bottom shadow-xl transition-all dark:bg-gray-800/95 sm:my-8 sm:w-full sm:max-w-2xl sm:p-6 sm:align-middle">
<div>
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-900">
<.icon name="hero-shield-check" class="h-6 w-6 text-green-600 dark:text-green-400" />
</div>
<div class="mt-3 text-center sm:mt-5">
<h3
class="text-lg font-semibold leading-6 text-gray-900 dark:text-white"
id="recovery-codes-modal-title"
>
Recovery Codes Generated
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500 dark:text-gray-400">
Save these codes in a secure location. Each code can only be used once.
</p>
</div>
<!-- Recovery Codes Grid -->
<div class="mt-6">
<div class="rounded-lg bg-gray-50 p-6 dark:bg-gray-900/50">
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
<%= for code <- @generated_recovery_codes do %>
<div class="rounded-md bg-white px-3 py-2 font-mono text-sm text-gray-900 shadow-xs dark:bg-gray-800 dark:text-white">
{code}
</div>
<% end %>
</div>
</div>
</div>
<!-- Copy to Clipboard -->
<div class="mt-4 flex justify-center gap-3">
<button
type="button"
phx-hook="CopyToClipboard"
data-target="#recovery-codes-text"
id="copy-recovery-codes"
class="inline-flex items-center gap-x-1.5 rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
<.icon name="hero-clipboard" class="-ml-0.5 h-5 w-5" /> Copy All
</button>
</div>
<!-- Hidden textarea for copy functionality -->
<textarea
id="recovery-codes-text"
class="sr-only"
readonly
>{Enum.join(@generated_recovery_codes, "\n")}</textarea>
<!-- Warning -->
<div class="mt-6 rounded-lg bg-amber-50 p-4 dark:bg-amber-900/20">
<div class="flex">
<div class="flex-shrink-0">
<.icon name="hero-exclamation-triangle" class="h-5 w-5 text-amber-400" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-amber-800 dark:text-amber-200">
Important
</h3>
<div class="mt-2 text-sm text-amber-700 dark:text-amber-300">
<p>
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.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="mt-5 sm:mt-6">
<button
type="button"
phx-click="close_recovery_codes_modal"
class="inline-flex w-full justify-center rounded-lg bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
I've Saved These Codes
</button>
</div>
</div>
</div>
</div>
<% end %>
<!-- Passkey Registration Modal -->
<div
id="passkey-modal"

View file

@ -0,0 +1,20 @@
defmodule Towerops.Repo.Migrations.CreateUserTotpDevices do
use Ecto.Migration
def change do
create table(:user_totp_devices, primary_key: false) do
add :id, :binary_id, primary_key: true
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
add :name, :string, null: false
add :totp_secret, :binary, null: false
add :last_used_at, :utc_datetime
add :created_at, :utc_datetime, null: false
timestamps(type: :utc_datetime)
end
create index(:user_totp_devices, [:user_id])
create index(:user_totp_devices, [:user_id, :last_used_at])
end
end

View file

@ -0,0 +1,20 @@
defmodule Towerops.Repo.Migrations.CreateUserRecoveryCodes do
use Ecto.Migration
def change do
create table(:user_recovery_codes, primary_key: false) do
add :id, :binary_id, primary_key: true
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
add :code_hash, :string, null: false
add :used_at, :utc_datetime
add :created_at, :utc_datetime, null: false
timestamps(type: :utc_datetime, updated_at: false)
end
create index(:user_recovery_codes, [:user_id])
create index(:user_recovery_codes, [:user_id, :used_at])
create unique_index(:user_recovery_codes, [:code_hash])
end
end

View file

@ -8,6 +8,7 @@ Devices Tested & Working
* Backend refactoring
* Patched some potential memory leaks
* Show version and license for Mikrotik devices
* MFA Recovery keys and MFA management
2026-01-30
* Completely overhaul poller agent to not track any state

View file

@ -481,4 +481,295 @@ defmodule Towerops.AccountsTest do
assert {:error, :invalid_code} = Accounts.verify_user_totp(updated_user, "000000")
end
end
describe "TOTP Device Management" do
setup do
user = user_fixture()
{:ok, user: user}
end
test "list_user_totp_devices/1 returns empty list for user with no devices", %{user: user} do
assert [] = Accounts.list_user_totp_devices(user.id)
end
test "list_user_totp_devices/1 returns devices ordered by last_used_at", %{user: user} do
{:ok, _device1, _secret1} = Accounts.create_totp_device(user.id, "Device 1")
{:ok, device2, secret2} = Accounts.create_totp_device(user.id, "Device 2")
# Use device2
code = NimbleTOTP.verification_code(secret2)
{:ok, _} = Accounts.verify_user_totp_any_device(user, code)
devices = Accounts.list_user_totp_devices(user.id)
assert length(devices) == 2
# Device 2 should be first because it was used
assert hd(devices).id == device2.id
end
test "count_user_totp_devices/1 returns correct count", %{user: user} do
assert 0 = Accounts.count_user_totp_devices(user.id)
{:ok, _device1, _} = Accounts.create_totp_device(user.id, "Device 1")
assert 1 = Accounts.count_user_totp_devices(user.id)
{:ok, _device2, _} = Accounts.create_totp_device(user.id, "Device 2")
assert 2 = Accounts.count_user_totp_devices(user.id)
end
test "create_totp_device/2 creates device with unique secret", %{user: user} do
{:ok, device, secret} = Accounts.create_totp_device(user.id, "iPhone 15")
assert device.name == "iPhone 15"
assert device.user_id == user.id
assert is_binary(secret)
# NimbleTOTP default is 20 bytes
assert byte_size(secret) == 20
assert is_binary(device.totp_secret)
end
test "create_totp_device/2 validates device name length", %{user: user} do
long_name = String.duplicate("a", 101)
{:error, changeset} = Accounts.create_totp_device(user.id, long_name)
assert "should be at most 100 character(s)" in errors_on(changeset).name
end
test "verify_user_totp_any_device/2 verifies code against any user device", %{user: user} do
{:ok, device1, secret1} = Accounts.create_totp_device(user.id, "Device 1")
{:ok, device2, secret2} = Accounts.create_totp_device(user.id, "Device 2")
# Test with device 1 code
code1 = NimbleTOTP.verification_code(secret1)
assert {:ok, verified_device} = Accounts.verify_user_totp_any_device(user, code1)
assert verified_device.id == device1.id
# Test with device 2 code
code2 = NimbleTOTP.verification_code(secret2)
assert {:ok, verified_device} = Accounts.verify_user_totp_any_device(user, code2)
assert verified_device.id == device2.id
end
test "verify_user_totp_any_device/2 updates last_used_at on successful verification", %{user: user} do
{:ok, device, secret} = Accounts.create_totp_device(user.id, "Device")
assert is_nil(device.last_used_at)
code = NimbleTOTP.verification_code(secret)
{:ok, updated_device} = Accounts.verify_user_totp_any_device(user, code)
assert updated_device.last_used_at
assert DateTime.diff(updated_device.last_used_at, DateTime.utc_now(:second)) <= 1
end
test "verify_user_totp_any_device/2 returns error for invalid code", %{user: user} do
{:ok, _device, _secret} = Accounts.create_totp_device(user.id, "Device")
assert {:error, :invalid_code} = Accounts.verify_user_totp_any_device(user, "000000")
end
test "delete_totp_device/2 prevents deletion of last device", %{user: user} do
{:ok, device, _} = Accounts.create_totp_device(user.id, "Only Device")
assert {:error, :last_device} = Accounts.delete_totp_device(device.id, user.id)
assert 1 = Accounts.count_user_totp_devices(user.id)
end
test "delete_totp_device/2 allows deletion when multiple devices exist", %{user: user} do
{:ok, device1, _} = Accounts.create_totp_device(user.id, "Device 1")
{:ok, device2, _} = Accounts.create_totp_device(user.id, "Device 2")
assert {:ok, _} = Accounts.delete_totp_device(device1.id, user.id)
assert 1 = Accounts.count_user_totp_devices(user.id)
devices = Accounts.list_user_totp_devices(user.id)
assert length(devices) == 1
assert hd(devices).id == device2.id
end
test "delete_totp_device/2 returns error for non-existent device", %{user: user} do
fake_id = Ecto.UUID.generate()
assert {:error, :not_found} = Accounts.delete_totp_device(fake_id, user.id)
end
test "delete_totp_device/2 returns error for unauthorized user" do
user1 = user_fixture()
user2 = user_fixture()
{:ok, device, _} = Accounts.create_totp_device(user1.id, "Device")
# So we have multiple
{:ok, _device2, _} = Accounts.create_totp_device(user1.id, "Device 2")
assert {:error, :unauthorized} = Accounts.delete_totp_device(device.id, user2.id)
end
test "rename_totp_device/3 updates device name", %{user: user} do
{:ok, device, _} = Accounts.create_totp_device(user.id, "Old Name")
assert {:ok, updated_device} = Accounts.rename_totp_device(device.id, user.id, "New Name")
assert updated_device.name == "New Name"
end
test "rename_totp_device/3 returns error for non-existent device", %{user: user} do
fake_id = Ecto.UUID.generate()
assert {:error, :not_found} = Accounts.rename_totp_device(fake_id, user.id, "New Name")
end
test "rename_totp_device/3 returns error for unauthorized user" do
user1 = user_fixture()
user2 = user_fixture()
{:ok, device, _} = Accounts.create_totp_device(user1.id, "Device")
assert {:error, :unauthorized} = Accounts.rename_totp_device(device.id, user2.id, "Hacked")
end
end
describe "Recovery Codes" do
setup do
user = user_fixture()
{:ok, user: user}
end
test "generate_recovery_codes/1 generates 12 unique codes", %{user: user} do
{:ok, codes} = Accounts.generate_recovery_codes(user.id)
assert length(codes) == 12
# All unique
assert length(Enum.uniq(codes)) == 12
# Check format: XXXX-XXXX
Enum.each(codes, fn code ->
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

View file

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

View file

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