The native backend's second factor: TOTP and recovery codes (ported from
exio's and macaw's Accounts.Totp, which are the same rules), on the
macaw_auth_users row and in macaw_auth_recovery_codes.
Call these through Macaw.Auth.Native (verify_second_factor/3,
enroll_confirm/3, disable/3), which puts every code check behind the
throttle; the functions here don't throttle.
- RFC 6238, through
NimbleTOTP: SHA-1, six digits, 30-second steps; a 160-bit (20-byte) random secret; a code is accepted for the current step and one either side, so a clock a little off still works. - A code works once. The step a code was accepted for is stored
(
totp_last_step), and a code is accepted only for a later step. The write is conditional (UPDATE … WHERE totp_last_step < step,claim_step/3), so of two submissions of one code racing each other, the database lets exactly one through; the other updates no row and is refused. Codes are compared withPlug.Crypto.secure_compare/2. - The secret is sealed (
Macaw.Auth.Native.Seal, AES-256-GCM under the configuredsealing_key, bound to the user's id). It leaves this module only fromenroll_begin/2, for the enrollment page's QR code. - Enrollment is pending until confirmed.
enroll_begin/2stores a sealed secret withtotp_enabled_atnil, and returns the same secret on a reload;enroll_confirm/3with a valid code enables it and returns ten recovery codes, shown once. - Recovery codes: ten, each ten characters of an alphabet without
look-alikes (no 0, O, 1 or I), shown in two groups of five
(
ABCDE-FGHJK), stored only as SHA-256, each spent by deleting its row conditionally. Regenerating replaces them all. - Input is normalized: spaces and dashes removed, letters upper case.
- Policy (
totp_required,Macaw.Auth.Native.Config) decidesstatus/2::enrolled, else:requiredwhen policy requires one, else:none.
Every function honors :now (a DateTime). Telemetry
[:macaw_auth, :native, :second_factor]: %{count: 1} with
%{event: :enrollment_started | :enrolled | :verified | :recovery_code_used | :refused | :disabled | :reset | :recovery_codes_regenerated, user_id: id}; never a secret or a code.
Summary
Functions
An administrator clears a user's second factor (a lost device), enrolled
or pending, and revokes every session of theirs
(Macaw.Auth.Native.Sessions.revoke_all/3, so their open pages
disconnect): they sign in again, and enroll again if policy requires.
:ok. Option :announce (default true): false revokes without
telling open pages, for a process that is not the app's node.
Records step as the user's last accepted one, only if it is later than
the one stored: :ok, or :replayed when it isn't (another submission
of the same code, or of an earlier one, got there first). The guard is
the update's own WHERE, so it holds however many callers race.
The six-digit code for secret at time (unix seconds or a DateTime).
The user turns their second factor off, proving it with a current code
or a recovery code: :ok (the secret and recovery codes are deleted), or
{:error, :invalid_code | :required}, :required when policy requires
one (an administrator's admin_reset/2 then).
Starts, or resumes, enrollment: {:ok, %{secret: binary, uri: otpauth}}
(the URI for a QR code, its label issuer:username), or
{:error, :already_enrolled | :not_found}. Not yet active: a pending
enrollment changes nothing at sign-in until enroll_confirm/3. A
pending secret is kept, so a reload shows the same code.
Confirms a pending enrollment with a valid code: {:ok, recovery_codes}
(shown once; never retrievable again), or {:error, :invalid_code | :not_started | :already_enrolled}. The confirming code's step is
recorded, so it can't then be used to sign in.
A submitted code with spaces and dashes removed and letters upper case.
How many unspent recovery codes the user has.
Replaces an enrolled user's recovery codes: {:ok, codes} (the new
ones, shown once; the old ones no longer work), or
{:error, :not_enrolled}.
Whether policy (totp_required) requires a second factor of this user.
The contract's second_factor for a row: :enrolled, :required or :none.
The step (div(unix seconds, 30)) a time falls in.
Checks a TOTP code or an unspent recovery code of an enrolled user. :ok
spends it: the step, or the recovery code, never works again. Otherwise
{:error, :invalid_code}, the same whatever was wrong (not enrolled, a
wrong code, a replayed one).
Types
@type user() :: Macaw.Auth.User.t() | Macaw.Auth.Native.UserRecord.t() | Ecto.UUID.t()
Functions
An administrator clears a user's second factor (a lost device), enrolled
or pending, and revokes every session of theirs
(Macaw.Auth.Native.Sessions.revoke_all/3, so their open pages
disconnect): they sign in again, and enroll again if policy requires.
:ok. Option :announce (default true): false revokes without
telling open pages, for a process that is not the app's node.
@spec claim_step(module(), Ecto.UUID.t(), integer()) :: :ok | :replayed
Records step as the user's last accepted one, only if it is later than
the one stored: :ok, or :replayed when it isn't (another submission
of the same code, or of an earlier one, got there first). The guard is
the update's own WHERE, so it holds however many callers race.
@spec code(binary(), integer() | DateTime.t()) :: String.t()
The six-digit code for secret at time (unix seconds or a DateTime).
The user turns their second factor off, proving it with a current code
or a recovery code: :ok (the secret and recovery codes are deleted), or
{:error, :invalid_code | :required}, :required when policy requires
one (an administrator's admin_reset/2 then).
@spec enroll_begin(user(), keyword()) :: {:ok, %{secret: binary(), uri: String.t()}} | {:error, :already_enrolled | :not_found}
Starts, or resumes, enrollment: {:ok, %{secret: binary, uri: otpauth}}
(the URI for a QR code, its label issuer:username), or
{:error, :already_enrolled | :not_found}. Not yet active: a pending
enrollment changes nothing at sign-in until enroll_confirm/3. A
pending secret is kept, so a reload shows the same code.
@spec enroll_confirm(user(), String.t(), keyword()) :: {:ok, [String.t()]} | {:error, :invalid_code | :not_started | :already_enrolled}
Confirms a pending enrollment with a valid code: {:ok, recovery_codes}
(shown once; never retrievable again), or {:error, :invalid_code | :not_started | :already_enrolled}. The confirming code's step is
recorded, so it can't then be used to sign in.
A submitted code with spaces and dashes removed and letters upper case.
@spec recovery_codes_left(user()) :: non_neg_integer()
How many unspent recovery codes the user has.
Replaces an enrolled user's recovery codes: {:ok, codes} (the new
ones, shown once; the old ones no longer work), or
{:error, :not_enrolled}.
@spec required?(Macaw.Auth.Native.UserRecord.t(), Macaw.Auth.Native.Config.t()) :: boolean()
Whether policy (totp_required) requires a second factor of this user.
@spec status(Macaw.Auth.Native.UserRecord.t(), Macaw.Auth.Native.Config.t()) :: :none | :enrolled | :required
The contract's second_factor for a row: :enrolled, :required or :none.
@spec step(integer() | DateTime.t()) :: integer()
The step (div(unix seconds, 30)) a time falls in.
Checks a TOTP code or an unspent recovery code of an enrolled user. :ok
spends it: the step, or the recovery code, never works again. Otherwise
{:error, :invalid_code}, the same whatever was wrong (not enrolled, a
wrong code, a replayed one).