$d + ['twofa_remember_days' => '30']); add_filter('gatecraft_setting_rules', function (array $r, string $tab) { return $tab === 'security' ? $r + ['twofa_remember_days' => ['enum', ['0', '7', '14', '30']]] : $r; }, 10, 2); function trust_days(): int { return (int)setting('twofa_remember_days'); } function trust_hash(string $token): string { return hash_hmac('sha256', 'trusted-browser|' . $token, app_key()); } /** Remember this browser for the signed-in person. Call right after a correct authenticator code. */ function trust_remember(int $userId): void { $days = trust_days(); if ($days <= 0 || headers_sent()) return; $token = bin2hex(random_bytes(32)); $expires = time() + $days * 86400; q('DELETE FROM {trusted_browsers} WHERE user_id = ? AND expires_at < ?', [$userId, now()]); // At most ten browsers per person: the oldest make way. $keep = q('SELECT id FROM {trusted_browsers} WHERE user_id = ? ORDER BY id DESC LIMIT 9', [$userId])->fetchAll(Result::COLUMN); if ($keep) { $keep = array_map('intval', $keep); q('DELETE FROM {trusted_browsers} WHERE user_id = ? AND id NOT IN (' . placeholders($keep) . ')', array_merge([$userId], $keep)); } $ua = mb_substr(sanitize_text_field(wp_unslash((string)($_SERVER['HTTP_USER_AGENT'] ?? ''))), 0, 255); q('INSERT INTO {trusted_browsers} (user_id, token_hash, user_agent, created_at, expires_at) VALUES (?,?,?,?,?)', [$userId, trust_hash($token), $ua, now(), ldate('Y-m-d H:i:s', $expires)]); setcookie(GATECRAFT_TRUST_COOKIE, $userId . '.' . $token, ['expires' => $expires, 'path' => '/', 'secure' => is_https(), 'httponly' => true, 'samesite' => 'Lax']); audit('2fa.browser_remembered', 'user', $userId, ['days' => $days]); } /** True when this browser was remembered by this person and is still within its time. */ function trust_valid(int $userId): bool { if (trust_days() <= 0) return false; $c = isset($_COOKIE[GATECRAFT_TRUST_COOKIE]) && is_string($_COOKIE[GATECRAFT_TRUST_COOKIE]) ? sanitize_text_field(wp_unslash($_COOKIE[GATECRAFT_TRUST_COOKIE])) : ''; if (!preg_match('/^(\d+)\.([a-f0-9]{64})$/', $c, $m) || (int)$m[1] !== $userId) return false; $row = q1('SELECT id, created_at FROM {trusted_browsers} WHERE user_id = ? AND token_hash = ? AND expires_at > ?', [$userId, trust_hash($m[2]), now()]); if (!$row) return false; // A shorter setting applies to browsers remembered earlier, too. if (lstrtotime($row['created_at']) + trust_days() * 86400 < time()) return false; q('UPDATE {trusted_browsers} SET last_used_at = ? WHERE id = ?', [now(), $row['id']]); return true; } /** Forget every remembered browser of a person. */ function trust_forget_user(int $userId): void { try { q('DELETE FROM {trusted_browsers} WHERE user_id = ?', [$userId]); } catch (\Throwable $e) { // Before the upgrade that adds the table there is nothing to forget. } } function trust_count(int $userId): int { try { return (int)qv('SELECT COUNT(*) FROM {trusted_browsers} WHERE user_id = ? AND expires_at > ?', [$userId, now()]); } catch (\Throwable $e) { return 0; } } /** Account security: forget all remembered browsers, including this one. */ function h_trust_forget(): void { $u = current_user(); if (!$u) redirect('/login'); trust_forget_user((int)$u['id']); if (!headers_sent()) setcookie(GATECRAFT_TRUST_COOKIE, '', ['expires' => time() - 3600, 'path' => '/', 'secure' => is_https(), 'httponly' => true, 'samesite' => 'Lax']); audit('2fa.browsers_forgotten', 'user', (int)$u['id']); flash('success', 'Done. Every browser will ask for your authenticator code at the next sign-in.'); redirect('/account/security'); }