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/3with:code) — after the password is checked, so only someone who knew it learns that a code is needed; the account'stotp_codeargument has to be a current code, or itsrecovery_codeone 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/3with: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
Functions
Whether signing in asks user for a code: it has an authenticator, and its site's policy is not :off.
@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.
Deletes user's recovery codes, in AshAuthentication's own context: an admin turning off
someone's authenticator reaches no codes of theirs otherwise.
@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)
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.
@spec fresh_recovery_codes() :: [String.t()]
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.
SecondFactor.policy(site.id) #=> :recommended
@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.
@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.
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.
@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"
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.
@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.