From 42565779bc914f3dc464bdd4cb364f8d4cad5cff Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 1 Feb 2026 14:17:09 -0600 Subject: [PATCH] feat: add Accounts.verify_totp_only/2 function --- lib/towerops/accounts.ex | 45 +++++++++++++++++++ .../accounts/verify_totp_only_test.exs | 43 ++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 test/towerops/accounts/verify_totp_only_test.exs diff --git a/lib/towerops/accounts.ex b/lib/towerops/accounts.ex index 0131e350..d9126f10 100644 --- a/lib/towerops/accounts.ex +++ b/lib/towerops/accounts.ex @@ -574,6 +574,51 @@ defmodule Towerops.Accounts do end end + @doc """ + Verifies a TOTP code for a user, rejecting recovery codes. + + This is used for sudo mode verification where we want to ensure + the user has access to their authenticator device, not just a recovery code. + + Returns `{:ok, user}` on success with valid TOTP code. + Returns `{:error, :recovery_code_not_allowed}` if a recovery code is provided. + Returns `{:error, reason}` for invalid TOTP codes. + + ## Examples + + iex> verify_totp_only(user, "123456") + {:ok, %User{}} + + iex> verify_totp_only(user, "ABCD-EFGH-IJKL") + {:error, :recovery_code_not_allowed} + + iex> verify_totp_only(user, "000000") + {:error, :invalid_code} + """ + def verify_totp_only(%User{} = user, code) when is_binary(code) do + # TOTP codes are exactly 6 numeric digits + # Recovery codes contain letters and/or are longer than 6 digits + numeric_only = String.replace(code, ~r/[^0-9]/, "") + + cond do + # Code contains non-numeric characters (recovery codes have letters) + String.length(numeric_only) != String.length(code) -> + {:error, :recovery_code_not_allowed} + + # Code is longer than 6 digits + String.length(numeric_only) > 6 -> + {:error, :recovery_code_not_allowed} + + # Looks like a TOTP code, verify it + true -> + case verify_user_mfa(user, code) do + {:ok, user, :totp} -> {:ok, user} + {:ok, _user, :recovery_code} -> {:error, :recovery_code_not_allowed} + {:error, reason} -> {:error, reason} + end + end + end + ## Settings @doc """ diff --git a/test/towerops/accounts/verify_totp_only_test.exs b/test/towerops/accounts/verify_totp_only_test.exs new file mode 100644 index 00000000..c375f003 --- /dev/null +++ b/test/towerops/accounts/verify_totp_only_test.exs @@ -0,0 +1,43 @@ +defmodule Towerops.Accounts.VerifyTotpOnlyTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + + alias Towerops.Accounts + + describe "verify_totp_only/2" do + setup do + user = user_fixture() + + # Create TOTP device + {:ok, device, _secret} = + Accounts.create_totp_device(user.id, "Test Device") + + %{user: user, device: device} + end + + test "accepts valid TOTP codes", %{user: user, device: device} do + code = NimbleTOTP.verification_code(device.totp_secret) + + assert {:ok, returned_user} = Accounts.verify_totp_only(user, code) + assert returned_user.id == user.id + end + + test "rejects invalid TOTP codes", %{user: user} do + assert {:error, :invalid_code} = Accounts.verify_totp_only(user, "000000") + end + + test "rejects recovery codes", %{user: user} do + # Recovery codes are longer than 6 digits + recovery_code = "ABCD-EFGH-IJKL" + + assert {:error, :recovery_code_not_allowed} = + Accounts.verify_totp_only(user, recovery_code) + end + + test "rejects codes longer than 6 digits", %{user: user} do + assert {:error, :recovery_code_not_allowed} = + Accounts.verify_totp_only(user, "1234567890") + end + end +end