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 $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 $filters Optional filters (action, user_id, date_from, date_to). * * @return array> 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> $logs Log entries. * @param array $filters Filter criteria. * * @return array> 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 $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 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, 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; } }