Macaw.Auth.Native.Totp (macaw_auth v0.4.1)

Copy Markdown View Source

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 with Plug.Crypto.secure_compare/2.
  • The secret is sealed (Macaw.Auth.Native.Seal, AES-256-GCM under the configured sealing_key, bound to the user's id). It leaves this module only from enroll_begin/2, for the enrollment page's QR code.
  • Enrollment is pending until confirmed. enroll_begin/2 stores a sealed secret with totp_enabled_at nil, and returns the same secret on a reload; enroll_confirm/3 with 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) decides status/2: :enrolled, else :required when 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

Functions

admin_reset(user, opts \\ [])

@spec admin_reset(user(), keyword()) :: :ok

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.

claim_step(repo, user_id, step)

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

code(secret, time)

@spec code(binary(), integer() | DateTime.t()) :: String.t()

The six-digit code for secret at time (unix seconds or a DateTime).

disable(user, code, opts \\ [])

@spec disable(user(), term(), keyword()) :: :ok | {:error, :invalid_code | :required}

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).

enroll_begin(user, opts \\ [])

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

enroll_confirm(user, code, opts \\ [])

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

normalize(code)

@spec normalize(term()) :: String.t()

A submitted code with spaces and dashes removed and letters upper case.

recovery_codes_left(user)

@spec recovery_codes_left(user()) :: non_neg_integer()

How many unspent recovery codes the user has.

regenerate_recovery_codes(user, opts \\ [])

@spec regenerate_recovery_codes(user(), keyword()) ::
  {:ok, [String.t()]} | {:error, :not_enrolled}

Replaces an enrolled user's recovery codes: {:ok, codes} (the new ones, shown once; the old ones no longer work), or {:error, :not_enrolled}.

required?(user_record, config)

Whether policy (totp_required) requires a second factor of this user.

status(record, config)

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

step(time)

@spec step(integer() | DateTime.t()) :: integer()

The step (div(unix seconds, 30)) a time falls in.

verify(user, code, opts \\ [])

@spec verify(user(), term(), keyword()) :: :ok | {:error, :invalid_code}

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).