# `MishkaCmsCore.Accounts.SecondFactor`
[🔗](https://github.com/mishka-group/mishka_cms_core/blob/v0.0.1/lib/mishka_cms_core/accounts/_second_factor.ex#L5)

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

# `policy`

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

# `reason`

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

# `asked?`

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

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

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

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

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

What a visitor is told for each refusal.

# `enabled?`

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

Whether `user` has an authenticator set up.

# `explain`

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

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

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

# `owed?`

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

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

# `policy`

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

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

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

```elixir
@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?`

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

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

# `setup_url`

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

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

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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
