Writing a Captcha Module

4 views Markdown

A Captcha module supplies two halves of one challenge: the markup a public form shows, and the verification that runs on submit.

Overview

Four modules ship. DefaultCaptcha builds a code image and compares the typed answer against the session. Turnstile, hCaptcha and reCaptcha embed a widget and verify its token. There is no base class.

One provider is active at a time, named in options/captcha/type. Which forms are protected is decided by the shared helper. You supply the challenge; the helper decides when to show it.

A widget that never appears is usually a configuration problem, not a module problem.

Prerequisites

  • Write access to coremio/modules/Captcha/.
  • A provider account with a site key and a secret key.
  • A form to test against.
  • Securing Theme Forms: captcha is one of five layers there.

Structure

Three files, no namespace, no views. The settings screen comes from a field descriptor your module returns.

layout
coremio/modules/Captcha/Acme/
├── Acme.php        class Acme, no namespace
├── config.php      returns the saved keys, e.g. site-key and secret-key
└── lang/
    ├── en.php      error strings and any button labels your markup prints
    └── tr.php

A provider that makes its own image uses the shared endpoint /captcha.jpg, which calls generateDisplay() on the active provider.

Walkthrough

The Module Class

  1. Create coremio/modules/Captcha/Acme/Acme.php, class named after the directory, no namespace.
  2. In the constructor, read config and language. Guard the config with an array check: an unsaved module returns nothing.
  3. Implement getMarkup(), returning a fragment; the helper wraps it.
  4. Implement check(), returning a plain boolean.
  5. Add headJS() for a script tag and refreshJS() to reset a spent token.

The Settings Fields

  1. Return a field descriptor from config_fields(); those keys are what you read back.
  2. Give the secret field the password type.
  3. Implement save_fields(), returning the array to persist; false with $this->error shows your message.
  4. Merge rather than replace.
  5. The caller writes the returned array to config.php.

Gating a Form

  1. Print the widget from the theme with the captcha tag, naming the area.
  2. In the submit handler, work out whether a challenge is required, then verify.
  3. Reply with a distinct status when the challenge is required but unanswered.
  4. Call the refresh function after every submit.
  5. Register a new area name through the areas hook.

Reference

Method Contract

Only check() is called without a guard, so it is the one method you cannot leave out. Everything else is probed with method_exists.

MethodCalled byReturns
check()The submit handlerbool; nothing else is inspected
getMarkup()The widget builderthe widget HTML fragment
headJS()The widget builder, once per requesta script tag, or empty
refreshJS()The widget buildera JavaScript statement, or empty
getInputName()The helper constructorthe answer field's name
generateDisplay()The shared image endpointwrites the image, returns empty
config_fields()The settings screenthe field descriptor map
save_fields($fields)The settings savethe array to persist, or false

The presence of getInputName() is the family marker. With it the module is a code challenge: the helper adds a box and a text input with that name. Without it, the markup goes straight into the slot.

Signatures

signatures
public function __construct();

// The verdict. A bool, not a status array: the helper returns it straight to the caller.
public function check(): bool;

// The widget fragment. No form tag, no wrapper: the helper supplies the slot.
public function getMarkup(): string;

// Emitted once per request per provider, above the slot.
public function headJS(): string;

// A statement, not a function: the helper wraps it into window.wcpCaptchaRefresh.
public function refreshJS(): string;

// Code providers only. Its PRESENCE switches the slot into code mode.
public function getInputName(): string;

// Code providers only. Writes the image itself and returns ''.
public function generateDisplay(): string;

// Settings screen.
public function config_fields(): array;
public function save_fields($fields = []): array|bool;
the field descriptor
// config_fields(): the array key IS the config key. Whatever you name here is
// what arrives in save_fields() and what you read back from $this->config.
public function config_fields(): array
{
    return [
        'site-key' => [
            'wrap_width'  => 100,        // percentage width of the field row
            'name'        => "Site Key", // the label
            'description' => "",         // help text under the field
            'type'        => "text",     // text | password | select | switch
            'value'       => $this->config['site-key'] ?? '',
        ],
        'secret-key' => [
            'wrap_width'  => 100,
            'name'        => "Secret Key",
            'description' => "",
            'type'        => "password",
            'value'       => $this->config['secret-key'] ?? '',
        ],
    ];
}

// save_fields(): validate, then return the array to persist. The CALLER writes the file.
public function save_fields($fields = []): array|bool
{
    if (!isset($fields['site-key']) || !$fields['secret-key']) {
        $this->error = $this->lang['error1'];
        return false;
    }

    return $this->config ? array_replace_recursive($this->config, $fields) : $fields;
}

$error must exist on your class: the settings save reads it after a false return and raises the message from there.

The Operator's Choices

None of these live in your module's config; they sit in the shared options file.

options/captcha/status The master switch. Off, the widget is an empty string everywhere.
options/captcha/type The active provider's class name. An unknown value falls back to the built in module.
options/captcha/{area} One flag per protected form: sign in, sign up, password reset, contact, feedback, newsletter, domain lookup, licence check.
register:admin.captcha_protected_areas Adds an area to the settings list. The list is by reference: push your key, the return is ignored.
Captcha::enabled() The gate a submit handler asks: master switch on, area flag on.
Captcha::widget() Builds the whole slot. Accepts tray (a collapse id), class and force (always visible).

The Three States

The same widget call produces three results. Knowing which one you have answers most "why is it not showing" questions.

StateWhenResult
StaticThe area is switched onThe slot, visible, with the submit gate marker
AdaptiveThe area is off but the bot shield watches itThe slot in a collapsed tray, no gate marker
AbsentNeither applies, and not forcedAn empty string

The adaptive state omits the gate marker on purpose. Otherwise the theme would block the first submit and the bot shield would never see an attempt. Once an address has tripped the shield, the box is shown up front.

Example

A token based provider, then the theme and the handler that consume it.

Acme.php
<?php
class Acme
{
    public array $lang;
    public array $config;

    // Read by the settings save after save_fields() returns false.
    public string $error = '';

    public function __construct()
    {
        $config       = Modules::Config("Captcha", __CLASS__);
        $this->config = is_array($config) ? $config : [];
        $this->lang   = Modules::Lang("Captcha", __CLASS__);
    }

    public function config_fields(): array
    {
        return [
            'site-key' => [
                'wrap_width'  => 100,
                'name'        => "Site Key",
                'description' => "",
                'type'        => "text",
                'value'       => $this->config['site-key'] ?? '',
            ],
            'secret-key' => [
                'wrap_width'  => 100,
                'name'        => "Secret Key",
                'description' => "",
                'type'        => "password",
                'value'       => $this->config['secret-key'] ?? '',
            ],
        ];
    }

    public function save_fields($fields = []): array|bool
    {
        if (!isset($fields['site-key']) || !$fields['secret-key']) {
            $this->error = $this->lang['error1'] ?? 'Both keys are required.';
            return false;
        }

        return $this->config ? array_replace_recursive($this->config, $fields) : $fields;
    }

    // A fragment. The helper adds the slot wrapper and the spacing utilities.
    public function getMarkup(): string
    {
        return '<div class="acme-captcha" data-sitekey="' . htmlspecialchars($this->config['site-key'] ?? '', ENT_QUOTES) . '" data-theme="auto"></div>';
    }

    public function headJS(): string
    {
        return '<script src="https://challenges.example.com/v1/api.js" async defer></script>';
    }

    // A statement, not a function body: the helper wraps it in window.wcpCaptchaRefresh.
    public function refreshJS(): string
    {
        return 'if (typeof acmeCaptcha !== "undefined") acmeCaptcha.reset();';
    }

    public function check(): bool
    {
        // The provider names its own POST field, so this one is read raw rather than
        // through the input name the code family declares.
        $token = (string) Filter::init("POST/acme-captcha-response", "hclear");
        if ($token === '') return false;

        $response = Utility::HttpRequest([
            'url'  => 'https://challenges.example.com/v1/siteverify',
            'type' => 'POST',
            'data' => [
                'secret'   => $this->config['secret-key'] ?? '',
                'response' => $token,
                'remoteip' => UserManager::GetIP(),
            ],
        ]);

        $decoded = Utility::jdecode((string) $response, true) ?: [];

        // A bool, nothing else. A transport failure is a failed challenge, not an exception:
        // throwing here would turn an unreachable provider into a broken login form.
        return (bool) ($decoded['success'] ?? false);
    }
}
the theme side
<form action="{link route='domain'}" method="post" data-check>
    <input type="text" name="domain" class="form-control">

    {csrf form='domain-check'}
    {captcha area='domain-check' tray='domainCaptchaTray'}

    <button type="submit" class="btn btn-primary">{lang key='search'}</button>
</form>
the submit handler
// The order matters: forgery check, then hard block, then the challenge.
if (!\Validation::verify_csrf_token((string) Filter::init("POST/token", "hclear"), "domain-check"))
    return $operation->output(['status' => "error", 'message' => Language::g("needs/csrf-failed")]);

if (\ProcessRestriction::blocked("domain-check"))
    return $operation->output(['status' => "error", 'message' => Language::g("needs/too-many-requests")]);

// Two independent reasons to ask: the operator turned the area on, or this
// visitor tripped the shield. Either one makes the answer mandatory.
$needCaptcha = \Captcha::enabled("domain-check") || \BotShield::triggered("domain-check");

if ($needCaptcha && !(new \Captcha())->check()) {
    \BotShield::record("domain-check");

    // A distinct status, not a generic error: the form JS reveals the tray on this one.
    return $operation->output([
        'status'  => "captcha_required",
        'message' => Language::g("needs/captcha-failed"),
    ]);
}

// ... the actual lookup ...

\ProcessRestriction::hit("domain-check");
if ($needCaptcha) \BotShield::clear("domain-check");
else              \BotShield::record("domain-check");

Pitfalls

An unknown provider name falls back silently

The helper loads the configured class and, if that fails, builds the built in module. A misspelled directory, a mismatched class name or a fatal in your constructor give the same symptom.

Tokens are single use: refresh after every submit

A verified token is rejected on the second attempt. Return a reset statement from refreshJS() and call the global refresh in the form's finally block.

One page, several widgets, one session slot

A self drawing provider keeps its phrase in one session slot, so every image request replaces it. With more than one widget on a page, stamp all their URLs with the same value in one pass.

Do not decide visibility yourself

getMarkup() is only called once the helper has decided the challenge should exist. Your own config check breaks the adaptive path: that slot is meant to be hidden and revealed later.

A new protected form needs a new area name

The area string is the key in both the config and the gate call. Register yours through the areas hook, then use the same string in the theme tag and the handler.

Was this helpful?

Thanks for your feedback!

Still Need Help?

Our support team is here around the clock for anything you can't find above.