Writing an Authentication Module

3 views Markdown

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.

Structure

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

layout
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.

MethodCalled whenFamily
setup(array $user_data): arrayAccount panel, no method activesecret based
install(array $data = []): arrayEnrolment code submittedsecret based
uninstall(array $data = []): arrayMethod disabledboth
verification(array $params = []): arrayChallenge screen, or before a sensitive changecode based
verify(array $params = [], $code = ''): arrayCode submitted (login or step up)both
verify_recovery(array $params = [], $key = ''): arrayRecovery key submittedoptional

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

signatures
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;
return shapes
// 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.

preference storage
// 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.

coremio/modules/Authentication/Acme/Acme.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;
    }
}
coremio/classes/Auth.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.

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.