452 lines
11 KiB
PHP
452 lines
11 KiB
PHP
<?php
|
|
/**
|
|
* Audit log class
|
|
*
|
|
* @package TwoFactorExtended
|
|
* @since 0.1.0
|
|
*/
|
|
|
|
// Prevent direct access.
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Class Two_Factor_Extended_Audit_Log
|
|
*
|
|
* Logs 2FA configuration changes and enforcement actions.
|
|
*
|
|
* @since 0.1.0
|
|
*/
|
|
class Two_Factor_Extended_Audit_Log {
|
|
|
|
/**
|
|
* Option key for audit logs.
|
|
*
|
|
* @since 0.1.0
|
|
* @var string
|
|
*/
|
|
const OPTION_KEY = 'two_factor_extended_audit_logs';
|
|
|
|
/**
|
|
* Maximum number of logs to keep.
|
|
*
|
|
* @since 0.1.0
|
|
* @var int
|
|
*/
|
|
const MAX_LOGS = 1000;
|
|
|
|
/**
|
|
* Log retention period in days.
|
|
*
|
|
* @since 0.1.0
|
|
* @var int
|
|
*/
|
|
const RETENTION_DAYS = 90;
|
|
|
|
/**
|
|
* Constructor.
|
|
*
|
|
* @since 0.1.0
|
|
*/
|
|
public function __construct() {
|
|
$this->init_hooks();
|
|
}
|
|
|
|
/**
|
|
* Initialize WordPress hooks.
|
|
*
|
|
* @since 0.1.0
|
|
*/
|
|
private function init_hooks(): void {
|
|
// Log 2FA configuration changes.
|
|
add_action( 'two_factor_user_options_update', array( $this, 'log_user_2fa_change' ), 10, 1 );
|
|
|
|
// Log settings changes.
|
|
add_action( 'update_option_' . TWO_FACTOR_EXTENDED_OPTION_SETTINGS, array( $this, 'log_settings_change' ), 10, 2 );
|
|
|
|
// Log enforcement actions.
|
|
add_action( 'wp_login_failed', array( $this, 'log_login_failure' ), 10, 1 );
|
|
|
|
// Daily cleanup of old logs.
|
|
add_action( 'two_factor_extended_daily_cleanup', array( $this, 'cleanup_old_logs' ) );
|
|
}
|
|
|
|
/**
|
|
* Log an event.
|
|
*
|
|
* @since 0.1.0
|
|
*
|
|
* @param string $action Action type.
|
|
* @param string $description Event description.
|
|
* @param int $user_id User ID (optional).
|
|
* @param array<string, mixed> $metadata Additional metadata (optional).
|
|
*/
|
|
public function log_event( string $action, string $description, int $user_id = 0, array $metadata = array() ): void {
|
|
$logs = $this->get_logs();
|
|
|
|
$log_entry = array(
|
|
'timestamp' => current_time( 'timestamp' ),
|
|
'action' => sanitize_key( $action ),
|
|
'description' => sanitize_text_field( $description ),
|
|
'user_id' => $user_id,
|
|
'actor_id' => get_current_user_id(),
|
|
'ip_address' => $this->get_client_ip(),
|
|
'metadata' => $metadata,
|
|
);
|
|
|
|
// Add to beginning of array.
|
|
array_unshift( $logs, $log_entry );
|
|
|
|
// Trim to max logs.
|
|
if ( count( $logs ) > self::MAX_LOGS ) {
|
|
$logs = array_slice( $logs, 0, self::MAX_LOGS );
|
|
}
|
|
|
|
update_option( self::OPTION_KEY, $logs, false );
|
|
}
|
|
|
|
/**
|
|
* Log user 2FA configuration change.
|
|
*
|
|
* @since 0.1.0
|
|
*
|
|
* @param int $user_id User ID.
|
|
*/
|
|
public function log_user_2fa_change( int $user_id ): void {
|
|
$user = get_userdata( $user_id );
|
|
|
|
if ( ! $user ) {
|
|
return;
|
|
}
|
|
|
|
$providers = Two_Factor_Extended_Provider_Detector::get_user_enabled_providers( $user_id );
|
|
$provider_names = array();
|
|
|
|
foreach ( array_keys( $providers ) as $class ) {
|
|
$names = Two_Factor_Extended_Provider_Detector::get_provider_names();
|
|
$provider_names[] = $names[ $class ] ?? $class;
|
|
}
|
|
|
|
$this->log_event(
|
|
'2fa_config_changed',
|
|
sprintf(
|
|
'User %s changed 2FA configuration',
|
|
$user->user_login
|
|
),
|
|
$user_id,
|
|
array(
|
|
'enabled_providers' => $provider_names,
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Log plugin settings change.
|
|
*
|
|
* @since 0.1.0
|
|
*
|
|
* @param mixed $old_value Old settings value.
|
|
* @param mixed $new_value New settings value.
|
|
*/
|
|
public function log_settings_change( $old_value, $new_value ): void {
|
|
$this->log_event(
|
|
'settings_changed',
|
|
'Plugin settings were updated',
|
|
0,
|
|
array(
|
|
'changed_keys' => $this->get_changed_keys( $old_value, $new_value ),
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Log login failure related to 2FA requirements.
|
|
*
|
|
* @since 0.1.0
|
|
*
|
|
* @param string $username Username.
|
|
*/
|
|
public function log_login_failure( string $username ): void {
|
|
$user = get_user_by( 'login', $username );
|
|
|
|
if ( ! $user ) {
|
|
return;
|
|
}
|
|
|
|
// Check if failure was due to 2FA requirements.
|
|
$enforcement = two_factor_extended()->get_enforcement();
|
|
|
|
if ( null === $enforcement ) {
|
|
return;
|
|
}
|
|
|
|
$required = $enforcement->get_required_providers_for_user( $user->ID );
|
|
|
|
if ( ! empty( $required ) && ! $enforcement->user_meets_requirements( $user->ID, $required ) ) {
|
|
$this->log_event(
|
|
'login_blocked',
|
|
sprintf(
|
|
'Login blocked for user %s due to missing 2FA requirements',
|
|
$username
|
|
),
|
|
$user->ID,
|
|
array(
|
|
'required_providers' => $required,
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all audit logs.
|
|
*
|
|
* @since 0.1.0
|
|
*
|
|
* @param array<string, mixed> $filters Optional filters (action, user_id, date_from, date_to).
|
|
*
|
|
* @return array<int, array<string, mixed>> Array of log entries.
|
|
*/
|
|
public function get_logs( array $filters = array() ): array {
|
|
$raw = get_option( self::OPTION_KEY, array() );
|
|
|
|
if ( ! is_array( $raw ) ) {
|
|
return array();
|
|
}
|
|
|
|
// Validate that each entry is an array.
|
|
$logs = array();
|
|
|
|
foreach ( $raw as $entry ) {
|
|
if ( is_array( $entry ) ) {
|
|
$logs[] = $entry;
|
|
}
|
|
}
|
|
|
|
// Apply filters.
|
|
if ( ! empty( $filters ) ) {
|
|
$logs = $this->filter_logs( $logs, $filters );
|
|
}
|
|
|
|
return $logs;
|
|
}
|
|
|
|
/**
|
|
* Filter logs based on criteria.
|
|
*
|
|
* @since 0.1.0
|
|
*
|
|
* @param array<int, array<string, mixed>> $logs Log entries.
|
|
* @param array<string, mixed> $filters Filter criteria.
|
|
*
|
|
* @return array<int, array<string, mixed>> Filtered logs.
|
|
*/
|
|
private function filter_logs( array $logs, array $filters ): array {
|
|
return array_values(
|
|
array_filter(
|
|
$logs,
|
|
function ( array $log ) use ( $filters ): bool {
|
|
// Filter by action.
|
|
if ( ! empty( $filters['action'] ) && ( $log['action'] ?? '' ) !== $filters['action'] ) {
|
|
return false;
|
|
}
|
|
|
|
// Filter by user_id.
|
|
$filter_user_id = isset( $filters['user_id'] ) && is_numeric( $filters['user_id'] ) ? (int) $filters['user_id'] : 0;
|
|
$log_user_id = isset( $log['user_id'] ) && is_numeric( $log['user_id'] ) ? (int) $log['user_id'] : 0;
|
|
|
|
if ( ! empty( $filters['user_id'] ) && $log_user_id !== $filter_user_id ) {
|
|
return false;
|
|
}
|
|
|
|
// Filter by date range.
|
|
$timestamp = isset( $log['timestamp'] ) && is_numeric( $log['timestamp'] ) ? (int) $log['timestamp'] : 0;
|
|
|
|
if ( ! empty( $filters['date_from'] ) && is_string( $filters['date_from'] ) ) {
|
|
$from = strtotime( $filters['date_from'] );
|
|
|
|
if ( false !== $from && $timestamp < $from ) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if ( ! empty( $filters['date_to'] ) && is_string( $filters['date_to'] ) ) {
|
|
$to = strtotime( $filters['date_to'] );
|
|
|
|
if ( false !== $to && $timestamp > $to ) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Clear all logs.
|
|
*
|
|
* @since 0.1.0
|
|
*
|
|
* @return bool True on success.
|
|
*/
|
|
public function clear_logs(): bool {
|
|
return delete_option( self::OPTION_KEY );
|
|
}
|
|
|
|
/**
|
|
* Cleanup old logs based on retention period.
|
|
*
|
|
* @since 0.1.0
|
|
*/
|
|
public function cleanup_old_logs(): void {
|
|
$logs = $this->get_logs();
|
|
$cutoff_time = current_time( 'timestamp' ) - ( self::RETENTION_DAYS * DAY_IN_SECONDS );
|
|
|
|
$filtered_logs = array_filter(
|
|
$logs,
|
|
function ( array $log ) use ( $cutoff_time ): bool {
|
|
$ts = isset( $log['timestamp'] ) && is_numeric( $log['timestamp'] ) ? (int) $log['timestamp'] : 0;
|
|
|
|
return $ts >= $cutoff_time;
|
|
}
|
|
);
|
|
|
|
update_option( self::OPTION_KEY, array_values( $filtered_logs ), false );
|
|
}
|
|
|
|
/**
|
|
* Export logs to CSV.
|
|
*
|
|
* @since 0.1.0
|
|
*
|
|
* @param array<string, mixed> $filters Optional filters.
|
|
*
|
|
* @return string CSV content.
|
|
*/
|
|
public function export_to_csv( array $filters = array() ): string {
|
|
$logs = $this->get_logs( $filters );
|
|
|
|
$csv = array();
|
|
$csv[] = array( 'Timestamp', 'Action', 'Description', 'User', 'Actor', 'IP Address' );
|
|
|
|
foreach ( $logs as $log ) {
|
|
$log_user_id = isset( $log['user_id'] ) && is_int( $log['user_id'] ) ? $log['user_id'] : 0;
|
|
$log_actor_id = isset( $log['actor_id'] ) && is_int( $log['actor_id'] ) ? $log['actor_id'] : 0;
|
|
$log_ts = isset( $log['timestamp'] ) && is_int( $log['timestamp'] ) ? $log['timestamp'] : null;
|
|
|
|
$user = $log_user_id ? get_userdata( $log_user_id ) : null;
|
|
$actor = $log_actor_id ? get_userdata( $log_actor_id ) : null;
|
|
|
|
$csv[] = array(
|
|
$log_ts ? gmdate( 'Y-m-d H:i:s', $log_ts ) : '',
|
|
isset( $log['action'] ) && is_string( $log['action'] ) ? $log['action'] : '',
|
|
isset( $log['description'] ) && is_string( $log['description'] ) ? $log['description'] : '',
|
|
$user ? $user->user_login : '-',
|
|
$actor ? $actor->user_login : 'System',
|
|
isset( $log['ip_address'] ) && is_string( $log['ip_address'] ) ? $log['ip_address'] : '',
|
|
);
|
|
}
|
|
|
|
// Convert to CSV string.
|
|
ob_start();
|
|
$handle = fopen( 'php://output', 'w' );
|
|
|
|
if ( false !== $handle ) {
|
|
foreach ( $csv as $row ) {
|
|
fputcsv( $handle, $row );
|
|
}
|
|
|
|
fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Simple CSV export
|
|
}
|
|
|
|
$output = ob_get_clean();
|
|
|
|
return false !== $output ? $output : '';
|
|
}
|
|
|
|
/**
|
|
* Get client IP address.
|
|
*
|
|
* @since 0.1.0
|
|
*
|
|
* @return string IP address.
|
|
*/
|
|
private function get_client_ip(): string {
|
|
$ip = '';
|
|
|
|
if ( ! empty( $_SERVER['HTTP_CLIENT_IP'] ) && is_string( $_SERVER['HTTP_CLIENT_IP'] ) ) {
|
|
$ip = sanitize_text_field( wp_unslash( $_SERVER['HTTP_CLIENT_IP'] ) );
|
|
} elseif ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) && is_string( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
|
|
$ip = sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) );
|
|
} elseif ( ! empty( $_SERVER['REMOTE_ADDR'] ) && is_string( $_SERVER['REMOTE_ADDR'] ) ) {
|
|
$ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) );
|
|
}
|
|
|
|
return filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : '';
|
|
}
|
|
|
|
/**
|
|
* Get changed keys between old and new settings.
|
|
*
|
|
* @since 0.1.0
|
|
*
|
|
* @param mixed $old_value Old value.
|
|
* @param mixed $new_value New value.
|
|
*
|
|
* @return array<int, string|int> Changed keys.
|
|
*/
|
|
private function get_changed_keys( $old_value, $new_value ): array {
|
|
if ( ! is_array( $old_value ) || ! is_array( $new_value ) ) {
|
|
return array();
|
|
}
|
|
|
|
$changed = array();
|
|
|
|
foreach ( $new_value as $key => $value ) {
|
|
if ( ! isset( $old_value[ $key ] ) || $old_value[ $key ] !== $value ) {
|
|
$changed[] = $key;
|
|
}
|
|
}
|
|
|
|
return $changed;
|
|
}
|
|
|
|
/**
|
|
* Get log statistics.
|
|
*
|
|
* @since 0.1.0
|
|
*
|
|
* @return array{total: int, by_action: array<string, int>, recent_count: int} Statistics.
|
|
*/
|
|
public function get_statistics(): array {
|
|
$logs = $this->get_logs();
|
|
|
|
$stats = array(
|
|
'total' => count( $logs ),
|
|
'by_action' => array(),
|
|
'recent_count' => 0,
|
|
);
|
|
|
|
$recent_cutoff = current_time( 'timestamp' ) - ( 7 * DAY_IN_SECONDS );
|
|
|
|
foreach ( $logs as $log ) {
|
|
$action = isset( $log['action'] ) && is_string( $log['action'] ) ? $log['action'] : '';
|
|
$ts = isset( $log['timestamp'] ) && is_numeric( $log['timestamp'] ) ? (int) $log['timestamp'] : 0;
|
|
|
|
// Count by action.
|
|
if ( ! isset( $stats['by_action'][ $action ] ) ) {
|
|
$stats['by_action'][ $action ] = 0;
|
|
}
|
|
$stats['by_action'][ $action ]++;
|
|
|
|
// Count recent logs (last 7 days).
|
|
if ( $ts >= $recent_cutoff ) {
|
|
$stats['recent_count']++;
|
|
}
|
|
}
|
|
|
|
return $stats;
|
|
}
|
|
}
|