# Writing a Captcha Module

https://dev.wisecp.com/es/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](https://dev.wisecp.com/en/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.

```bash
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`.

| 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

```php
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;
```

```php
// 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.

| 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
<?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);
    }
}
```

```twig
<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>
```

```php
// 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.

## Related Articles

- [Securing Theme Forms](https://dev.wisecp.com/en/securing-theme-forms)
- [Writing a Fraud Module](https://dev.wisecp.com/en/writing-a-fraud-module)
- [Module Configuration](https://dev.wisecp.com/en/module-configuration)
- [Writing a Hook Listener](https://dev.wisecp.com/en/writing-a-hook-listener)
- [Security Practices](https://dev.wisecp.com/en/security-practices)
- [Login and Registration](https://dev.wisecp.com/en/login-and-registration)
