get(self::ID); if (!$row) { return ['enabled' => false, 'grace_days' => 7, 'started' => 0, 'epoch' => '', '_revision' => 0]; } if (!isset($row['enabled'], $row['grace_days'], $row['started'], $row['epoch']) || !is_bool($row['enabled']) || !is_int($row['grace_days']) || $row['grace_days'] < 1 || $row['grace_days'] > 30 || !is_int($row['started']) || !is_string($row['epoch'])) { throw new \RuntimeException('The administrator protection policy could not be read. Restore its retained record or use documented host recovery.'); } return $row; } /** Uses the unfiltered role/capability map to avoid recursion through user_has_cap. */ public static function targeted(int $uid): bool { $user = get_userdata($uid); return $user instanceof \WP_User && (in_array('administrator', (array)$user->roles, true) || !empty($user->allcaps['manage_options'])); } private static function rolesTargeted(array $roles): bool { foreach ($roles as $role) { $definition = get_role($role); if ($role === 'administrator' || ($definition && !empty($definition->capabilities['manage_options']))) { return true; } } return false; } public static function status(int $uid): array { $policy = self::settings(); $targeted = self::targeted($uid); $enrolled = $uid > 0 && AccountSecurity::enrolled($uid); $start = (int)$policy['started']; if ($policy['enabled'] && $targeted) { $assignment = (new Store())->get('auth_policy_user_' . $uid); if ($assignment && ($assignment['epoch'] ?? '') === $policy['epoch']) { $start = max($start, (int)$assignment['started']); } } $deadline = $start + (int)$policy['grace_days'] * DAY_IN_SECONDS; $required = $policy['enabled'] && $targeted && !self::recovery(); return ['enabled' => $policy['enabled'], 'targeted' => $targeted, 'required' => $required, 'enrolled' => $enrolled, 'deadline' => $deadline, 'restricted' => $required && !$enrolled && time() >= $deadline, 'grace_days' => $policy['grace_days'], 'host_recovery' => self::recovery()]; } public static function restricted(int $uid): bool { if ($uid < 1 || self::recovery() || !self::targeted($uid)) { return false; } if (!array_key_exists($uid, self::$requestStatus)) { try { self::$requestStatus[$uid] = self::status($uid)['restricted']; } catch (\Throwable $error) { self::$requestStatus[$uid] = true; } } return self::$requestStatus[$uid]; } public static function capabilities(array $allcaps, array $caps, array $args, \WP_User $user): array { if (self::restricted((int)$user->ID)) { // Retain the account setup/recovery page. No setting, content, user or plugin administration. foreach ($allcaps as $name => $allowed) { $allcaps[$name] = in_array($name, ['read', 'exist'], true) && $allowed; } $allcaps['read'] = true; $allcaps['exist'] = true; $allcaps['do_not_allow'] = false; } return $allcaps; } public static function rest($result) { if (is_wp_error($result)) { return $result; } return self::restricted(get_current_user_id()) ? new \WP_Error('antivirus_ai_enrollment_required', 'Set up your administrator second factor on the WordPress Account protection page before using the API.', ['status' => 403]) : $result; } public static function adminGate(): void { if (!self::restricted(get_current_user_id())) { return; } if (DeviceTrust::recoveryRequest()) { return; } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Only size or unchanged-byte validation uses the original input here; the accepted value is unslashed and sanitized, and normalization cannot create an allowed method, action or credential. $method = isset( $_SERVER['REQUEST_METHOD'] ) && is_string( $_SERVER['REQUEST_METHOD'] ) && strlen( $_SERVER['REQUEST_METHOD'] ) <= 16 ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Only size or unchanged-byte validation uses the original input here; the accepted value is unslashed and sanitized, and normalization cannot create an allowed method, action or credential. if ( wp_doing_ajax() || 'GET' !== $method || $method !== wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) { wp_die(esc_html__('Set up your administrator second factor on Account protection before continuing.', 'shieldlume-security'), '', ['response' => 403]); } wp_safe_redirect(AccountSecurity::pageUrl()); exit; } public static function loginRedirect(string $redirect, string $requested, $user): string { return $user instanceof \WP_User && self::restricted((int)$user->ID) ? admin_url('profile.php?page=antivirus-ai-account') : $redirect; } public static function notice(): void { try { $state = self::status(get_current_user_id()); } catch (\Throwable $error) { return; } if (!$state['required'] || $state['enrolled']) { return; } $text = $state['restricted'] ? 'Administrator actions are restricted until you finish second-factor setup. Your existing sessions have not been deleted.' : 'Your administrator account needs a second factor by ' . wp_date('M j, Y H:i T', $state['deadline']) . '. Set it up now and save your recovery codes.'; echo '

' . esc_html($text) . ' Account protection

'; } public static function withLock(callable $operation): mixed { $store = new Store(); $token = $store->lock('auth_policy', 30); if (!$token) { throw new \RuntimeException('Administrator protection is being updated. Reload and retry.'); } try { return $operation(); } finally { $store->unlock('auth_policy', $token); } } /** Caller has completed account reauthentication and holds that account's lock. */ public static function save(int $uid, bool $enabled, int $days, int $revision, bool $acknowledged, array $account): void { if (!current_user_can('manage_options') || get_current_user_id() !== $uid) { throw new \RuntimeException('Only a verified site administrator can change this policy.'); } if ($days < 1 || $days > 30) { throw new \RuntimeException('Choose a grace period from 1 to 30 days.'); } self::withLock(static function () use ($uid, $enabled, $days, $revision, $acknowledged, $account): void { $old = self::settings(); if ((int)$old['_revision'] !== $revision) { throw new \RuntimeException('The policy changed in another session. Reload before making a new decision.'); } if ($enabled && (!$acknowledged || empty($account['recovery']) || (empty($account['totp']) && empty($account['keys'])) || !is_ssl() || !function_exists('sodium_crypto_secretbox') || self::recovery())) { throw new \RuntimeException('Before enabling this policy, enable and verify your own second factor, save recovery codes, use HTTPS with Sodium, and turn off host recovery.'); } if ($enabled) { AccountSecurity::origin(); } // Editing the policy never restarts an existing grace period. Disabling/re-enabling is explicit. $start = $enabled && !$old['enabled'] ? time() : (int)$old['started']; $epoch = $enabled && !$old['enabled'] ? bin2hex(random_bytes(16)) : $old['epoch']; (new Store())->put('auth_policy', self::ID, ['enabled' => $enabled, 'grace_days' => $days, 'started' => $start, 'epoch' => $epoch, 'changed_by' => $uid], 'active', $revision > 0 ? $revision : null); self::invalidate(); self::audit('administrator_mfa_policy_changed', ['actor_id' => $uid, 'enabled' => $enabled, 'grace_days' => $days]); }); } /** Serialize removal against enabling policy; the actual factor write remains inside this guard. */ public static function saveFactors(int $uid, array $row, callable $write): mixed { return self::withLock(static function () use ($uid, $row, $write): mixed { $policy = self::settings(); if ($policy['enabled'] && self::targeted($uid) && !self::recovery() && empty($row['totp']) && empty($row['keys'])) { throw new \RuntimeException('Administrator policy requires a second factor. Add a replacement before removing your last factor, or have a protected administrator disable the policy first. Host recovery remains available.'); } $result = $write(); self::invalidate(); return $result; }); } private static function assign(int $uid, bool $previouslyTargeted = false): void { try { self::withLock(static function () use ($uid, $previouslyTargeted): void { $policy = self::settings(); if (!$policy['enabled'] || (!$previouslyTargeted && !self::targeted($uid))) { return; } $store = new Store(); $id = 'auth_policy_user_' . $uid; $old = $store->get($id); // An account cannot renew its grace by removing and re-adding its administrator role. if ($old && ($old['epoch'] ?? '') === $policy['epoch']) { return; } $store->put('auth_policy_user', $id, ['epoch' => $policy['epoch'], 'started' => $previouslyTargeted ? (int)$policy['started'] : time()], 'active', $old['_revision'] ?? null); self::invalidate(); }); } catch (\Throwable $error) { /* Existing policy start remains the restrictive fallback; never grant an unpersisted grace. */ } } public static function roleChanged(int $uid, string $role, array $oldRoles): void { self::invalidate(); self::assign($uid, self::rolesTargeted($oldRoles)); } public static function roleAdded(int $uid, string $role): void { $user = get_userdata($uid); $prior = array_values(array_diff((array)($user->roles ?? []), [$role])); self::invalidate(); if (!self::rolesTargeted($prior)) { self::assign($uid); } } public static function roleRemoved(int $uid, string $role): void { self::invalidate(); if (self::rolesTargeted([$role])) { self::assign($uid, true); } } public static function created(int $uid): void { self::assign($uid); } public static function deleted(int $uid): void { self::invalidate(); try { (new Store())->delete('auth_policy_user_' . $uid); } catch (\Throwable $error) { /* Deleted users cannot authenticate. */ } } public static function audit(string $event, array $data): void { try { (new Store())->activity($event, $data); } catch (\Throwable $error) { update_option('antivirus_ai_account_monitor_warning', ['at' => time(), 'message' => 'An account-protection change succeeded but its activity record could not be saved. Review account protection and active sessions.'], false); } } }