issue('user:42', 'jane@example.com'); * // → ['challenge' => 'k3Q…', 'expires_at' => 1720000600, 'resend_after' => 1720000060] * // Hand $c['challenge'] to the client (cookie, JSON body, hidden field). Never the code. * * // Step 2 — the user typed the code: * $r = $tfa->verify($c['challenge'], '482913'); * // → ['ok' => true, 'subject' => 'user:42'] * // → ['ok' => false, 'error' => 'invalid_code', 'attempts_left' => 4] * // → ['ok' => false, 'error' => 'expired' | 'too_many_attempts' | 'not_found' | 'malformed'] * * // "Send again" button: * $r = $tfa->resend($c['challenge']); * // → same shape as issue(), or ['ok' => false, 'error' => 'cooldown', 'retry_after' => ts] * * Security properties * - Codes come from random_int(), are never stored in clear (HMAC-SHA256 keyed from the site's * salts) and are compared in constant time. * - A challenge dies after MAX_ATTEMPTS wrong codes, after TTL seconds, and on first success * (single use). The 6-digit space (1e6) against 5 attempts gives an attacker ≤ 0.0005 % per * challenge; the per-subject issue cap stops them farming fresh challenges. * - The challenge token is 32 random bytes: guessing it is not feasible, so knowing a victim's * e-mail alone gives no foothold. * - Everything is keyed per plugin (hook prefix) so two framework plugins on one site never * read each other's challenges. * * Delivery * By default the code goes out through wp_mail(). Plugins that own a mail transport * (SMTP2GO, FluentCRM, a Next.js mailer…) intercept with the `softminal_cache_two_factor_send` filter: * return true when you sent it yourself, false/null to fall through to wp_mail(). Subject and * body are filterable (`softminal_cache_two_factor_subject`, `softminal_cache_two_factor_message`). * * Storage * Transients (`softminal_cache_2fa_*`), so a persistent object cache is used when present and wp_options * otherwise; nothing to install. Override per instance with `setStore()` if you need Redis * directly or a shared store across sites. */ class EmailTwoFactor { /** Default code lifetime, seconds. */ public const TTL = 600; /** Wrong codes allowed per challenge before it is destroyed. */ public const MAX_ATTEMPTS = 5; /** Seconds before "send again" is honoured. */ public const RESEND_COOLDOWN = 60; /** Challenges one subject may open per hour (resends do not count). */ public const MAX_ISSUES_PER_HOUR = 10; public const CODE_LENGTH = 6; /** @var array{ttl:int,max_attempts:int,resend_cooldown:int,max_issues_per_hour:int,code_length:int} */ private array $options; /** @var callable|null fn(string $key): mixed|false */ private $storeGet = null; /** @var callable|null fn(string $key, mixed $value, int $ttl): void */ private $storeSet = null; /** @var callable|null fn(string $key): void */ private $storeDelete = null; /** * @param array{ttl?:int,max_attempts?:int,resend_cooldown?:int,max_issues_per_hour?:int,code_length?:int} $options */ public function __construct(array $options = []) { $this->options = [ 'ttl' => max(30, (int) ($options['ttl'] ?? self::TTL)), 'max_attempts' => max(1, (int) ($options['max_attempts'] ?? self::MAX_ATTEMPTS)), 'resend_cooldown' => max(0, (int) ($options['resend_cooldown'] ?? self::RESEND_COOLDOWN)), 'max_issues_per_hour' => max(1, (int) ($options['max_issues_per_hour'] ?? self::MAX_ISSUES_PER_HOUR)), 'code_length' => min(10, max(4, (int) ($options['code_length'] ?? self::CODE_LENGTH))), ]; } /** * Replace the transient store (e.g. with Redis for a multi-site SSO). All three callables * are required; keys are already prefixed and safe as-is. */ public function setStore(callable $get, callable $set, callable $delete): self { $this->storeGet = $get; $this->storeSet = $set; $this->storeDelete = $delete; return $this; } /** * Start a challenge for $subject (whatever identifies the account: "user:42", an e-mail, a * customer id) and mail the code to $email. $context is passed through to the mail filters * (e.g. ['site' => 'Softminal Admin', 'ip' => '…']) and stored with the challenge. * * @return array{ok:bool,challenge?:string,expires_at?:int,resend_after?:int,error?:string,retry_after?:int} */ public function issue(string $subject, string $email, array $context = []): array { $subject = trim($subject); $email = trim($email); if ($subject === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) { return ['ok' => false, 'error' => 'malformed']; } $rateKey = $this->key('issues_' . hash('sha256', strtolower($subject))); $issues = (int) $this->get($rateKey); if ($issues >= $this->options['max_issues_per_hour']) { return ['ok' => false, 'error' => 'too_many_requests', 'retry_after' => time() + 3600]; } $this->set($rateKey, $issues + 1, 3600); $challenge = $this->token(); $record = $this->freshRecord($subject, $email, $context); $sent = $this->deliver($record, $challenge); if (!$sent) { return ['ok' => false, 'error' => 'send_failed']; } $this->set($this->key('c_' . $challenge), $record, $this->options['ttl']); do_action('softminal_cache_two_factor_issued', $subject, $email, $context); return [ 'ok' => true, 'challenge' => $challenge, 'expires_at' => $record['expires_at'], 'resend_after' => $record['issued_at'] + $this->options['resend_cooldown'], ]; } /** * Mail a NEW code for an existing challenge (the old code stops working). Attempts are * reset; the challenge token and expiry are kept so the client changes nothing. * * @return array{ok:bool,challenge?:string,expires_at?:int,resend_after?:int,error?:string,retry_after?:int} */ public function resend(string $challenge): array { if (!$this->validToken($challenge)) { return ['ok' => false, 'error' => 'malformed']; } $storeKey = $this->key('c_' . $challenge); $record = $this->get($storeKey); if (!is_array($record)) { return ['ok' => false, 'error' => 'not_found']; } if ($record['expires_at'] <= time()) { $this->delete($storeKey); return ['ok' => false, 'error' => 'expired']; } $retryAt = $record['issued_at'] + $this->options['resend_cooldown']; if ($retryAt > time()) { return ['ok' => false, 'error' => 'cooldown', 'retry_after' => $retryAt]; } $fresh = $this->freshRecord($record['subject'], $record['email'], $record['context']); $fresh['expires_at'] = $record['expires_at']; // a resend never extends the window if (!$this->deliver($fresh, $challenge)) { return ['ok' => false, 'error' => 'send_failed']; } $this->set($storeKey, $fresh, max(1, $fresh['expires_at'] - time())); return [ 'ok' => true, 'challenge' => $challenge, 'expires_at' => $fresh['expires_at'], 'resend_after' => $fresh['issued_at'] + $this->options['resend_cooldown'], ]; } /** * Check the code the user typed. On success the challenge is consumed and the subject it was * issued for is returned, so the caller knows which account to log in. * * @return array{ok:bool,subject?:string,email?:string,context?:array,error?:string,attempts_left?:int} */ public function verify(string $challenge, string $code): array { $code = preg_replace('/\D+/', '', $code) ?? ''; if (!$this->validToken($challenge) || strlen($code) !== $this->options['code_length']) { return ['ok' => false, 'error' => 'malformed']; } $storeKey = $this->key('c_' . $challenge); $record = $this->get($storeKey); if (!is_array($record) || !isset($record['hash'], $record['subject'])) { return ['ok' => false, 'error' => 'not_found']; } if ($record['expires_at'] <= time()) { $this->delete($storeKey); return ['ok' => false, 'error' => 'expired']; } if (!hash_equals($record['hash'], $this->hash($code, $challenge))) { $record['attempts']++; $left = $this->options['max_attempts'] - $record['attempts']; if ($left <= 0) { $this->delete($storeKey); do_action('softminal_cache_two_factor_locked', $record['subject'], $record['email'], $record['context']); return ['ok' => false, 'error' => 'too_many_attempts']; } $this->set($storeKey, $record, max(1, $record['expires_at'] - time())); return ['ok' => false, 'error' => 'invalid_code', 'attempts_left' => $left]; } // Single use. $this->delete($storeKey); do_action('softminal_cache_two_factor_verified', $record['subject'], $record['email'], $record['context']); return ['ok' => true, 'subject' => $record['subject'], 'email' => $record['email'], 'context' => $record['context']]; } /** Drop a challenge early (user cancelled, logged in another way). */ public function cancel(string $challenge): void { if ($this->validToken($challenge)) { $this->delete($this->key('c_' . $challenge)); } } // ---------------------------------------------------------------- internals /** @return array{subject:string,email:string,context:array,hash:string,attempts:int,issued_at:int,expires_at:int,code:string} */ private function freshRecord(string $subject, string $email, array $context): array { $now = time(); $code = $this->code(); return [ 'subject' => $subject, 'email' => $email, 'context' => $context, 'hash' => '', // filled once the challenge token is known (deliver()) 'attempts' => 0, 'issued_at' => $now, 'expires_at' => $now + $this->options['ttl'], 'code' => $code, // transient: stripped before the record is stored ]; } /** * Bind the code to the challenge, mail it, and strip the clear code from the record. * Returns false when nothing could be sent (the challenge must then not be stored). */ private function deliver(array &$record, string $challenge): bool { $code = $record['code']; unset($record['code']); $record['hash'] = $this->hash($code, $challenge); $minutes = (int) ceil(($record['expires_at'] - time()) / 60); $subject = (string) apply_filters( 'softminal_cache_two_factor_subject', sprintf('%s is your verification code', $code), $record['subject'], $record['context'] ); $message = (string) apply_filters( 'softminal_cache_two_factor_message', sprintf( "Your verification code is:\n\n %s\n\nIt expires in %d minute%s. If you did not request it, you can ignore this e-mail.", $code, $minutes, $minutes === 1 ? '' : 's' ), $code, $record['subject'], $record['context'] ); /** * Let the plugin send the mail through its own transport. * Return true when handled; anything else falls back to wp_mail(). */ $handled = apply_filters('softminal_cache_two_factor_send', null, $record['email'], $code, $subject, $message, $record['subject'], $record['context']); if ($handled === true) { return true; } if ($handled === false) { return false; } return function_exists('wp_mail') ? (bool) wp_mail($record['email'], $subject, $message) : false; } private function code(): string { $len = $this->options['code_length']; return str_pad((string) random_int(0, (10 ** $len) - 1), $len, '0', STR_PAD_LEFT); } /** 32 random bytes, URL-safe base64 (43 chars, no padding). */ private function token(): string { return rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='); } private function validToken(string $token): bool { return (bool) preg_match('/^[A-Za-z0-9_-]{43}$/', $token); } /** The code is bound to its challenge so a hash from one challenge is useless for another. */ private function hash(string $code, string $challenge): string { return hash_hmac('sha256', $challenge . ':' . $code, $this->secret()); } private function secret(): string { if (function_exists('wp_salt')) { return wp_salt('auth'); } if (defined('AUTH_KEY') && defined('SECURE_AUTH_KEY')) { return AUTH_KEY . SECURE_AUTH_KEY; } throw new \RuntimeException('No WordPress salts available for two-factor hashing.'); } private function key(string $suffix): string { // Transient names are limited to 172 chars; hash long tokens defensively. return 'softminal_cache_2fa_' . (strlen($suffix) > 60 ? hash('sha256', $suffix) : $suffix); } /** @return mixed|false */ private function get(string $key) { return $this->storeGet ? ($this->storeGet)($key) : get_transient($key); } /** @param mixed $value */ private function set(string $key, $value, int $ttl): void { if ($this->storeSet) { ($this->storeSet)($key, $value, $ttl); return; } set_transient($key, $value, $ttl); } private function delete(string $key): void { if ($this->storeDelete) { ($this->storeDelete)($key); return; } delete_transient($key); } }