Add mandatory TOTP MFA

This commit is contained in:
Graham McIntire 2026-01-28 15:09:38 -06:00
parent 1f6151b5a8
commit 0995abbccb
10 changed files with 559 additions and 9 deletions

View file

@ -154,6 +154,88 @@ defmodule Towerops.Accounts do
end
end
## TOTP / Two-Factor Authentication
@doc """
Generates a new TOTP secret for a user.
Returns a binary secret that should be stored temporarily until verified.
"""
def generate_totp_secret do
NimbleTOTP.secret()
end
@doc """
Generates an otpauth URI for displaying as a QR code.
## Examples
iex> generate_totp_uri(user, secret)
"otpauth://totp/Towerops:user@example.com?secret=SECRET&issuer=Towerops"
"""
def generate_totp_uri(%User{} = user, secret) do
NimbleTOTP.otpauth_uri("Towerops:#{user.email}", secret, issuer: "Towerops")
end
@doc """
Generates a QR code as a data URI for the TOTP URI.
Returns a base64-encoded PNG image as a data URI that can be displayed in an img tag.
"""
def generate_totp_qr_code(%User{} = user, secret) do
uri = generate_totp_uri(user, secret)
uri
|> EQRCode.encode()
|> EQRCode.png()
|> Base.encode64()
|> then(&"data:image/png;base64,#{&1}")
end
@doc """
Verifies a TOTP code against a secret.
Returns true if the code is valid within the time window, false otherwise.
"""
def verify_totp(secret, code) when is_binary(secret) and is_binary(code) do
# Allow 30 second window for time drift
NimbleTOTP.valid?(secret, code, since: System.system_time(:second) - 30)
end
@doc """
Enables TOTP for a user by saving the secret.
The secret should have been verified before calling this function.
"""
def enable_totp(%User{} = user, secret) when is_binary(secret) do
user
|> Ecto.Changeset.change(totp_secret: secret)
|> Repo.update()
end
@doc """
Checks if a user has TOTP enabled.
"""
def totp_enabled?(%User{totp_secret: secret}) when is_binary(secret), do: true
def totp_enabled?(_user), do: false
@doc """
Verifies a TOTP code for a user who has TOTP enabled.
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
if verify_totp(secret, code) do
{:ok, user}
else
{:error, :invalid_code}
end
end
def verify_user_totp(%User{}, _code) do
{:error, :totp_not_enabled}
end
## Settings
@doc """

View file

@ -5,6 +5,7 @@ defmodule Towerops.Accounts.User do
Users can own or be members of multiple organizations and authenticate via:
- Email/password
- WebAuthn passkeys
- TOTP (Time-based One-Time Password) two-factor authentication
- Session tokens
"""
use Ecto.Schema
@ -27,6 +28,8 @@ defmodule Towerops.Accounts.User do
field :name, :string
field :avatar_url, :string
field :timezone, :string
field :totp_secret, :binary, redact: true
field :totp_verified_at, :utc_datetime, virtual: true
has_many :memberships, Membership
has_many :organizations, through: [:memberships, :organization]
@ -46,6 +49,8 @@ defmodule Towerops.Accounts.User do
name: String.t() | nil,
avatar_url: String.t() | nil,
timezone: String.t(),
totp_secret: binary() | nil,
totp_verified_at: DateTime.t() | nil,
memberships: NotLoaded.t() | [Membership.t()],
organizations: NotLoaded.t() | [Towerops.Organizations.Organization.t()],
credentials: NotLoaded.t() | [UserCredential.t()],

View file

@ -0,0 +1,206 @@
defmodule ToweropsWeb.AccountLive.TotpEnrollment do
@moduledoc """
LiveView for mandatory TOTP enrollment during user registration.
New users must enroll a TOTP device before accessing the application.
"""
use ToweropsWeb, :live_view
alias Towerops.Accounts
@impl true
def mount(_params, _session, socket) do
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
secret = Accounts.generate_totp_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)
{:ok, socket}
end
end
@impl true
def handle_event("verify_code", %{"code" => code}, socket) do
user = socket.assigns.current_scope.user
secret = socket.assigns.secret
if Accounts.verify_totp(secret, code) do
case Accounts.enable_totp(user, secret) do
{:ok, _user} ->
{:noreply,
socket
|> put_flash(:info, "Two-factor authentication enabled successfully!")
|> redirect(to: ~p"/devices")}
{:error, _changeset} ->
{:noreply, assign(socket, :error, "Failed to enable two-factor authentication. Please try again.")}
end
else
{:noreply, assign(socket, :error, "Invalid code. Please try again.")}
end
end
@impl true
def render(assigns) do
~H"""
<Layouts.app 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:
</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>
</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"
>
<div class="space-y-4">
<div>
<label
for="code"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
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 max-w-xs 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 max-w-xs">
<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 max-w-xs">
<.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>
</div>
</div>
</Layouts.app>
"""
end
end

View file

@ -190,6 +190,7 @@ defmodule ToweropsWeb.Router do
on_mount: [
{ToweropsWeb.UserAuth, :load_cookie_consent},
{ToweropsWeb.UserAuth, :require_authenticated_user},
{ToweropsWeb.UserAuth, :require_totp_enrollment},
{ToweropsWeb.UserAuth, :require_superuser}
] do
scope "/admin", ToweropsWeb.Admin do
@ -207,6 +208,7 @@ defmodule ToweropsWeb.Router do
on_mount: [
{ToweropsWeb.UserAuth, :load_cookie_consent},
{ToweropsWeb.UserAuth, :require_authenticated_user},
{ToweropsWeb.UserAuth, :require_totp_enrollment},
{ToweropsWeb.UserAuth, :require_sudo_mode}
] do
scope "/", ToweropsWeb do
@ -217,7 +219,8 @@ defmodule ToweropsWeb.Router do
end
end
live_session :require_authenticated_user,
# TOTP enrollment page - requires auth but NOT TOTP (since user is enrolling)
live_session :totp_enrollment,
on_mount: [
{ToweropsWeb.UserAuth, :load_cookie_consent},
{ToweropsWeb.UserAuth, :require_authenticated_user}
@ -225,6 +228,19 @@ defmodule ToweropsWeb.Router do
scope "/", ToweropsWeb do
pipe_through [:browser, :require_authenticated_user]
live "/account/totp-enrollment", AccountLive.TotpEnrollment, :index
end
end
live_session :require_authenticated_user,
on_mount: [
{ToweropsWeb.UserAuth, :load_cookie_consent},
{ToweropsWeb.UserAuth, :require_authenticated_user},
{ToweropsWeb.UserAuth, :require_totp_enrollment}
] do
scope "/", ToweropsWeb do
pipe_through [:browser, :require_authenticated_user]
live "/mobile/qr-login", MobileQRLive, :index
live "/orgs", OrgLive.Index, :index
live "/orgs/new", OrgLive.New, :new
@ -235,6 +251,7 @@ defmodule ToweropsWeb.Router do
on_mount: [
{ToweropsWeb.UserAuth, :load_cookie_consent},
{ToweropsWeb.UserAuth, :require_authenticated_user},
{ToweropsWeb.UserAuth, :require_totp_enrollment},
{ToweropsWeb.UserAuth, :load_current_organization}
] do
scope "/orgs/:org_slug", ToweropsWeb do
@ -249,6 +266,7 @@ defmodule ToweropsWeb.Router do
on_mount: [
{ToweropsWeb.UserAuth, :load_cookie_consent},
{ToweropsWeb.UserAuth, :require_authenticated_user},
{ToweropsWeb.UserAuth, :require_totp_enrollment},
{ToweropsWeb.UserAuth, :load_default_organization}
] do
scope "/", ToweropsWeb do

View file

@ -257,15 +257,20 @@ defmodule ToweropsWeb.UserAuth do
end
defp signed_in_path(user) when is_struct(user) do
# Get user's organizations (ordered by most recently joined first)
case Towerops.Organizations.list_user_organizations(user.id) do
[_first_org | _] ->
# Devices page
~p"/devices"
# Check if user has TOTP enabled (mandatory for all users)
if Accounts.totp_enabled?(user) do
# Get user's organizations (ordered by most recently joined first)
case Towerops.Organizations.list_user_organizations(user.id) do
[_first_org | _] ->
# Devices page
~p"/devices"
[] ->
# No organizations yet, go to org list
~p"/orgs"
[] ->
# No organizations yet, go to org list
~p"/orgs"
end
else
~p"/account/totp-enrollment"
end
end
@ -498,6 +503,21 @@ defmodule ToweropsWeb.UserAuth do
end
end
def on_mount(:require_totp_enrollment, _params, _session, socket) do
user = socket.assigns.current_scope && socket.assigns.current_scope.user
if user && !Accounts.totp_enabled?(user) do
socket =
socket
|> LiveView.put_flash(:error, "You must set up two-factor authentication to continue.")
|> LiveView.redirect(to: ~p"/account/totp-enrollment")
{:halt, socket}
else
{:cont, socket}
end
end
def on_mount(:load_cookie_consent, _params, session, socket) do
requires_consent = Map.get(session, "requires_cookie_consent", false)

View file

@ -42,6 +42,8 @@ defmodule Towerops.MixProject do
defp deps do
[
{:bcrypt_elixir, "~> 3.0"},
{:nimble_totp, "~> 1.0"},
{:eqrcode, "~> 0.1.10"},
{:phoenix, "~> 1.8.3"},
{:phoenix_ecto, "~> 4.5"},
{:ecto_sql, "~> 3.13"},

View file

@ -14,6 +14,7 @@
"ecto_psql_extras": {:hex, :ecto_psql_extras, "0.8.8", "aa02529c97f69aed5722899f5dc6360128735a92dd169f23c5d50b1f7fdede08", [:mix], [{:ecto_sql, "~> 3.7", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:postgrex, "> 0.16.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:table_rex, "~> 3.1.1 or ~> 4.0", [hex: :table_rex, repo: "hexpm", optional: false]}], "hexpm", "04c63d92b141723ad6fed2e60a4b461ca00b3594d16df47bbc48f1f4534f2c49"},
"ecto_sql": {:hex, :ecto_sql, "3.13.4", "b6e9d07557ddba62508a9ce4a484989a5bb5e9a048ae0e695f6d93f095c25d60", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2b38cf0749ca4d1c5a8bcbff79bbe15446861ca12a61f9fba604486cb6b62a14"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"eqrcode": {:hex, :eqrcode, "0.1.10", "6294fece9d68ad64eef1c3c92cf111cfd6469f4fbf230a2d4cc905a682178f3f", [:mix], [], "hexpm", "da30e373c36a0fd37ab6f58664b16029919896d6c45a68a95cc4d713e81076f1"},
"erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"},
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
@ -37,6 +38,7 @@
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_ownership": {:hex, :nimble_ownership, "1.0.2", "fa8a6f2d8c592ad4d79b2ca617473c6aefd5869abfa02563a77682038bf916cf", [:mix], [], "hexpm", "098af64e1f6f8609c6672127cfe9e9590a5d3fcdd82bc17a377b8692fd81a879"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"nimble_totp": {:hex, :nimble_totp, "1.0.0", "79753bae6ce59fd7cacdb21501a1dbac249e53a51c4cd22b34fa8438ee067283", [:mix], [], "hexpm", "6ce5e4c068feecdb782e85b18237f86f66541523e6bad123e02ee1adbe48eda9"},
"oban": {:hex, :oban, "2.20.3", "e4d27336941955886cc7113420c32c63b70b64f10b27e08e3cf2b001153953cd", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "075ffbf1279a96bec495bc63d647b08929837d70bcc0427249ffe4d1dddaec33"},
"oban_met": {:hex, :oban_met, "1.0.5", "bb633ab06448dab2ef9194f6688d33b3d07fc3f2ad793a1a08f4dfbb2cc9fe50", [:mix], [{:oban, "~> 2.19", [hex: :oban, repo: "hexpm", optional: false]}], "hexpm", "64664d50805bbfd3903aeada1f3c39634652a87844797ee400b0bcc95a28f5ea"},
"oban_web": {:hex, :oban_web, "2.11.7", "a998868bb32b3d3c44f328a8baec820af5c3b46a3a743d89c169e75b0bf7efcc", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, "~> 2.19", [hex: :oban, repo: "hexpm", optional: false]}, {:oban_met, "~> 1.0", [hex: :oban_met, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}], "hexpm", "f0ef9ec8f97381a0287c60a732736ff77bc351cbbe82eceffb5a635f84788656"},

View file

@ -0,0 +1,13 @@
defmodule Towerops.Repo.Migrations.AddTotpToUsers do
use Ecto.Migration
def change do
alter table(:users) do
add :totp_secret, :binary
end
alter table(:users_tokens) do
add :totp_verified_at, :utc_datetime
end
end
end

View file

@ -391,4 +391,93 @@ defmodule Towerops.AccountsTest do
refute inspect(%User{password: "123456"}) =~ "password: \"123456\""
end
end
describe "TOTP enrollment" do
setup do
%{user: user_fixture()}
end
test "generate_totp_secret/0 generates a valid secret" do
secret = Accounts.generate_totp_secret()
assert is_binary(secret)
assert byte_size(secret) > 0
end
test "generate_totp_uri/2 creates valid otpauth URI", %{user: user} do
secret = Accounts.generate_totp_secret()
uri = Accounts.generate_totp_uri(user, secret)
assert uri =~ "otpauth://totp/Towerops:#{user.email}"
assert uri =~ "secret="
assert uri =~ "issuer=Towerops"
end
test "generate_totp_qr_code/2 creates base64-encoded data URI", %{user: user} do
secret = Accounts.generate_totp_secret()
qr_code = Accounts.generate_totp_qr_code(user, secret)
assert String.starts_with?(qr_code, "data:image/png;base64,")
# Verify it's valid base64 after the prefix
[_, base64] = String.split(qr_code, ",", parts: 2)
assert {:ok, _} = Base.decode64(base64)
end
test "verify_totp/2 validates correct TOTP codes" do
secret = Accounts.generate_totp_secret()
code = NimbleTOTP.verification_code(secret)
assert Accounts.verify_totp(secret, code)
end
test "verify_totp/2 rejects incorrect TOTP codes" do
secret = Accounts.generate_totp_secret()
refute Accounts.verify_totp(secret, "000000")
refute Accounts.verify_totp(secret, "invalid")
end
test "verify_totp/2 accepts codes within time window" do
secret = Accounts.generate_totp_secret()
# Generate code for current time (should always be valid)
code = NimbleTOTP.verification_code(secret)
# Verify it works with the time window check (since: -30)
assert Accounts.verify_totp(secret, code)
end
test "enable_totp/2 saves TOTP secret to user", %{user: user} do
secret = Accounts.generate_totp_secret()
assert {:ok, updated_user} = Accounts.enable_totp(user, secret)
assert updated_user.totp_secret == secret
assert Accounts.totp_enabled?(updated_user)
end
test "totp_enabled?/1 returns false for user without TOTP", %{user: user} do
refute Accounts.totp_enabled?(user)
end
test "totp_enabled?/1 returns true for user with TOTP", %{user: user} do
secret = Accounts.generate_totp_secret()
{:ok, updated_user} = Accounts.enable_totp(user, secret)
assert Accounts.totp_enabled?(updated_user)
end
test "verify_user_totp/2 validates codes for enrolled user", %{user: user} do
secret = Accounts.generate_totp_secret()
{:ok, updated_user} = Accounts.enable_totp(user, secret)
code = NimbleTOTP.verification_code(secret)
assert {:ok, %User{}} = Accounts.verify_user_totp(updated_user, code)
end
test "verify_user_totp/2 rejects invalid codes for enrolled user", %{user: user} do
secret = Accounts.generate_totp_secret()
{:ok, updated_user} = Accounts.enable_totp(user, secret)
assert {:error, :invalid_code} = Accounts.verify_user_totp(updated_user, "000000")
end
end
end

View file

@ -0,0 +1,113 @@
defmodule ToweropsWeb.AccountLive.TotpEnrollmentTest do
use ToweropsWeb.ConnCase
import Phoenix.LiveViewTest
import Towerops.AccountsFixtures
alias Towerops.Accounts
describe "TOTP Enrollment Page - unenrolled user" do
setup do
user = user_fixture()
%{conn: log_in_user(build_conn(), user), user: user}
end
test "renders enrollment page with QR code", %{conn: conn} do
{:ok, view, html} = live(conn, ~p"/account/totp-enrollment")
assert html =~ "Set Up Two-Factor Authentication"
assert html =~ "Install an Authenticator App"
assert html =~ "Scan the QR Code"
assert html =~ "Enter the Code from Your App"
# Verify QR code is present (as base64 data URI)
assert view |> element("img[alt='TOTP QR Code']") |> has_element?()
end
test "accepts valid TOTP code and enables TOTP", %{conn: conn, user: user} 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
# Generate a valid code
code = NimbleTOTP.verification_code(secret)
# Submit the form
view
|> form("form", %{code: code})
|> render_submit()
# Should redirect to devices page
assert_redirect(view, ~p"/devices")
# Verify TOTP was enabled in database
updated_user = Accounts.get_user!(user.id)
assert Accounts.totp_enabled?(updated_user)
assert updated_user.totp_secret == secret
end
test "rejects invalid TOTP code", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/account/totp-enrollment")
# Submit with invalid code
html =
view
|> form("form", %{code: "000000"})
|> render_submit()
assert html =~ "Invalid code. Please try again."
# Should still be on enrollment page (view is still alive, no redirect)
assert render(view) =~ "Set Up Two-Factor Authentication"
end
test "rejects non-numeric code", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/account/totp-enrollment")
# HTML5 pattern validation would prevent this, but test backend handling
html =
view
|> form("form", %{code: "abcdef"})
|> render_submit()
assert html =~ "Invalid code. Please try again."
end
test "renders code input field with proper configuration", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/account/totp-enrollment")
# Verify input field is present with correct attributes
assert html =~ "id=\"code\""
assert html =~ "inputmode=\"numeric\""
assert html =~ "pattern=\"[0-9]{6}\""
assert html =~ "maxlength=\"6\""
assert html =~ "placeholder=\"000000\""
end
end
describe "TOTP Enrollment Page - already enrolled user" do
setup do
user = user_fixture()
secret = Accounts.generate_totp_secret()
{:ok, enrolled_user} = Accounts.enable_totp(user, secret)
%{conn: log_in_user(build_conn(), enrolled_user), user: enrolled_user}
end
test "redirects to devices page if TOTP already enabled", %{conn: conn} do
{:error, {:redirect, %{to: path}}} = live(conn, ~p"/account/totp-enrollment")
assert path == ~p"/devices"
end
end
describe "TOTP Enrollment Page - unauthenticated user" do
test "redirects to login page" do
conn = build_conn()
{:error, {:redirect, %{to: path}}} = live(conn, ~p"/account/totp-enrollment")
assert path == ~p"/users/log-in"
end
end
end