Writing a Captcha Module
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.
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
- Create
coremio/modules/Captcha/Acme/Acme.php, class named after the directory, no namespace. - In the constructor, read config and language. Guard the config with an array check: an unsaved module returns nothing.
- Implement
getMarkup(), returning a fragment; the helper wraps it. - Implement
check(), returning a plain boolean. - Add
headJS()for a script tag andrefreshJS()to reset a spent token.
The Settings Fields
- Return a field descriptor from
config_fields(); those keys are what you read back. - Give the secret field the password type.
- Implement
save_fields(), returning the array to persist; false with$this->errorshows your message. - Merge rather than replace.
- The caller writes the returned array to
config.php.
Gating a Form
- Print the widget from the theme with the captcha tag, naming the area.
- In the submit handler, work out whether a challenge is required, then verify.
- Reply with a distinct status when the challenge is required but unanswered.
- Call the refresh function after every submit.
- 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.
| Method | Called by | Returns |
|---|---|---|
check() | The submit handler | bool; nothing else is inspected |
getMarkup() | The widget builder | the widget HTML fragment |
headJS() | The widget builder, once per request | a script tag, or empty |
refreshJS() | The widget builder | a JavaScript statement, or empty |
getInputName() | The helper constructor | the answer field's name |
generateDisplay() | The shared image endpoint | writes the image, returns empty |
config_fields() | The settings screen | the field descriptor map |
save_fields($fields) | The settings save | the 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
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;
// 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.
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.
| State | When | Result |
|---|---|---|
| Static | The area is switched on | The slot, visible, with the submit gate marker |
| Adaptive | The area is off but the bot shield watches it | The slot in a collapsed tray, no gate marker |
| Absent | Neither applies, and not forced | An 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.
<?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);
}
}
<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 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
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.
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.
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.
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.
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.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.