# Writing an Authentication Module

https://dev.wisecp.com/es/writing-an-authentication-module

An Authentication module is a second factor method. It implements the methods the login core calls to enrol, challenge and verify a user.

## Overview

Three modules ship: Email, Sms and Totp. There is no base class. The core resolves a class from the method name and probes each method with `method_exists`.

There are two families. Does enrolment produce a secret shown to the user? If yes, implement `setup()`. If no, implement `verification()` to deliver a code.

Admin and client login run through the same core, so one module serves both.

## Prerequisites

- Write access to `coremio/modules/Authentication/`.
- A delivery channel or a shared secret scheme.
- A working mail or SMS driver, for a code method.
- [How Notification Templates Work](https://dev.wisecp.com/en/how-notification-templates-work).

## Structure

Three files are enough. There is no settings page and no controller: the operator only switches the method on.

```bash
coremio/modules/Authentication/Acme/
├── Acme.php        namespace WISECP\modules\Authentication; class Acme
├── config.php      meta + settings (setup, method, attempts, attempts_penalty_minute)
└── lang/
    ├── en.php      name + description, shown on the method card
    └── tr.php
```

The class is namespaced. The shipped modules write `namespace WISECP\modules\Authentication;`, while the core builds `WISECP\Modules\Authentication\{Method}`. Both resolve.

## Walkthrough

### Building the Module Class

1. Create `coremio/modules/Authentication/Acme/Acme.php` with a class named after the directory.
2. Load `config.php` in the constructor. It takes no arguments.
3. Prefix global classes with a backslash: `\Session`, `\Filter`, `\Notification`.
4. Implement your family's set: `setup()` plus `install()`, or `verification()`.
5. Implement `verify()` in both cases.

### Enrolment

1. For a setup method, `setup(array $user_data)` returns the secret and everything the wizard shows. It receives the plain user array.
2. The core generates setup data once and stashes it in the session, so the wizard and the enable step share a copy.
3. `install(array $data)` checks the typed code against the stashed data. An error status aborts enrolment.
4. What `setup()` returns is stored encrypted under the preference's `data` key and handed back later.
5. Implement `uninstall(array $data)` for the disable path.

### The Login Challenge

1. `verification(array $params)` runs when the challenge screen appears. Generate the code, deliver it, stash it, return the screen payload.
2. Rate limit delivery yourself and report the remaining seconds through `retry_delay`.
3. `verify(array $params, $code)` receives the submitted code. Return the successful or error status; the core applies the lockout.
4. For a recovery key, add `verify_recovery(array $params, $key)` and set `recovery` to true in the payload.
5. Test both screens: they keep separate sessions and counters.

## Reference

### The Method Contract

Every call is guarded, so each method is optional. The combination is not: without `setup()` or `verification()` a method cannot be enrolled.

| Method | Called when | Family |
| --- | --- | --- |
| `setup(array $user_data): array` | Account panel, no method active | secret based |
| `install(array $data = []): array` | Enrolment code submitted | secret based |
| `uninstall(array $data = []): array` | Method disabled | both |
| `verification(array $params = []): array` | Challenge screen, or before a sensitive change | code based |
| `verify(array $params = [], $code = ''): array` | Code submitted (login or step up) | both |
| `verify_recovery(array $params = [], $key = ''): array` | Recovery key submitted | optional |

The presence of `setup()` is a family marker read in four places. It decides whether the panel shows a wizard and whether a code is delivered before a sensitive change. The config key `settings.setup` says the same and is read first.

### Signatures and Payload Shapes

```php
public function __construct();

// SHAPE TRAP: $user_data is the plain user row (id, email, full_name),
// NOT the ['user' => ..., 'data' => ...] wrapper the verify methods receive.
public function setup(array $user_data): array;

// $data is exactly what setup() returned, replayed from the session stash.
public function install(array $data = []): array;

// $data is the stored preference data, i.e. what setup() returned.
public function uninstall(array $data = []): array;

// ORDER: params first, the submitted value second.
// $params = ['user' => ['id' => 5, 'email' => '...'], 'data' => [ /* what setup() returned */ ]]
public function verification(array $params = []): array;
public function verify(array $params = [], string|int|null $code = ''): array;
public function verify_recovery(array $params = [], string|int|null $key = ''): array;
```

```php
// setup(): everything the enrolment wizard renders, plus the secret to persist.
// The _preview keys exist so the screen can group the characters without
// the template having to know the format.
return [
    'secret_key'            => 'JBSWY3DPEHPK3PXP',
    'recovery_key'          => 'K7Q2M9XR4TZB6WVA',
    'secret_key_preview'    => 'JBSW Y3DP EHPK 3PXP',
    'recovery_key_preview'  => 'K7Q2 M9XR 4TZB 6WVA',
    'qr_code'               => 'data:image/png;base64,iVBORw0KG',   // a data URI, not a path
    'content'               => null,                                 // extra HTML for the wizard, or null
];

// verification(): how the challenge screen should behave.
return [
    'digit'       => 6,        // how many input boxes to draw
    'retry_delay' => 118,      // seconds until a resend is allowed; 0 means immediately
    'recovery'    => true,     // offer the recovery key field (omit or false to hide it)
    'content'     => null,     // extra HTML above the input, or null
];

// A delivery failure is reported through 'error', which the core surfaces verbatim.
return ['error' => 'Email could not be sent'];

// install(), uninstall(), verify(), verify_recovery(): a status, optionally a message.
return ['status' => 'successful'];
return ['status' => 'error', 'message' => 'That code did not match.'];
```

Only the literal string `successful` counts as success. Anything else, including a non array return, consumes an attempt.

### Config Keys and the Registry

`config.php` carries the family marker and the lockout policy. The core reads that block through the module metadata.

- **settings.setup**: True for a secret based method, false for a delivered code. Read first, `setup()` is the fallback.
- **settings.method**: A lower case channel tag (`email`, `sms`, `totp`) picking the card's wording and icon.
- **settings.attempts**: Wrong codes tolerated before the account is blocked. Zero disables counting.
- **settings.attempts_penalty_minute**: Block length in minutes. Defaults to five.
- **modules/authentications**: The operator's list of switched on method names (plural). A method absent from it is never offered.
- **Modules::Load()**: Returns **metadata**, not an instance: `['lang' => [...], 'config' => [...]]`. The instance is built with `new`.

### Where the Enrolment Is Stored

One method per user, held as an encrypted blob on the user's information record. You receive the decoded `data` half.

```php
// Written by enableTwoFactor, after install() approved the code.
$store = ['method' => $method];
if (method_exists($module, 'setup')) $store['data'] = $setupData;

User::setInfo($userId, ['authentication' => Crypt::encode(Utility::jencode($store), Config::get('crypt/user'))]);

// Read back on every challenge. A method the operator has since switched off
// returns false here, so the login proceeds without a second factor.
$raw  = User::getInfo($userId, ['authentication'])['authentication'] ?? '';
$pref = Utility::jdecode(Crypt::decode($raw, Config::get('crypt/user')), true);

// And this is the wrapper your verify() receives.
$params = ['user' => $userRow, 'data' => $pref['data'] ?? []];
```

Two hooks fire around this. `gate:user.two_factor_disable` vetoes a disable by returning a message; `action:user.two_factor_changed` is notified of every enable and disable.

## Example

A code based method that delivers through the notification system.

```php
<?php
namespace WISECP\modules\Authentication;

class Acme
{
    public array $config;

    private const DELAY = 120;

    public function __construct()
    {
        $this->config = include __DIR__ . DS . 'config.php';
    }

    public function verification(array $params = []): array
    {
        $userId    = (int) ($params['user']['id'] ?? 0);
        $stash     = $this->stash();
        $remaining = self::DELAY;
        $mayResend = true;

        // Blocked users must not be able to burn deliveries while they wait out the penalty.
        if (\User::CheckBlocked("member-login-authentication-attempt", $userId)) $mayResend = false;

        if ($stash) {
            $remaining = (int) $stash['expire'] - time();
            if ($remaining > 0) $mayResend = false;
        }

        if ($mayResend) {
            $expire = \DateManager::next_date(['second' => self::DELAY]);
            $code   = random_int(100000, 999999);

            $sent = \Notification::dispatch('user', 'two-factor-verification', [
                'user_id' => $userId,
                'code'    => $code,
                '_sync'   => true,
            ]);

            // 'error' is the delivery failure channel; the core prints this message as is.
            if (!$sent) return ['error' => 'Verification code could not be delivered.'];

            $this->stash(['code' => $code, 'expire' => \DateManager::strtotime($expire)]);
            $remaining = self::DELAY;
        }

        return [
            'digit'       => (int) ($this->config['settings']['digits'] ?? 6),
            'retry_delay' => max(0, $remaining),
            'content'     => null,
        ];
    }

    public function verify(array $params = [], string|int|null $code = ''): array
    {
        $stash = $this->stash();

        if (empty($code) || !$stash || (string) $stash['code'] !== (string) $code)
            return ['status' => 'error'];

        // Single use: clear it so a replay of the same code cannot pass.
        \Session::delete('AcmeAuthData');

        return ['status' => 'successful'];
    }

    public function uninstall(array $data = []): array
    {
        \Session::delete('AcmeAuthData');

        return ['status' => 'successful'];
    }

    private function stash(?array $write = null): array
    {
        if ($write !== null) {
            \Session::set('AcmeAuthData', \Utility::jencode($write), true);
            return $write;
        }

        $raw  = \Utility::jdecode((string) \Session::get('AcmeAuthData', true), true);
        if (!$raw) return [];

        // Expired stash is no stash, otherwise a stale code stays valid forever.
        if ((int) ($raw['expire'] ?? 0) < time()) {
            \Session::delete('AcmeAuthData');
            return [];
        }

        return $raw;
    }
}
```

```php
// The instance: Load first, then a bare new on the resolved class name.
$class  = 'WISECP\\Modules\\Authentication\\' . $method;
$module = Modules::Load('Authentication', $method) && class_exists($class) ? new $class() : false;

$params      = ['user' => $state['user'] ?? [], 'data' => $state['authentication']['data'] ?? []];
$recoveryKey = trim(str_replace(' ', '', $recoveryKey));

if ($recoveryKey !== '')
    $verify = method_exists($module, 'verify_recovery') ? $module->verify_recovery($params, $recoveryKey) : ['status' => 'error'];
else
    $verify = method_exists($module, 'verify') ? $module->verify($params, $code) : ['status' => 'error'];

if (!is_array($verify) || ($verify['status'] ?? 'error') !== 'successful') {
    // Load returns METADATA here, which is where the attempt policy comes from.
    $meta  = Modules::Load('Authentication', $method) ?: [];
    $total = (int) ($meta['config']['settings']['attempts'] ?? 0);

    if ($total) {
        $penalty  = (int) ($meta['config']['settings']['attempts_penalty_minute'] ?? 5);
        $attempts = (int) ($state['attempts'] ?? 0) + 1;

        if ($total - $attempts < 1) User::addBlocked($reason, $userId, [], DateManager::next_date(['minute' => $penalty]));
        else $state['attempts'] = $attempts;
    }

    return ['status' => 'error', 'message' => $message];
}
```

## Pitfalls

> **setup() gets the user row, the verify methods get a wrapper**
> 
> `setup()` reads `$user_data['email']`. Everything else gets `['user' => [...], 'data' => [...]]`. The wrong shape yields an empty value, not an error: the code never matches.

> **A recovery key succeeds once and then turns 2FA off**
> 
> A successful `verify_recovery()` makes the core delete the stored preference, so the user enrols again. The key is used once.

> **Rate limit delivery inside your module**
> 
> The core counts wrong answers, not resends. A reloaded challenge screen calls `verification()` again, so an unconditional send can spam an inbox. Hold the code and return `retry_delay`.

> **Return a status array, do not throw**
> 
> The core reads a status rather than catching. Report a wrong code as `['status' => 'error']`, a delivery failure as `['error' => '...']`. An uncaught throw breaks the login screen.

> **Enrolments survive a method being switched off**
> 
> The stored preference is checked against the active list on every read. Switching your method off does not lock out enrolled users; they stop being challenged. Switching it back on resumes.

## Related Articles

- [Writing a Social Login Provider](https://dev.wisecp.com/en/writing-a-social-login-provider)
- [How Notification Templates Work](https://dev.wisecp.com/en/how-notification-templates-work)
- [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)
- [Reading and Writing Configuration](https://dev.wisecp.com/en/reading-and-writing-configuration)
