Move ~94 root class shells and root *-traits into domain directories (core, auth, user, credits, content, payment/gateways, forum, shop, sms, ai, messages, extensions, widgets, settings, admin). Update bootstrap require paths, co-locate traits with owners, fix forum/user relative requires, drop duplicate shop controller requires, and wire home-modules load. Path-only; class names and init order unchanged.
252 lines
7.6 KiB
PHP
252 lines
7.6 KiB
PHP
<?php
|
|
/**
|
|
* AKSRT API Audit Logger
|
|
*
|
|
* Logs sensitive REST API operations for security auditing.
|
|
* Does NOT log passwords, secrets, or private keys.
|
|
*
|
|
* @package AKSRT
|
|
*/
|
|
|
|
defined('ABSPATH') || exit;
|
|
|
|
class AKSRT_API_Audit {
|
|
|
|
private static $instance = null;
|
|
private static $table_name = '';
|
|
|
|
private function __construct() {}
|
|
|
|
public static function get_instance(): self {
|
|
if (null === self::$instance) {
|
|
self::$instance = new self();
|
|
global $wpdb;
|
|
self::$table_name = $wpdb->prefix . 'aksrt_api_audit';
|
|
}
|
|
return self::$instance;
|
|
}
|
|
|
|
public function init(): void {
|
|
add_action('rest_api_init', [$this, 'register_hooks']);
|
|
add_action('aksrt_db_migrate', [$this, 'maybe_create_table']);
|
|
}
|
|
|
|
/**
|
|
* Check if audit logging is enabled
|
|
*/
|
|
private function is_enabled(): bool {
|
|
if (!class_exists('AKSRT_Settings')) return false;
|
|
return (bool) AKSRT_Settings::get_value('api_audit_enabled', false);
|
|
}
|
|
|
|
/**
|
|
* Register REST pre-dispatch hook to log requests
|
|
*/
|
|
public function register_hooks(): void {
|
|
if (!$this->is_enabled()) return;
|
|
|
|
// Log after response to capture status code
|
|
add_filter('rest_post_dispatch', [$this, 'log_request'], 10, 3);
|
|
}
|
|
|
|
/**
|
|
* Log a REST API request
|
|
*/
|
|
public function log_request($result, $server, $request) {
|
|
$route = $request->get_route();
|
|
$method = $request->get_method();
|
|
|
|
// Only log write operations (POST, PUT, DELETE, PATCH)
|
|
if (!in_array($method, ['POST', 'PUT', 'DELETE', 'PATCH'], true)) {
|
|
return $result;
|
|
}
|
|
|
|
// Skip non-aksrt routes
|
|
if (strpos($route, '/aksrt/') === false) {
|
|
return $result;
|
|
}
|
|
|
|
// Skip health checks and non-sensitive routes
|
|
$skip_patterns = ['/health', '/settings/export', '/settings/import'];
|
|
foreach ($skip_patterns as $pattern) {
|
|
if (strpos($route, $pattern) !== false) {
|
|
return $result;
|
|
}
|
|
}
|
|
|
|
$user_id = get_current_user_id();
|
|
$ip = $this->get_client_ip();
|
|
$status = $result instanceof \WP_REST_Response ? $result->get_status() : 200;
|
|
|
|
$this->insert_log([
|
|
'user_id' => $user_id,
|
|
'method' => $method,
|
|
'route' => $route,
|
|
'ip' => $ip,
|
|
'status' => $status,
|
|
'user_agent' => substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255),
|
|
]);
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Manually log an event
|
|
*/
|
|
public static function log(string $action, array $context = [], int $user_id = 0): void {
|
|
$instance = self::get_instance();
|
|
if (!$instance->is_enabled()) return;
|
|
|
|
$instance->insert_log([
|
|
'user_id' => $user_id ?: get_current_user_id(),
|
|
'method' => 'ACTION',
|
|
'route' => $action,
|
|
'ip' => $instance->get_client_ip(),
|
|
'status' => 200,
|
|
'user_agent' => substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255),
|
|
'context' => wp_json_encode($context, JSON_UNESCAPED_UNICODE),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Insert a log entry
|
|
*/
|
|
private function insert_log(array $data): void {
|
|
global $wpdb;
|
|
|
|
$wpdb->insert(self::$table_name, [
|
|
'user_id' => $data['user_id'] ?? 0,
|
|
'method' => $data['method'] ?? '',
|
|
'route' => $data['route'] ?? '',
|
|
'ip' => $data['ip'] ?? '',
|
|
'status' => $data['status'] ?? 0,
|
|
'user_agent' => $data['user_agent'] ?? '',
|
|
'context' => $data['context'] ?? '',
|
|
'created_at' => current_time('mysql', true),
|
|
], ['%d', '%s', '%s', '%s', '%d', '%s', '%s', '%s']);
|
|
}
|
|
|
|
/**
|
|
* Get client IP
|
|
*/
|
|
private function get_client_ip(): string {
|
|
$ip = '';
|
|
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
|
$parts = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
|
|
$ip = trim($parts[0]);
|
|
} elseif (!empty($_SERVER['HTTP_X_REAL_IP'])) {
|
|
$ip = $_SERVER['HTTP_X_REAL_IP'];
|
|
} elseif (!empty($_SERVER['REMOTE_ADDR'])) {
|
|
$ip = $_SERVER['REMOTE_ADDR'];
|
|
}
|
|
return sanitize_text_field($ip);
|
|
}
|
|
|
|
/**
|
|
* Create audit table if not exists
|
|
*/
|
|
public function maybe_create_table(): void {
|
|
global $wpdb;
|
|
$table = self::$table_name;
|
|
$charset = $wpdb->get_charset_collate();
|
|
|
|
$sql = "CREATE TABLE IF NOT EXISTS {$table} (
|
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
user_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
|
method VARCHAR(10) NOT NULL DEFAULT '',
|
|
route VARCHAR(255) NOT NULL DEFAULT '',
|
|
ip VARCHAR(45) NOT NULL DEFAULT '',
|
|
status SMALLINT UNSIGNED NOT NULL DEFAULT 0,
|
|
user_agent VARCHAR(255) NOT NULL DEFAULT '',
|
|
context TEXT,
|
|
created_at DATETIME NOT NULL,
|
|
PRIMARY KEY (id),
|
|
KEY idx_user_id (user_id),
|
|
KEY idx_route (route),
|
|
KEY idx_created_at (created_at)
|
|
) {$charset};";
|
|
|
|
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
|
dbDelta($sql);
|
|
}
|
|
|
|
/**
|
|
* Query audit logs with pagination
|
|
*/
|
|
public function query_logs(array $args = []): array {
|
|
global $wpdb;
|
|
|
|
$defaults = [
|
|
'per_page' => 20,
|
|
'page' => 1,
|
|
'user_id' => 0,
|
|
'route' => '',
|
|
'method' => '',
|
|
'from' => '',
|
|
'to' => '',
|
|
];
|
|
$args = wp_parse_args($args, $defaults);
|
|
|
|
$where = ['1=1'];
|
|
$values = [];
|
|
|
|
if ($args['user_id'] > 0) {
|
|
$where[] = 'user_id = %d';
|
|
$values[] = $args['user_id'];
|
|
}
|
|
if (!empty($args['route'])) {
|
|
$where[] = 'route LIKE %s';
|
|
$values[] = '%' . $wpdb->esc_like($args['route']) . '%';
|
|
}
|
|
if (!empty($args['method'])) {
|
|
$where[] = 'method = %s';
|
|
$values[] = $args['method'];
|
|
}
|
|
if (!empty($args['from'])) {
|
|
$where[] = 'created_at >= %s';
|
|
$values[] = $args['from'];
|
|
}
|
|
if (!empty($args['to'])) {
|
|
$where[] = 'created_at <= %s';
|
|
$values[] = $args['to'];
|
|
}
|
|
|
|
$where_sql = implode(' AND ', $where);
|
|
$offset = ($args['page'] - 1) * $args['per_page'];
|
|
|
|
$count_sql = "SELECT COUNT(*) FROM " . self::$table_name . " WHERE {$where_sql}";
|
|
$total = (int) ($values
|
|
? $wpdb->get_var($wpdb->prepare($count_sql, $values))
|
|
: $wpdb->get_var($count_sql));
|
|
|
|
$values[] = $args['per_page'];
|
|
$values[] = $offset;
|
|
|
|
$logs = $wpdb->get_results(
|
|
$wpdb->prepare(
|
|
"SELECT * FROM " . self::$table_name . " WHERE {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d",
|
|
$values
|
|
),
|
|
ARRAY_A
|
|
);
|
|
|
|
return [
|
|
'total' => $total,
|
|
'per_page'=> $args['per_page'],
|
|
'page' => $args['page'],
|
|
'logs' => $logs ?: [],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Purge old logs
|
|
*/
|
|
public function purge_old(int $days = 90): int {
|
|
global $wpdb;
|
|
$cutoff = gmdate('Y-m-d H:i:s', time() - ($days * DAY_IN_SECONDS));
|
|
return (int) $wpdb->query(
|
|
$wpdb->prepare("DELETE FROM " . self::$table_name . " WHERE created_at < %s", $cutoff)
|
|
);
|
|
}
|
|
}
|