Wiztchta

Drop-in, pluggable CAPTCHA for Phoenix forms and LiveViews. No database, no third-party service.

<.form for={@form} phx-submit="save">
  <.input field={@form[:email]} />
  <.wiztchta challenge={@challenge} />
  <.button>Sign up</.button>
</.form>

Installation

# mix.exs
{:wiztchta, "~> 0.1"}
# config/config.exs
config :wiztchta, endpoint: MyAppWeb.Endpoint
# lib/my_app_web.ex, inside html_helpers
import Wiztchta.Components
// assets/js/app.js (Phoenix's esbuild config already resolves packages in deps/)
import { Hooks as WiztchtaHooks, init as initWiztchta } from "wiztchta";

const liveSocket = new LiveSocket("/live", Socket, {
  hooks: { ...colocatedHooks, ...WiztchtaHooks },
  // ...
});
initWiztchta(); // only needed for forms rendered by controllers

Proof-of-work uses crypto.subtle, so it needs HTTPS (or localhost).

LiveView

def mount(_params, _session, socket) do
  {:ok, assign(socket, form: to_form(%{}), challenge: Wiztchta.new())}
end

def handle_event("save", params, socket) do
  socket = assign(socket, :challenge, Wiztchta.new()) # tokens are single-use

  case Wiztchta.verify(params) do
    :ok -> {:noreply, ...}
    {:error, reason} -> {:noreply, put_flash(socket, :error, Wiztchta.error_message(reason))}
  end
end

Keep the challenge in an assign. Without challenge={...}, the component makes a new challenge each time it renders.

Controllers

<.form for={@form} action={~p"/contact"}>
  ...
  <.wiztchta type={Wiztchta.Challenges.Math} />
</.form>
plug Wiztchta.Plug when action in [:create]

def create(conn, params) do
  case conn.assigns.wiztchta do
    :ok -> ...
    {:error, reason} -> ...
  end
end

Configuration

config :wiztchta,
  endpoint: MyAppWeb.Endpoint,      # or secret_key_base: "..."
  default_challenge: Wiztchta.Challenges.ProofOfWork,
  pow_difficulty: 16,               # each +1 doubles the work
  max_age: 300,                     # seconds a challenge stays valid
  store: Wiztchta.Store.ETS,        # nil disables replay protection
  param: "wiztchta"                 # form param name

You can pass any of these per call too, for example Wiztchta.new(type: Wiztchta.Challenges.Math, max_age: 60) or <.wiztchta opts={[difficulty: 18]} />.

The default ETS store only knows about tokens used on its own node. In a cluster, a token could be used once on each node. Implement Wiztchta.Store on top of shared storage to prevent that.

Styling

The markup uses plain classes (wiztchta, wiztchta-pow, wiztchta-status, wiztchta-math, wiztchta-label, wiztchta-input), and proof-of-work exposes data-state="pending|solving|solved|error". Pass class to the component to add your own.

Development

mix deps.get
mix test
mix dev        # demo app at http://localhost:4000 (LiveView + controller forms)
mix precommit

License

MIT. See LICENSE.