MishkaCmsCore.Accounts.SecondFactor (Mishka CMS Core v0.0.1)

Copy Markdown View Source

An authenticator app as a second step after the password, for an account that has set one up: AshAuthentication's totp strategy on MishkaCmsCore.Accounts.User. An account without one signs in with its password alone, as before.

Setting one up

On the settings page's Preferences tab (MishkaCmsCoreWeb.Live.Admin.Components.TwoFactor), by the strategy's own two steps: setup_with_totp hands back the otpauth:// address and a setup token without storing anything, and confirm_setup_with_totp stores the secret once a code from the app matches it. Only for one's own account. disable_totp turns it off with a current code; an admin of the account's site, or a master, turns it off for someone who lost the app (turn_off_totp, the Users page), with a current code from their own (step_up/2).

Recovery codes

AshAuthentication's recovery_code strategy: generate_recovery_codes makes ten one-time codes with a current code from the app, shown once and stored only as hashes (MishkaCmsCore.Accounts.RecoveryCode); making new ones drops the old. Signing in, one sent as recovery_code in place of totp_code is taken instead of the app's code, and deleted. Turning the authenticator off deletes them all.

What a site asks of its admins

policy/1: :off — no code is asked of anyone, even an account with an authenticator; :optional — each admin chooses; :recommended — an admin without one is reminded at each sign-in (MishkaCmsCore.Accounts.User.Notices.TwoStepReminder) and on the dashboard; :required — an admin without one sets one up before using the dashboard (MishkaCmsCore.Accounts.TwoStepGuard). Read from the master's security.two_step.every_site, or each site's own security.two_step while the master leaves it to them.

Where it is asked

At the account actions, so the sign-in page, AshAuthentication's own routes and the API hear the same answer, refused with MishkaCmsCore.Accounts.SecondFactor.Needed:

  • signing in by password (prepare/3 with :code) — after the password is checked, so only someone who knew it learns that a code is needed; the account's totp_code argument has to be a current code, or its recovery_code one of its recovery codes. The sign-in page asks for it as a second step and keeps the password in its process meanwhile; an API client sends it with the password. A code is accepted once (last_totp_at);
  • signing in by sign-in link (prepare/3 with :refuse) — refused, since a link proves the mailbox and not the authenticator;
  • any other way AshAuthentication signs in in the browser — refused a session by MishkaCmsCoreWeb.AuthController (session_refused?/2).

In each case the token AshAuthentication had already made is revoked, as AuthGate does.

Plugins

"auth:second_factor:refused" hears each refused sign-in (return: false), with %{user_id:, site_id:, reason:}; "auth:second_factor:enabled", "auth:second_factor:disabled" and "auth:recovery_codes:generated" each account that turned it on or off or made recovery codes — see MishkaCmsCore.Accounts.Changes.AnnounceSecondFactor; "auth:recovery_code:used" each sign-in that took a recovery code, with %{user_id:, site_id:, left:}. The account is emailed on the second and the last (MishkaCmsCore.Accounts.User.Notices.SecondFactorTurnedOff, MishkaCmsCore.Accounts.User.Notices.RecoveryCodeUsed).

Summary

Functions

Whether signing in asks user for a code: it has an authenticator, and its site's policy is not :off.

Checks code against user's authenticator with the strategy's verify_with_totp, counted by its rate limit. :ok, {:error, %Needed{}} for a missing or wrong code, or the limiter's own refusal.

Deletes user's recovery codes, in AshAuthentication's own context: an admin turning off someone's authenticator reaches no codes of theirs otherwise.

An after_action for an action that turns the authenticator off: the account's recovery codes go with it.

What a visitor is told for each refusal.

Whether user has an authenticator set up.

The one line a person is told when a code they typed was refused — too many tries, a code that did not match, or none set up for their own account.

Ten new recovery codes, as the recovery_code strategy makes them, before they are hashed.

Whether user is an admin without an authenticator on a site that requires one.

What two-step sign-in asks of the admins of site_id — nil for masters: the master's security.two_step.every_site when it names a policy, else the site's own security.two_step (the master site's, for masters), else :optional.

The read preparation on a sign-in action, after the one that checks the password or the token: prepare &SecondFactor.prepare(&1, &2, :code) asks a current code in totp_code, prepare &SecondFactor.prepare(&1, &2, :refuse) refuses every account that has an authenticator.

How many recovery codes user has left. Read in AshAuthentication's own context, as the strategy reads them: nobody else reads this table.

Why a sign-in was refused a second factor, out of whatever error shape it reached its caller in — an Ash error, an AshPhoenix form's raw errors, or a JSON:API error document. nil for any other refusal.

Whether user is an admin without an authenticator on a site that recommends one.

The otpauth:// address setup_with_totp hands back, named after the account's site: its site.title setting (the global one for a master), which the authenticator app lists the code under. A title left empty names it after the strategy's issuer. Only the name changes; codes are checked against the secret alone.

A current code from actor's own authenticator, for an admin's action on another account (turn_off_totp): use_code/3 on the admin's own account, or {:error, %Needed{reason: :own}} when they have not set one up.

check/3, and the code is then spent: verify_with_totp refuses a code from before last_totp_at, and writing it here, only while it is still older than now, keeps two requests racing with one code from both getting through.

Types

policy()

@type policy() :: :off | :optional | :recommended | :required

reason()

@type reason() :: :required | :invalid | :password | :own

Functions

asked?(user)

@spec asked?(map()) :: boolean()

Whether signing in asks user for a code: it has an authenticator, and its site's policy is not :off.

check(user, code, tenant)

@spec check(MishkaCmsCore.Accounts.User.t(), String.t() | nil, String.t() | nil) ::
  :ok | {:error, term()}

Checks code against user's authenticator with the strategy's verify_with_totp, counted by its rate limit. :ok, {:error, %Needed{}} for a missing or wrong code, or the limiter's own refusal.

clear_recovery_codes(map)

@spec clear_recovery_codes(map()) :: :ok | {:error, term()}

Deletes user's recovery codes, in AshAuthentication's own context: an admin turning off someone's authenticator reaches no codes of theirs otherwise.

cleared_recovery_codes(changeset, user, context)

@spec cleared_recovery_codes(
  Ash.Changeset.t(),
  MishkaCmsCore.Accounts.User.t(),
  map()
) ::
  {:ok, MishkaCmsCore.Accounts.User.t()} | {:error, term()}

An after_action for an action that turns the authenticator off: the account's recovery codes go with it.

change after_action(&SecondFactor.cleared_recovery_codes/3)

describe(atom)

@spec describe(reason()) :: %{title: String.t(), text: String.t()}

What a visitor is told for each refusal.

enabled?(arg1)

@spec enabled?(term()) :: boolean()

Whether user has an authenticator set up.

explain(error)

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

The one line a person is told when a code they typed was refused — too many tries, a code that did not match, or none set up for their own account.

fresh_recovery_codes()

@spec fresh_recovery_codes() :: [String.t()]

Ten new recovery codes, as the recovery_code strategy makes them, before they are hashed.

owed?(user)

@spec owed?(map() | nil) :: boolean()

Whether user is an admin without an authenticator on a site that requires one.

policy(site_id)

@spec policy(String.t() | nil) :: policy()

What two-step sign-in asks of the admins of site_id — nil for masters: the master's security.two_step.every_site when it names a policy, else the site's own security.two_step (the master site's, for masters), else :optional.

SecondFactor.policy(site.id) #=> :recommended

prepare(query, context, way)

@spec prepare(Ash.Query.t(), map(), :code | :refuse) :: Ash.Query.t()

The read preparation on a sign-in action, after the one that checks the password or the token: prepare &SecondFactor.prepare(&1, &2, :code) asks a current code in totp_code, prepare &SecondFactor.prepare(&1, &2, :refuse) refuses every account that has an authenticator.

recovery_codes_left(map)

@spec recovery_codes_left(map()) :: non_neg_integer()

How many recovery codes user has left. Read in AshAuthentication's own context, as the strategy reads them: nobody else reads this table.

refusal(errors)

@spec refusal(term()) :: reason() | nil

Why a sign-in was refused a second factor, out of whatever error shape it reached its caller in — an Ash error, an AshPhoenix form's raw errors, or a JSON:API error document. nil for any other refusal.

reminded?(user)

@spec reminded?(map() | nil) :: boolean()

Whether user is an admin without an authenticator on a site that recommends one.

setup_url(totp_url, map)

@spec setup_url(String.t(), MishkaCmsCore.Accounts.User.t()) :: String.t()

The otpauth:// address setup_with_totp hands back, named after the account's site: its site.title setting (the global one for a master), which the authenticator app lists the code under. A title left empty names it after the strategy's issuer. Only the name changes; codes are checked against the secret alone.

SecondFactor.setup_url(started.__metadata__.totp_url, user)
#=> "otpauth://totp/Acme:ada%40example.com?secret=…&issuer=Acme&period=30"

step_up(arg1, code)

@spec step_up(map() | nil, String.t() | nil) :: :ok | {:error, term()}

A current code from actor's own authenticator, for an admin's action on another account (turn_off_totp): use_code/3 on the admin's own account, or {:error, %Needed{reason: :own}} when they have not set one up.

use_code(user, code, tenant)

@spec use_code(MishkaCmsCore.Accounts.User.t(), String.t() | nil, String.t() | nil) ::
  :ok | {:error, term()}

check/3, and the code is then spent: verify_with_totp refuses a code from before last_totp_at, and writing it here, only while it is still older than now, keeps two requests racing with one code from both getting through.