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.
Structure
Three files are enough. There is no settings page and no controller: the operator only switches the method on.
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
- Create
coremio/modules/Authentication/Acme/Acme.phpwith a class named after the directory. - Load
config.phpin the constructor. It takes no arguments. - Prefix global classes with a backslash:
\Session,\Filter,\Notification. - Implement your family's set:
setup()plusinstall(), orverification(). - Implement
verify()in both cases.
Enrolment
- For a setup method,
setup(array $user_data)returns the secret and everything the wizard shows. It receives the plain user array. - The core generates setup data once and stashes it in the session, so the wizard and the enable step share a copy.
install(array $data)checks the typed code against the stashed data. An error status aborts enrolment.- What
setup()returns is stored encrypted under the preference'sdatakey and handed back later. - Implement
uninstall(array $data)for the disable path.
The Login Challenge
verification(array $params)runs when the challenge screen appears. Generate the code, deliver it, stash it, return the screen payload.- Rate limit delivery yourself and report the remaining seconds through
retry_delay. verify(array $params, $code)receives the submitted code. Return the successful or error status; the core applies the lockout.- For a recovery key, add
verify_recovery(array $params, $key)and setrecoveryto true in the payload. - 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
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;
// 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.
setup() is the fallback.
email, sms, totp) picking the card's wording and icon.
['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.
// 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
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;
}
}
// 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() reads $user_data['email']. Everything else gets ['user' => [...], 'data' => [...]]. The wrong shape yields an empty value, not an error: the code never matches.
A successful verify_recovery() makes the core delete the stored preference, so the user enrols again. The key is used once.
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.
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.
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
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.