feat: add Accounts.verify_totp_only/2 function

This commit is contained in:
Graham McIntire 2026-02-01 14:17:09 -06:00
parent 92350173b0
commit 42565779bc
No known key found for this signature in database
2 changed files with 88 additions and 0 deletions

View file

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

View file

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