From 0995abbccb1dcff5036c9e97c117dc0a9418dd21 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 28 Jan 2026 15:09:38 -0600 Subject: [PATCH] Add mandatory TOTP MFA --- lib/towerops/accounts.ex | 82 +++++++ lib/towerops/accounts/user.ex | 5 + .../live/account_live/totp_enrollment.ex | 206 ++++++++++++++++++ lib/towerops_web/router.ex | 20 +- lib/towerops_web/user_auth.ex | 36 ++- mix.exs | 2 + mix.lock | 2 + .../20260128205714_add_totp_to_users.exs | 13 ++ test/towerops/accounts_test.exs | 89 ++++++++ .../account_live/totp_enrollment_test.exs | 113 ++++++++++ 10 files changed, 559 insertions(+), 9 deletions(-) create mode 100644 lib/towerops_web/live/account_live/totp_enrollment.ex create mode 100644 priv/repo/migrations/20260128205714_add_totp_to_users.exs create mode 100644 test/towerops_web/live/account_live/totp_enrollment_test.exs diff --git a/lib/towerops/accounts.ex b/lib/towerops/accounts.ex index 6c6bb1c1..71897175 100644 --- a/lib/towerops/accounts.ex +++ b/lib/towerops/accounts.ex @@ -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 """ diff --git a/lib/towerops/accounts/user.ex b/lib/towerops/accounts/user.ex index 7967b031..7225661d 100644 --- a/lib/towerops/accounts/user.ex +++ b/lib/towerops/accounts/user.ex @@ -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()], diff --git a/lib/towerops_web/live/account_live/totp_enrollment.ex b/lib/towerops_web/live/account_live/totp_enrollment.ex new file mode 100644 index 00000000..1eb1138a --- /dev/null +++ b/lib/towerops_web/live/account_live/totp_enrollment.ex @@ -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""" + +
+
+
+ <.icon + name="hero-shield-check" + class="w-16 h-16 mx-auto mb-4 text-blue-600 dark:text-blue-400" + /> +

+ Set Up Two-Factor Authentication +

+

+ To keep your account secure, two-factor authentication is required for all users. +

+
+ +
+ +
+

+ + 1 + + Install an Authenticator App +

+

+ Download an authenticator app on your phone if you haven't already: +

+
    +
  • + <.icon + name="hero-check-circle" + class="w-5 h-5 mr-2 text-green-600 dark:text-green-400" + /> Google Authenticator (iOS, Android) +
  • +
  • + <.icon + name="hero-check-circle" + class="w-5 h-5 mr-2 text-green-600 dark:text-green-400" + /> Authy (iOS, Android, Desktop) +
  • +
  • + <.icon + name="hero-check-circle" + class="w-5 h-5 mr-2 text-green-600 dark:text-green-400" + /> 1Password, Bitwarden (with TOTP support) +
  • +
+
+ + +
+

+ + 2 + + Scan the QR Code +

+

+ Open your authenticator app and scan this QR code: +

+
+
+ TOTP QR Code +
+
+
+ + +
+

+ + 3 + + Enter the Code from Your App +

+ <.form + for={%{}} + as={:form} + phx-submit="verify_code" + class="ml-11" + > +
+
+ + +
+ + <%= if @error do %> +
+
+ <.icon name="hero-exclamation-circle" class="h-5 w-5 text-red-400" /> +
+

+ {@error} +

+
+
+
+ <% end %> + +
+ <.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 + +
+
+ +
+
+ +
+
+ <.icon + name="hero-information-circle" + class="h-5 w-5 text-blue-600 dark:text-blue-400 mt-0.5" + /> +
+

+ 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 +end diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index 0c8f9822..18d7cc4a 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -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 diff --git a/lib/towerops_web/user_auth.ex b/lib/towerops_web/user_auth.ex index 04804de9..d1c8fa83 100644 --- a/lib/towerops_web/user_auth.ex +++ b/lib/towerops_web/user_auth.ex @@ -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) diff --git a/mix.exs b/mix.exs index 99d5b2ca..198debfd 100644 --- a/mix.exs +++ b/mix.exs @@ -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"}, diff --git a/mix.lock b/mix.lock index 80e2b995..2798886b 100644 --- a/mix.lock +++ b/mix.lock @@ -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"}, diff --git a/priv/repo/migrations/20260128205714_add_totp_to_users.exs b/priv/repo/migrations/20260128205714_add_totp_to_users.exs new file mode 100644 index 00000000..e3a5c751 --- /dev/null +++ b/priv/repo/migrations/20260128205714_add_totp_to_users.exs @@ -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 diff --git a/test/towerops/accounts_test.exs b/test/towerops/accounts_test.exs index bfd3d15f..7cb67a8f 100644 --- a/test/towerops/accounts_test.exs +++ b/test/towerops/accounts_test.exs @@ -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 diff --git a/test/towerops_web/live/account_live/totp_enrollment_test.exs b/test/towerops_web/live/account_live/totp_enrollment_test.exs new file mode 100644 index 00000000..217d8599 --- /dev/null +++ b/test/towerops_web/live/account_live/totp_enrollment_test.exs @@ -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