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

Copy Markdown View Source

The captcha (MishkaCmsCore.Captcha) in the actions a visitor fills a form for: sign_in_with_password, register_with_password, request_password_reset_token and request_magic_link, each taking the token as its captcha_token argument. Checked in the action, so the sign-in pages and the /api/auth routes ask it alike.

Where in the action

A before_action — a before_transaction on a create, so the provider is not asked inside a database transaction — between the action's two rate limits:

prepare rate_limit(per address)
prepare &CaptchaCheck.prepare(&1, &2, :sign_in)
prepare rate_limit(per account)

The per-address limit comes first, so one address sending tokens that fail costs the provider no more requests than that limit lets through. The per-account limit comes after, so attempts that never passed the captcha cannot use up an account's tries and lock its owner out. The password is checked after both — a bot that fails the captcha never learns whether it was right — and a registration is checked only once its fields are valid, so a typo does not spend the token.

An extension's form

An extension's action checks its own form the same way, named by an atom of its own, and its template draws the check with <.captcha for={:that_atom}> (MishkaCmsCore.Captcha.Widget). The address is read from the same ash_authentication_request context, so the form hands it the visitor's %{remote_ip: address}:

prepare &MishkaCmsCore.Accounts.CaptchaCheck.prepare(&1, &2, :newsletter)

Who is not asked

  • a scope that asks none — no provider, or one missing a key;
  • the JSON:API, whose requests MishkaCmsCore.Runtime.Plugs.AuthRateLimitPlug marks (api?: true in ash_authentication_request): its clients are apps, which have no captcha to show;
  • a call made with authorize?: false — the installer, the seeds, the console;
  • the second step of a password sign-in, once the first passed it: the sign-in page builds the code step's form with captcha_passed?: true in its context, which only server code can set.

The answer

A refusal is a MishkaCmsCore.Accounts.CaptchaCheck.Refused and fires "auth:captcha:refused" with %{form, reason, detail, site_id}. A provider that could not be asked lets the form through and fires "auth:captcha:unavailable" with %{form, detail, site_id}; both with return: false.

Summary

Functions

A change: checks the captcha of a create action's form before it runs — before its transaction, so a slow provider holds no database connection.

What to tell whoever was refused, in their language.

A preparation: checks the captcha of a read or generic action's form before it runs.

The captcha refusal anywhere inside reason, or nil: an error or error class, a form's raw_errors, or an AshAuthentication.Errors.AuthenticationFailed.

Types

form()

@type form() :: :sign_in | :register | :reset_request | :magic_link | atom()

Functions

change(changeset, context, form)

@spec change(Ash.Changeset.t(), map(), form()) :: Ash.Changeset.t()

A change: checks the captcha of a create action's form before it runs — before its transaction, so a slow provider holds no database connection.

describe(arg1)

@spec describe(:missing | :failed) :: %{title: String.t(), text: String.t()}

What to tell whoever was refused, in their language.

prepare(query, context, form)

@spec prepare(Ash.Query.t() | Ash.ActionInput.t(), map(), form()) ::
  Ash.Query.t() | Ash.ActionInput.t()

A preparation: checks the captcha of a read or generic action's form before it runs.

refusal(errors)

@spec refusal(term()) :: %{reason: :missing | :failed} | nil

The captcha refusal anywhere inside reason, or nil: an error or error class, a form's raw_errors, or an AshAuthentication.Errors.AuthenticationFailed.

CaptchaCheck.refusal(AshPhoenix.Form.raw_errors(form, for_path: :all))
#=> %{reason: :missing}