['ID']]);
foreach ($users as $user) {
$old_trusted_devices = get_user_meta($user->ID, 'wrs_sc_trusted_devices', true);
if ($old_trusted_devices) {
update_user_meta($user->ID, 'scbr_trusted_devices', $old_trusted_devices);
delete_user_meta($user->ID, 'wrs_sc_trusted_devices');
}
$old_siren_muted = get_user_meta($user->ID, 'wrs_sc_siren_muted', true);
if ($old_siren_muted) {
update_user_meta($user->ID, 'scbr_siren_muted', $old_siren_muted);
delete_user_meta($user->ID, 'wrs_sc_siren_muted');
}
}
$opts = get_option($this->opt_key);
if (!$opts) {
try {
$this->maybe_bootstrap_options();
$opts = get_option($this->opt_key);
} catch (Exception $e) {
// Log error silently - don't show admin notice to avoid annoying users
}
}
if (!empty($opts) && empty($opts['master_admin_id'])) {
$opts['needs_master_admin'] = 1;
update_option($this->opt_key, $opts);
}
// Initialize blocked users option
if (!get_option($this->blocked_users_key)) {
update_option($this->blocked_users_key, []);
}
}
public function maybe_bootstrap_options() {
$defaults = [
'master_admin_id' => 0,
'needs_master_admin' => 1,
'status' => 'off',
// NOTE: the stop password is NEVER stored in plaintext. Only its
// hash (via wp_hash_password) is kept in siren_password_hash.
'siren_password_hash' => '',
'custom_audio_url' => '',
'features' => [
'siren_enabled' => 1,
'email_alerts' => 1,
'overlay_enabled' => 1,
'broadcast_siren' => 1,
],
'overlay_text' => 'Security Verification: Please enter the Siren Stop Password to continue.',
];
$existing = get_option($this->opt_key);
if (!$existing) {
$defaults['siren_password_hash'] = wp_hash_password('2210');
update_option($this->opt_key, $defaults);
return;
}
// One-time migration: hash any legacy plaintext password, then drop it.
if (empty($existing['siren_password_hash']) && !empty($existing['siren_password'])) {
$existing['siren_password_hash'] = wp_hash_password($existing['siren_password']);
unset($existing['siren_password']);
update_option($this->opt_key, $existing);
} elseif (!empty($existing['siren_password'])) {
unset($existing['siren_password']);
update_option($this->opt_key, $existing);
}
if (empty($existing['siren_password_hash'])) {
$existing['siren_password_hash'] = wp_hash_password('2210');
update_option($this->opt_key, $existing);
}
if (!isset($existing['custom_audio_url'])) {
$existing['custom_audio_url'] = '';
update_option($this->opt_key, $existing);
}
}
/* ---------------------------- Utilities ---------------------------- */
private function get_opts() {
return get_option($this->opt_key, []);
}
private function is_master_admin($user_id = null) {
$user_id = $user_id ?: get_current_user_id();
$opts = $this->get_opts();
return !empty($opts['master_admin_id']) && intval($opts['master_admin_id']) === intval($user_id);
}
private function admins_list() {
return get_users([
'role__in' => ['administrator', 'editor', 'author'],
'fields' => ['ID', 'user_login', 'user_email']
]);
}
private function current_device_id() {
return isset($_COOKIE[$this->cookie]) ? sanitize_text_field(wp_unslash($_COOKIE[$this->cookie])) : '';
}
private function trusted_devices($user_id) {
$list = get_user_meta($user_id, 'scbr_trusted_devices', true);
return is_array($list) ? $list : [];
}
private function trust_device($user_id, $device_id) {
$list = $this->trusted_devices($user_id);
if ($device_id && !in_array($device_id, $list, true)) {
$list[] = $device_id;
update_user_meta($user_id, 'scbr_trusted_devices', $list);
}
}
private function remove_device($user_id, $device_id) {
$list = $this->trusted_devices($user_id);
if (($key = array_search($device_id, $list, true)) !== false) {
unset($list[$key]);
update_user_meta($user_id, 'scbr_trusted_devices', array_values($list));
}
}
private function broadcast_new_device_event($by_user_id, $device_id) {
$token = $this->generate_uuid();
set_transient($this->transient_broadcast, [
'token' => $token,
'user' => $by_user_id,
'device_id' => $device_id,
'ip' => $this->ip_address(),
'time' => time(),
], 15 * MINUTE_IN_SECONDS);
}
private function admin_emails_except($exclude_user_id) {
$emails = [];
foreach ($this->admins_list() as $u) {
if (intval($u->ID) !== intval($exclude_user_id) && is_email($u->user_email)) {
$emails[] = $u->user_email;
}
}
return $emails;
}
private function ip_address() {
// Only REMOTE_ADDR is trusted. X-Forwarded-For / Client-IP headers are
// client-controlled and would let an attacker rotate IPs to evade the
// attempt limiter, so they are deliberately ignored.
$ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '';
if ($ip && filter_var($ip, FILTER_VALIDATE_IP)) {
return $ip;
}
return 'unknown';
}
private function is_ip_blocked($ip) {
return get_transient($this->ip_blocked_prefix . $ip) ? true : false;
}
private function increment_ip_attempts($ip) {
$attempts = (int) get_transient($this->ip_attempts_prefix . $ip);
$attempts++;
set_transient($this->ip_attempts_prefix . $ip, $attempts, HOUR_IN_SECONDS);
if ($attempts >= $this->max_attempts) {
set_transient($this->ip_blocked_prefix . $ip, 1, HOUR_IN_SECONDS);
delete_transient($this->ip_attempts_prefix . $ip);
}
return $attempts;
}
private function is_user_blocked($user_id) {
$blocked_users = get_option($this->blocked_users_key, []);
return in_array(intval($user_id), $blocked_users, true);
}
private function block_user($user_id) {
$blocked_users = get_option($this->blocked_users_key, []);
if (!in_array(intval($user_id), $blocked_users, true)) {
$blocked_users[] = intval($user_id);
update_option($this->blocked_users_key, $blocked_users);
}
}
private function unblock_user($user_id) {
$blocked_users = get_option($this->blocked_users_key, []);
if (($key = array_search(intval($user_id), $blocked_users, true)) !== false) {
unset($blocked_users[$key]);
update_option($this->blocked_users_key, array_values($blocked_users));
}
}
private function generate_uuid() {
return wp_generate_uuid4();
}
public function set_security_headers() {
if (is_admin()) {
header('X-Frame-Options: SAMEORIGIN');
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: no-referrer-when-downgrade');
}
}
/* ------------------------- Script Enqueue ------------------------- */
public function enqueue_scripts($hook) {
$opts = $this->get_opts();
$user_id = get_current_user_id();
$device_id = $this->current_device_id();
// Enqueue styles for relevant admin pages. The settings page and the
// dashboard widget always need the v2 styles, even when protection is
// off; when protection is on, every admin page needs them (broadcast
// banner, overlay, realtime alerts).
if ($hook === 'toplevel_page_scbr_settings' ||
$hook === 'dashboard' ||
$opts['status'] === 'on'
) {
wp_enqueue_style(
'scbr-admin',
plugin_dir_url(__FILE__) . 'css/scbr-admin.css',
[],
'2.0.5'
);
}
// Enqueue overlay script for new device detection.
// NOTE: the stop-password hash is NEVER printed to the page. The
// browser only collects the password; verification happens
// server-side in ajax_stop_siren() via wp_check_password().
if ($opts['status'] === 'on' && get_transient($this->transient_prefix . $user_id . '_' . $device_id)) {
wp_enqueue_script(
'scbr-overlay',
plugin_dir_url(__FILE__) . 'js/scbr-overlay.js',
[],
'2.0.5',
true
);
wp_localize_script('scbr-overlay', 'scbr_ajax', [
'ajaxurl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce($this->nonce_action),
]);
}
// Enqueue settings script. Banner action buttons (trust/block/mute)
// render on every admin page via admin_notices, so their handlers must
// be present globally whenever protection is on (delegated binding).
if ($hook === 'toplevel_page_scbr_settings' || $opts['status'] === 'on') {
wp_enqueue_media();
wp_enqueue_script(
'scbr-settings',
plugin_dir_url(__FILE__) . 'js/scbr-settings.js',
['jquery', 'wp-mediaelement'],
'2.0.5',
true
);
wp_localize_script('scbr-settings', 'scbr_ajax', [
'ajaxurl' => admin_url('admin-ajax.php'),
]);
}
// Enqueue broadcast script for broadcast banner
if ($opts['status'] === 'on' && get_transient($this->transient_broadcast)) {
wp_enqueue_script(
'scbr-broadcast',
plugin_dir_url(__FILE__) . 'js/scbr-broadcast.js',
[],
'2.0.5',
true
);
}
// Realtime broadcast poller: lets other logged-in admins see and hear
// a new-device alert without reloading the page.
if ($opts['status'] === 'on' && !empty($opts['features']['broadcast_siren']) && current_user_can('edit_posts')) {
wp_enqueue_script(
'scbr-broadcast-poll',
plugin_dir_url(__FILE__) . 'js/scbr-broadcast-poll.js',
[],
'2.0.5',
true
);
wp_localize_script('scbr-broadcast-poll', 'scbrPoll', [
'ajaxurl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce($this->nonce_action),
]);
}
// Enqueue inline style for scroll blocking
if ($opts['status'] === 'on' && get_transient($this->transient_prefix . $user_id . '_' . $device_id)) {
wp_add_inline_style('scbr-admin', 'html,body{overflow:hidden !important;}');
}
}
/* ------------------------- Cookie Handling ------------------------- */
public function ensure_device_cookie() {
if (!is_admin() || !is_user_logged_in()) return;
if (empty($_COOKIE[$this->cookie])) {
$uuid = $this->generate_uuid();
$secure = is_ssl();
$httponly = true;
$path = defined('ADMIN_COOKIE_PATH') ? ADMIN_COOKIE_PATH : '/';
$domain = COOKIE_DOMAIN ?: '';
setcookie($this->cookie, $uuid, time() + YEAR_IN_SECONDS, $path, $domain, $secure, $httponly);
$_COOKIE[$this->cookie] = $uuid;
}
}
/* ----------------------- New Device Detection ----------------------- */
public function maybe_handle_new_device_detection() {
$opts = $this->get_opts();
if (empty($opts) || empty($opts['status']) || $opts['status'] !== 'on') {
return;
}
$this->handle_new_device_detection();
}
public function handle_new_device_detection() {
if (!current_user_can('edit_posts')) return;
$opts = $this->get_opts();
if (empty($opts['features']['overlay_enabled'])) return;
$user_id = get_current_user_id();
$device_id = $this->current_device_id();
$ip = $this->ip_address();
if (!$device_id || $this->is_ip_blocked($ip)) {
return;
}
if ($this->is_user_blocked($user_id)) {
wp_logout();
wp_die('Your account has been blocked. Please contact the master admin.');
}
$trusted = $this->trusted_devices($user_id);
$is_trusted = in_array($device_id, $trusted, true);
if (!$is_trusted) {
$trigger_key = $this->transient_prefix . $user_id . '_' . $device_id;
$already_triggered = (bool) get_transient($trigger_key);
// Keep the overlay active while the user keeps browsing, but only
// notify ONCE per new device (not on every admin page load).
set_transient($trigger_key, 1, 10 * MINUTE_IN_SECONDS);
if (!$already_triggered) {
$this->broadcast_new_device_event($user_id, $device_id);
if (!empty($opts['features']['email_alerts'])) {
$by = wp_get_current_user();
$subject = sprintf('[%s] New Device Login Detected', wp_specialchars_decode(get_bloginfo('name'), ENT_QUOTES));
$body = sprintf(
"User: %s (ID %d)\nTime: %s\nIP: %s\nUser Agent: %s\nDevice ID: %s\n\nThis user logged in from a NEW device and must verify with the Siren Stop Password.",
$by->user_login,
$by->ID,
date_i18n('Y-m-d H:i:s'),
$ip,
isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : 'unknown',
$device_id
);
foreach ($this->admin_emails_except($user_id) as $email) {
wp_mail($email, $subject, $body);
}
}
}
}
}
/* ------------------------- Overlay Handling ------------------------- */
public function maybe_render_new_device_overlay() {
$uid = get_current_user_id();
$device_id = $this->current_device_id();
$opts = $this->get_opts();
if (empty($opts) || empty($opts['status']) || $opts['status'] !== 'on') return;
if (!$uid || !$device_id || !get_transient($this->transient_prefix . $uid . '_' . $device_id)) return;
if (empty($opts['features']['overlay_enabled'])) return;
$ip = $this->ip_address();
if ($this->is_ip_blocked($ip)) {
wp_die('Your IP has been temporarily blocked due to multiple failed password attempts. Please try again later.');
}
$this->render_new_device_overlay();
}
public function render_new_device_overlay() {
$opts = $this->get_opts();
$message = esc_html($opts['overlay_text'] ?? 'Security Verification: Enter Siren Stop Password.');
$siren_muted = (bool) get_user_meta(get_current_user_id(), 'scbr_siren_muted', true);
$audio_src = !empty($opts['custom_audio_url']) ? esc_url($opts['custom_audio_url']) : 'data:audio/mpeg;base64,' . $this->base64_siren();
$audio_type = !empty($opts['custom_audio_url']) ? esc_attr('audio/mpeg') : esc_attr('audio/mpeg');
?>
get_opts();
if (empty($opts) || empty($opts['status']) || $opts['status'] !== 'on') {
return;
}
if (empty($opts['features']['broadcast_siren'])) {
return;
}
$this->admin_header_siren();
}
/**
* Siren audio source for the broadcast alert, or '' when it must not play
* (feature off, no active alert, user muted, or insufficient caps).
*/
private function get_broadcast_audio_src() {
if (!current_user_can('edit_posts')) return '';
$opts = $this->get_opts();
if (empty($opts['features']['broadcast_siren'])) return '';
if (!get_transient($this->transient_broadcast)) return '';
$muted = get_user_meta(get_current_user_id(), 'scbr_siren_muted', true);
if ($muted) return '';
$opts = $this->get_opts();
return !empty($opts['custom_audio_url']) ? esc_url($opts['custom_audio_url']) : 'data:audio/mpeg;base64,' . $this->base64_siren();
}
public function admin_header_siren() {
$audio_src = $this->get_broadcast_audio_src();
if (!$audio_src) return;
// Output audio tag
?>
get_opts();
if (empty($opts) || empty($opts['status']) || $opts['status'] !== 'on') {
return;
}
if (empty($opts['features']['broadcast_siren'])) {
return;
}
$this->render_broadcast_banner();
}
public function render_broadcast_banner() {
if (!current_user_can('edit_posts')) return;
$broadcast = get_transient($this->transient_broadcast);
if (!$broadcast) {
return;
}
$trigger_user = intval($broadcast['user'] ?? 0);
$current_user_id = get_current_user_id();
$is_same_user = ($trigger_user === $current_user_id);
$device_id = $this->current_device_id();
$is_trigger_device = ($device_id === ($broadcast['device_id'] ?? ''));
if ($is_trigger_device && !$is_same_user) return;
$nonce = wp_create_nonce($this->nonce_action);
$u = get_user_by('id', $trigger_user);
$name = $u ? esc_html($u->user_login) : esc_html('Unknown User');
$is_blocked = $this->is_user_blocked($trigger_user);
$action = $is_blocked ? 'unblock' : 'block';
$action_label = $is_blocked ? 'Unblock User' : 'Block User';
$siren_muted = (bool) get_user_meta(get_current_user_id(), 'scbr_siren_muted', true);
$mute_class = $siren_muted ? 'scbr-unmute' : 'scbr-mute';
$mute_label = $siren_muted ? __('Unmute Siren', 'defentry') : __('Mute Siren', 'defentry');
?>
ALERT: New device login detected for your account (Device ID: ). Trust the device or contact the master admin to protect the website.
is_master_admin() && !$is_trigger_device): ?>
ALERT: New device login detected for (ID: ). Siren active until verified or action taken.
is_master_admin()): ?>
nonce_action, 'nonce');
$ip = $this->ip_address();
if ($this->is_ip_blocked($ip)) {
wp_send_json_error('Your IP is temporarily blocked due to multiple failed attempts.', 403);
}
$opts = $this->get_opts();
$hash = !empty($opts['siren_password_hash']) ? $opts['siren_password_hash'] : '';
// The password is verified ONLY here, server-side, with
// wp_check_password(). The browser never sees the hash and there is
// no client-computed token path (that was a full bypass).
$provided = isset($_POST['pass']) ? sanitize_text_field(wp_unslash($_POST['pass'])) : '';
if ($provided !== '' && $hash !== '' && wp_check_password($provided, $hash)) {
$user_id = get_current_user_id();
$device_id = $this->current_device_id();
if ($device_id) $this->trust_device($user_id, $device_id);
delete_transient($this->transient_prefix . $user_id . '_' . $device_id);
delete_user_meta($user_id, $this->attempts_meta);
delete_transient($this->ip_attempts_prefix . $ip);
delete_transient($this->transient_broadcast);
wp_send_json_success(['ok' => 1]);
}
$attempts = $this->increment_ip_attempts($ip);
$remaining = max(0, $this->max_attempts - $attempts);
wp_send_json_error('Wrong password. Attempts remaining: ' . $remaining);
}
public function ajax_block_user() {
if (!is_user_logged_in() || !$this->is_master_admin()) {
wp_send_json_error('Not allowed. Only Master Admin can block users.', 403);
}
check_ajax_referer($this->nonce_action, 'nonce');
$target_user_id = isset($_POST['target_user_id']) ? intval($_POST['target_user_id']) : 0;
if (!$target_user_id || !user_can($target_user_id, 'edit_posts')) {
wp_send_json_error('Invalid user or user does not have required role.', 400);
}
if ($target_user_id === get_current_user_id()) {
wp_send_json_error('Cannot block yourself.', 400);
}
$this->block_user($target_user_id);
$sessions = WP_Session_Tokens::get_instance($target_user_id);
$sessions->destroy_all();
delete_user_meta($target_user_id, 'scbr_trusted_devices');
// Instantly block the IP tied to the active new-device alert for this
// user, so the suspicious device is locked out immediately.
$broadcast = get_transient($this->transient_broadcast);
if (!empty($broadcast['ip'])
&& intval($broadcast['user'] ?? 0) === $target_user_id
&& filter_var($broadcast['ip'], FILTER_VALIDATE_IP)
) {
set_transient($this->ip_blocked_prefix . $broadcast['ip'], 1, HOUR_IN_SECONDS);
delete_transient($this->ip_attempts_prefix . $broadcast['ip']);
}
delete_transient($this->transient_broadcast);
$opts = $this->get_opts();
if (!empty($opts['features']['email_alerts'])) {
$by = wp_get_current_user();
$target = get_user_by('id', $target_user_id);
$subject = sprintf('[%s] User Blocked', wp_specialchars_decode(get_bloginfo('name'), ENT_QUOTES));
$body = sprintf(
"User %s (ID %d) has been blocked by %s (ID %d) due to a suspicious login.\nTime: %s\nIP: %s",
$target->user_login,
$target->ID,
$by->user_login,
$by->ID,
date_i18n('Y-m-d H:i:s'),
$this->ip_address()
);
foreach ($this->admin_emails_except($by->ID) as $email) {
wp_mail($email, $subject, $body);
}
}
wp_send_json_success(['ok' => 1]);
}
public function ajax_unblock_user() {
if (!is_user_logged_in() || !$this->is_master_admin()) {
wp_send_json_error('Not allowed. Only Master Admin can unblock users.', 403);
}
check_ajax_referer($this->nonce_action, 'nonce');
$target_user_id = isset($_POST['target_user_id']) ? intval($_POST['target_user_id']) : 0;
if (!$target_user_id || !user_can($target_user_id, 'edit_posts')) {
wp_send_json_error('Invalid user or user does not have required role.', 400);
}
if ($target_user_id === get_current_user_id()) {
wp_send_json_error('Cannot unblock yourself.', 400);
}
$this->unblock_user($target_user_id);
$opts = $this->get_opts();
if (!empty($opts['features']['email_alerts'])) {
$by = wp_get_current_user();
$target = get_user_by('id', $target_user_id);
$subject = sprintf('[%s] User Unblocked', wp_specialchars_decode(get_bloginfo('name'), ENT_QUOTES));
$body = sprintf(
"User %s (ID %d) has been unblocked by %s (ID %d).\nTime: %s\nIP: %s",
$target->user_login,
$target->ID,
$by->user_login,
$by->ID,
date_i18n('Y-m-d H:i:s'),
$this->ip_address()
);
foreach ($this->admin_emails_except($by->ID) as $email) {
wp_mail($email, $subject, $body);
}
}
wp_send_json_success(['ok' => 1]);
}
public function ajax_trust_device() {
if (!is_user_logged_in() || !current_user_can('edit_posts')) {
wp_send_json_error('Not allowed.', 403);
}
check_ajax_referer($this->nonce_action, 'nonce');
$target_user_id = isset($_POST['target_user_id']) ? intval($_POST['target_user_id']) : 0;
$device_id = isset($_POST['device_id']) ? sanitize_text_field(wp_unslash($_POST['device_id'])) : '';
if (!$target_user_id || !user_can($target_user_id, 'edit_posts') || !$device_id) {
wp_send_json_error('Invalid user or device.', 400);
}
// A user may trust their OWN device; trusting anyone else's device
// requires the Master Admin.
if ($target_user_id !== get_current_user_id() && !$this->is_master_admin()) {
wp_send_json_error('Not allowed. Only the Master Admin can trust devices for other users.', 403);
}
$this->trust_device($target_user_id, $device_id);
delete_transient($this->transient_prefix . $target_user_id . '_' . $device_id);
delete_transient($this->transient_broadcast);
$opts = $this->get_opts();
if (!empty($opts['features']['email_alerts'])) {
$by = wp_get_current_user();
$target = get_user_by('id', $target_user_id);
$subject = sprintf('[%s] Device Trusted', wp_specialchars_decode(get_bloginfo('name'), ENT_QUOTES));
$body = sprintf(
"Device for user %s (ID %d) has been trusted by %s (ID %d).\nTime: %s\nIP: %s",
$target->user_login,
$target->ID,
$by->user_login,
$by->ID,
date_i18n('Y-m-d H:i:s'),
$this->ip_address()
);
foreach ($this->admin_emails_except($by->ID) as $email) {
wp_mail($email, $subject, $body);
}
}
wp_send_json_success(['ok' => 1]);
}
public function ajax_remove_device() {
if (!is_user_logged_in() || !current_user_can('administrator') || !$this->is_master_admin()) {
wp_send_json_error('Not allowed.', 403);
}
check_ajax_referer($this->nonce_action, 'nonce');
$target_user_id = isset($_POST['target_user_id']) ? intval($_POST['target_user_id']) : 0;
$device_id = isset($_POST['device_id']) ? sanitize_text_field(wp_unslash($_POST['device_id'])) : '';
if (!$target_user_id || !user_can($target_user_id, 'edit_posts') || !$device_id) {
wp_send_json_error('Invalid user or device.', 400);
}
if ($target_user_id === get_current_user_id()) {
wp_send_json_error('Cannot remove your own devices.', 400);
}
$this->remove_device($target_user_id, $device_id);
$opts = $this->get_opts();
if (!empty($opts['features']['email_alerts'])) {
$by = wp_get_current_user();
$target = get_user_by('id', $target_user_id);
$subject = sprintf('[%s] Device Removed', wp_specialchars_decode(get_bloginfo('name'), ENT_QUOTES));
$body = sprintf(
"A trusted device for user %s (ID %d) was removed by %s (ID %d).\nTime: %s\nIP: %s",
$target->user_login,
$target->ID,
$by->user_login,
$by->ID,
date_i18n('Y-m-d H:i:s'),
$this->ip_address()
);
foreach ($this->admin_emails_except($by->ID) as $email) {
wp_mail($email, $subject, $body);
}
}
wp_send_json_success(['ok' => 1]);
}
public function ajax_mute_siren() {
if (!is_user_logged_in() || !current_user_can('edit_posts')) {
wp_send_json_error('Not allowed.', 403);
}
check_ajax_referer($this->nonce_action, 'nonce');
update_user_meta(get_current_user_id(), 'scbr_siren_muted', 1);
wp_send_json_success(['ok' => 1]);
}
/**
* Realtime broadcast check for the admin poller. Returns the rendered
* warning banner HTML plus the siren audio source so other logged-in
* admins see and hear a new-device alert without reloading the page.
*/
public function ajax_check_broadcast() {
if (!is_user_logged_in() || !current_user_can('edit_posts')) {
wp_send_json_error('Not allowed.', 403);
}
check_ajax_referer($this->nonce_action, 'nonce');
$opts = $this->get_opts();
if (empty($opts['status']) || $opts['status'] !== 'on' || empty($opts['features']['broadcast_siren'])) {
wp_send_json_success(['active' => false]);
}
$broadcast = get_transient($this->transient_broadcast);
if (!$broadcast) {
wp_send_json_success(['active' => false]);
}
ob_start();
$this->render_broadcast_banner();
$html = ob_get_clean();
wp_send_json_success([
'active' => true,
'token' => $broadcast['token'] ?? '',
'html' => $html,
'audio_src' => $this->get_broadcast_audio_src(),
]);
}
/**
* Instantly block the IP tied to the active new-device alert.
* Master Admin only. Used by the "Block IP" banner button.
*/
public function ajax_block_ip() {
if (!is_user_logged_in() || !$this->is_master_admin()) {
wp_send_json_error('Not allowed. Only Master Admin can block IPs.', 403);
}
check_ajax_referer($this->nonce_action, 'nonce');
$broadcast = get_transient($this->transient_broadcast);
$ip = $broadcast['ip'] ?? '';
if (!$ip || !filter_var($ip, FILTER_VALIDATE_IP)) {
wp_send_json_error('No active alert IP to block.', 400);
}
set_transient($this->ip_blocked_prefix . $ip, 1, HOUR_IN_SECONDS);
delete_transient($this->ip_attempts_prefix . $ip);
wp_send_json_success(['ok' => 1, 'ip' => $ip]);
}
/**
* Re-enable the broadcast siren after it was muted. Any user who could
* mute it can unmute it.
*/
public function ajax_unmute_siren() {
if (!is_user_logged_in() || !current_user_can('edit_posts')) {
wp_send_json_error('Not allowed.', 403);
}
check_ajax_referer($this->nonce_action, 'nonce');
delete_user_meta(get_current_user_id(), 'scbr_siren_muted');
wp_send_json_success(['ok' => 1]);
}
/* ------------------------------ Settings UI ------------------------------ */
public function register_settings_page() {
add_menu_page(
'Defentry',
'Defentry',
'manage_options',
'scbr_settings',
[$this, 'render_settings_page'],
'dashicons-shield',
65
);
// The control plane is Master-Admin-exclusive: once setup is done,
// secondary admins don't even see the menu. (First-run wizard stays
// open to all admins; direct URL access still hits the wp_die guard.)
$opts = $this->get_opts();
if (empty($opts['needs_master_admin']) && !$this->is_master_admin()) {
remove_menu_page('scbr_settings');
}
}
public function render_settings_page() {
$opts = $this->get_opts();
$needs_setup = !empty($opts['needs_master_admin']);
// First run: any administrator may open the setup wizard (the same
// audience the existing admin-notice form already serves). Once a
// Master Admin exists, only they may open these settings.
if ($needs_setup) {
if (!current_user_can('manage_options')) {
wp_die(esc_html(__('Only an administrator can set up Defentry.', 'defentry')));
}
} elseif (!$this->is_master_admin()) {
wp_die(esc_html(__('Only the Master Admin can access Defentry settings.', 'defentry')));
}
$nonce = wp_create_nonce($this->nonce_action);
wp_enqueue_media();
// First-run setup wizard.
if ($needs_setup) {
$this->render_setup_wizard($nonce);
return;
}
// Wizard steps 2-3, shown right after step 1 was saved.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display-only render-path switch; state changes happen in nonce-verified admin_post handlers.
if (isset($_GET['scbr_wizard'])) {
$this->render_setup_wizard_steps_23($opts, $nonce);
return;
}
$tabs = [
'overview' => __('Overview', 'defentry'),
'verification' => __('Verification', 'defentry'),
'devices' => __('Devices', 'defentry'),
'protection' => __('Protection', 'defentry'),
'master' => __('Master Admin', 'defentry'),
];
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display-only tab switch; value is sanitized with sanitize_key() and whitelisted against $tabs.
$tab = isset($_GET['scbr_tab']) ? sanitize_key(wp_unslash($_GET['scbr_tab'])) : 'overview';
if (!isset($tabs[$tab])) {
$tab = 'overview';
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display-only success notice; the save itself is nonce-verified in handle_save_settings().
if (isset($_GET['updated'])) {
echo '
';
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display-only success notice; the reset itself is nonce-verified in handle_trust_reset().
if (isset($_GET['reset'])) {
echo '
' . esc_html(__('Trusted devices have been reset.', 'defentry')) . '
';
}
?>
$label): ?>
>
render_overview_tab($opts, $nonce); ?>
>
render_verification_tab($opts, $nonce); ?>
>
render_devices_tab($nonce); ?>
>
render_protection_tab($nonce); ?>
>
render_master_tab($opts, $nonce); ?>
admins_list() as $u) {
$total += count($this->trusted_devices($u->ID));
}
return $total;
}
/**
* Hidden fields that carry the current feature flags and siren audio URL
* through partial settings forms (Overview toggle, Master Admin tab,
* setup wizard). Without them, saving a partial form would reset the
* untouched settings to their defaults. Presentation only.
*/
private function render_passthrough_fields($opts) {
$features = $opts['features'] ?? [];
foreach (['siren_enabled', 'email_alerts', 'overlay_enabled', 'broadcast_siren'] as $key) {
printf(
'' . "\n",
esc_attr($key),
!empty($features[$key]) ? 1 : 0
);
}
printf(
'' . "\n",
esc_attr($opts['custom_audio_url'] ?? '')
);
}
private function render_overview_tab($opts, $nonce) {
$is_on = !empty($opts['status']) && $opts['status'] === 'on';
$trusted_total = $this->count_trusted_devices();
$blocked_total = count(get_option($this->blocked_users_key, []));
?>
3
[__('Enable Siren Sound', 'defentry'), __('Play the siren when a new device needs verification.', 'defentry')],
'overlay_enabled' => [__('Full-Screen Overlay', 'defentry'), __('Block the screen with the red verification prompt on new devices.', 'defentry')],
'broadcast_siren' => [__('Siren for Other Admins', 'defentry'), __('Other logged-in admins hear the siren and see the blinking alert banner.', 'defentry')],
'email_alerts' => [__('Email Alerts', 'defentry'), __('Notify other admins by email about new device logins.', 'defentry')],
];
?>