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
endSupervise 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 withglobal.render(element, options), whosecallbackhands 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
@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.
@type site_id() :: String.t() | nil
Callbacks
How the browser draws the check for form on site_id, in language.
@callback csp() :: %{ optional(:script_src | :frame_src | :style_src | :connect_src) => [String.t()] }
The origins the browser loads the provider from, by CSP directive.
@callback id() :: String.t()
The provider's id, stored in security.captcha.provider.
@callback label() :: String.t()
The name on the settings page, as a gettext_noop/1 msgid.
@callback offered?() :: boolean()
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.
@callback settings() :: [MishkaCmsCore.Settings.definition()]
The provider's own setting definitions (MishkaCmsCore.Settings.define/3).
@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
@spec all() :: [module()]
Every registered provider module, the first of each id.
@spec event() :: String.t()
The event a provider plugin listens on.
The offered provider registered under id.
@spec offered() :: [module()]
The providers this installation offers on the settings page.
A scope's own value of a provider setting, trimmed; nil when it set none.
@spec secret(String.t(), String.t(), keyword()) :: MishkaCmsCore.Settings.definition()
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.
@spec site_key(String.t(), String.t(), keyword()) :: MishkaCmsCore.Settings.definition()
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.
@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.