MishkaCmsCore.Captcha.Provider behaviour (Mishka CMS Core v0.0.1)

Copy Markdown View Source

A captcha provider: one module that is a mishka_installer plugin on the "captcha:providers" event and implements the callbacks below. MishkaCmsCore.Captcha never names a provider: a scope picks one on the settings page's Security tab (security.captcha.provider), and its sign-in, registration, forgot-password and sign-in-link forms ask it.

defmodule MyExtension.Captcha.Acme do
  use MishkaCmsCore.Captcha.Provider, id: "acme", label: "Acme Captcha"

  @site_key "security.captcha.acme.site_key"
  @secret "security.captcha.acme.secret_key"

  @impl true
  def settings do
    [
      site_key(@site_key, gettext_noop("Acme site key"), group: "Acme Captcha"),
      secret(@secret, gettext_noop("Acme secret key"), group: "Acme Captcha")
    ]
  end

  @impl true
  def ready?(site_id), do: own(@site_key, site_id) != nil and secret?(@secret, site_id)

  @impl true
  def browser(site_id, _form, _language) do
    %{
      script: "https://captcha.acme.example/api.js?render=explicit",
      global: "acme",
      mode: :widget,
      options: %{"sitekey" => own(@site_key, site_id)}
    }
  end

  @impl true
  def csp, do: %{script_src: ["https://captcha.acme.example"], frame_src: ["https://captcha.acme.example"]}

  @impl true
  def verify(token, site_id, request) do
    with {:ok, answer} <-
           siteverify("https://captcha.acme.example/siteverify", %{
             secret: Settings.secret(@secret, site_id),
             response: token,
             remoteip: request.remote_ip
           }),
         do: {:ok, %{hostname: answer["hostname"], test?: false}}
  end
end

Supervise the module in the extension's application, like any other plugin. use adds the plugin half — the "captcha:providers" registration, see MishkaCmsCore.ProviderRegistry — plus site_key/3 and secret/3, which build a provider's settings the way the core's are built, own/2, which reads one, and siteverify/2, which asks a provider's server about a token.

Settings

settings/0 declares the provider's own fields, shown on the Security tab under its label. Each scope sets its own (resolve: :own): a site never checks with another site's keys. The secret is a :secret setting; read it with MishkaCmsCore.Settings.secret/2, never get/3.

The browser

browser/3 tells the one Captcha hook (assets/js/shared_hooks.js) how to draw the check for form in language: the script to load, the global it defines, and the mode —

  • :widget — a box drawn with global.render(element, options), whose callback hands back the token (Turnstile, reCAPTCHA v2, hCaptcha);
  • :score — nothing drawn: global.execute(options.sitekey, %{action: options.action}) is asked for a token as the form is sent (reCAPTCHA v3).

csp/0 names the origins the browser loads the provider from; the security headers allow them on the sites that use it, and nowhere else.

Verifying

verify/3 asks the provider's server about one token, for site_id, with request — %{remote_ip: address | nil, form: form} — through siteverify/2, which reads the answer the same way for every provider. It answers {:ok, %{hostname: host, test?: boolean}}: the address the check was solved on, and whether the scope checks with the provider's published test keys, which pass every token on any address; or {:error, {:failed, detail}} when the token was refused — wrong, expired, used, a low score — or {:error, {:unavailable, detail}} when the provider could not be asked or refused the keys themselves. detail is a short text for the log and the settings page's test.

The registered providers

all/0, offered/0 and fetch/1 ask the "captcha:providers" event which providers are installed: the core's own — Cloudflare Turnstile, Google reCAPTCHA and hCaptcha — register the same way an extension's would.

Summary

Types

The form a check is for: the sign-in pages' four, the settings page's :test, or an extension's own, named by the atom its action passes MishkaCmsCore.Accounts.CaptchaCheck.

Callbacks

How the browser draws the check for form on site_id, in language.

The origins the browser loads the provider from, by CSP directive.

The provider's id, stored in security.captcha.provider.

The name on the settings page, as a gettext_noop/1 msgid.

Whether the provider is offered on this installation. Every provider is, unless it says not.

Whether site_id has everything this provider needs to check.

The provider's own setting definitions (MishkaCmsCore.Settings.define/3).

Asks the provider about token.

Functions

Every registered provider module, the first of each id.

The event a provider plugin listens on.

The offered provider registered under id.

The providers this installation offers on the settings page.

A scope's own value of a provider setting, trimmed; nil when it set none.

A provider's secret field: a :secret setting on the Security tab that each scope sets for itself, stored sealed and never shown again. opts are MishkaCmsCore.Settings.define/3's.

Whether a scope holds a :secret setting this server can open. One sealed under another SECRET_KEY_BASE cannot be, and counts as none: the provider is not ready, nothing is asked, and the settings page shows the key as not set until it is typed again.

A provider's public key field: a :string setting on the Security tab that each scope sets for itself. opts are MishkaCmsCore.Settings.define/3's.

Posts fields to a provider's verify endpoint, as a form, and reads the answer the way Turnstile, reCAPTCHA and hCaptcha all give it: {:ok, answer} when it says "success": true; {:error, {:unavailable, detail}} when it could not be reached in time, failed (a 5xx), or refused the keys themselves (missing-input-secret, invalid-input-secret, sitekey-secret-mismatch, not-using-dummy-passcode, internal-error); {:error, {:failed, detail}} for every other answer — a refusal with any other code or none, a 4xx, a body that is no answer. A field that is nil is left out. No retry and no redirect, so a slow provider cannot hold a sign-in open and the secret is never sent anywhere but url.

Types

browser()

@type browser() :: %{
  script: String.t(),
  global: String.t(),
  mode: :widget | :score,
  options: %{required(String.t()) => term()}
}

form()

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

The form a check is for: the sign-in pages' four, the settings page's :test, or an extension's own, named by the atom its action passes MishkaCmsCore.Accounts.CaptchaCheck.

site_id()

@type site_id() :: String.t() | nil

Callbacks

browser(site_id, form, language)

@callback browser(site_id(), form(), language :: String.t()) :: browser()

How the browser draws the check for form on site_id, in language.

csp()

@callback csp() :: %{
  optional(:script_src | :frame_src | :style_src | :connect_src) => [String.t()]
}

The origins the browser loads the provider from, by CSP directive.

id()

@callback id() :: String.t()

The provider's id, stored in security.captcha.provider.

label()

@callback label() :: String.t()

The name on the settings page, as a gettext_noop/1 msgid.

offered?()

(optional)
@callback offered?() :: boolean()

Whether the provider is offered on this installation. Every provider is, unless it says not.

ready?(site_id)

@callback ready?(site_id()) :: boolean()

Whether site_id has everything this provider needs to check.

settings()

@callback settings() :: [MishkaCmsCore.Settings.definition()]

The provider's own setting definitions (MishkaCmsCore.Settings.define/3).

verify(token, site_id, map)

@callback verify(token :: String.t(), site_id(), %{
  remote_ip: String.t() | nil,
  form: form()
}) ::
  {:ok, %{hostname: String.t() | nil, test?: boolean()}}
  | {:error, {:failed | :unavailable, String.t()}}

Asks the provider about token.

Functions

all()

@spec all() :: [module()]

Every registered provider module, the first of each id.

event()

@spec event() :: String.t()

The event a provider plugin listens on.

fetch(id)

@spec fetch(String.t() | nil) ::
  {:ok, module()} | {:error, :unknown_provider | :cannot_run}

The offered provider registered under id.

offered()

@spec offered() :: [module()]

The providers this installation offers on the settings page.

own(key, site_id)

@spec own(String.t(), site_id()) :: String.t() | nil

A scope's own value of a provider setting, trimmed; nil when it set none.

secret(key, label, opts \\ [])

A provider's secret field: a :secret setting on the Security tab that each scope sets for itself, stored sealed and never shown again. opts are MishkaCmsCore.Settings.define/3's.

secret?(key, site_id)

@spec secret?(String.t(), site_id()) :: boolean()

Whether a scope holds a :secret setting this server can open. One sealed under another SECRET_KEY_BASE cannot be, and counts as none: the provider is not ready, nothing is asked, and the settings page shows the key as not set until it is typed again.

site_key(key, label, opts \\ [])

A provider's public key field: a :string setting on the Security tab that each scope sets for itself. opts are MishkaCmsCore.Settings.define/3's.

siteverify(url, fields)

@spec siteverify(String.t(), map()) ::
  {:ok, map()} | {:error, {:failed | :unavailable, String.t()}}

Posts fields to a provider's verify endpoint, as a form, and reads the answer the way Turnstile, reCAPTCHA and hCaptcha all give it: {:ok, answer} when it says "success": true; {:error, {:unavailable, detail}} when it could not be reached in time, failed (a 5xx), or refused the keys themselves (missing-input-secret, invalid-input-secret, sitekey-secret-mismatch, not-using-dummy-passcode, internal-error); {:error, {:failed, detail}} for every other answer — a refusal with any other code or none, a 4xx, a body that is no answer. A field that is nil is left out. No retry and no redirect, so a slow provider cannot hold a sign-in open and the secret is never sent anywhere but url.

config :mishka_cms_core, MishkaCmsCore.Captcha.Provider, req_options: [...] adds to the request — the test suite routes it to Req.Test.