diff --git a/includes/class-audit-log.php b/includes/class-audit-log.php index ad24177..f52f456 100644 --- a/includes/class-audit-log.php +++ b/includes/class-audit-log.php @@ -77,10 +77,10 @@ class Two_Factor_Extended_Audit_Log { * * @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). + * @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(); @@ -176,6 +176,11 @@ class Two_Factor_Extended_Audit_Log { // 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 ) ) { @@ -198,17 +203,26 @@ class Two_Factor_Extended_Audit_Log { * * @since 0.1.0 * - * @param array $filters Optional filters (action, user_id, date_from, date_to). + * @param array $filters Optional filters (action, user_id, date_from, date_to). * - * @return array Array of log entries. + * @return array> Array of log entries. */ public function get_logs( array $filters = array() ): array { - $logs = get_option( self::OPTION_KEY, array() ); + $raw = get_option( self::OPTION_KEY, array() ); - if ( ! is_array( $logs ) ) { + 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 ); @@ -222,36 +236,51 @@ class Two_Factor_Extended_Audit_Log { * * @since 0.1.0 * - * @param array $logs Log entries. - * @param array $filters Filter criteria. + * @param array> $logs Log entries. + * @param array $filters Filter criteria. * - * @return array Filtered logs. + * @return array> Filtered logs. */ private function filter_logs( array $logs, array $filters ): array { - return array_filter( - $logs, - function ( $log ) use ( $filters ) { - // Filter by action. - if ( ! empty( $filters['action'] ) && $log['action'] !== $filters['action'] ) { - return false; - } + 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. - if ( ! empty( $filters['user_id'] ) && $log['user_id'] !== (int) $filters['user_id'] ) { - 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; - // Filter by date range. - if ( ! empty( $filters['date_from'] ) && $log['timestamp'] < strtotime( $filters['date_from'] ) ) { - return false; - } + if ( ! empty( $filters['user_id'] ) && $log_user_id !== $filter_user_id ) { + return false; + } - if ( ! empty( $filters['date_to'] ) && $log['timestamp'] > strtotime( $filters['date_to'] ) ) { - return false; - } + // Filter by date range. + $timestamp = isset( $log['timestamp'] ) && is_numeric( $log['timestamp'] ) ? (int) $log['timestamp'] : 0; - return true; - } + 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; + } + ) ); } @@ -277,8 +306,10 @@ class Two_Factor_Extended_Audit_Log { $filtered_logs = array_filter( $logs, - function ( $log ) use ( $cutoff_time ) { - return $log['timestamp'] >= $cutoff_time; + function ( array $log ) use ( $cutoff_time ): bool { + $ts = isset( $log['timestamp'] ) && is_numeric( $log['timestamp'] ) ? (int) $log['timestamp'] : 0; + + return $ts >= $cutoff_time; } ); @@ -290,7 +321,7 @@ class Two_Factor_Extended_Audit_Log { * * @since 0.1.0 * - * @param array $filters Optional filters. + * @param array $filters Optional filters. * * @return string CSV content. */ @@ -301,16 +332,20 @@ class Two_Factor_Extended_Audit_Log { $csv[] = array( 'Timestamp', 'Action', 'Description', 'User', 'Actor', 'IP Address' ); foreach ( $logs as $log ) { - $user = $log['user_id'] ? get_userdata( $log['user_id'] ) : null; - $actor = $log['actor_id'] ? get_userdata( $log['actor_id'] ) : null; + $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( - gmdate( 'Y-m-d H:i:s', $log['timestamp'] ), - $log['action'], - $log['description'], + $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', - $log['ip_address'], + isset( $log['ip_address'] ) && is_string( $log['ip_address'] ) ? $log['ip_address'] : '', ); } @@ -318,13 +353,17 @@ class Two_Factor_Extended_Audit_Log { ob_start(); $handle = fopen( 'php://output', 'w' ); - foreach ( $csv as $row ) { - fputcsv( $handle, $row ); + if ( false !== $handle ) { + foreach ( $csv as $row ) { + fputcsv( $handle, $row ); + } + + fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Simple CSV export } - fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Simple CSV export + $output = ob_get_clean(); - return ob_get_clean(); + return false !== $output ? $output : ''; } /** @@ -337,11 +376,11 @@ class Two_Factor_Extended_Audit_Log { private function get_client_ip(): string { $ip = ''; - if ( ! empty( $_SERVER['HTTP_CLIENT_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'] ) ) { + } 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'] ) ) { + } elseif ( ! empty( $_SERVER['REMOTE_ADDR'] ) && is_string( $_SERVER['REMOTE_ADDR'] ) ) { $ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ); } @@ -356,7 +395,7 @@ class Two_Factor_Extended_Audit_Log { * @param mixed $old_value Old value. * @param mixed $new_value New value. * - * @return array Changed keys. + * @return array Changed keys. */ private function get_changed_keys( $old_value, $new_value ): array { if ( ! is_array( $old_value ) || ! is_array( $new_value ) ) { @@ -379,7 +418,7 @@ class Two_Factor_Extended_Audit_Log { * * @since 0.1.0 * - * @return array Statistics. + * @return array{total: int, by_action: array, recent_count: int} Statistics. */ public function get_statistics(): array { $logs = $this->get_logs(); @@ -393,14 +432,17 @@ class Two_Factor_Extended_Audit_Log { $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'][ $log['action'] ] ) ) { - $stats['by_action'][ $log['action'] ] = 0; + if ( ! isset( $stats['by_action'][ $action ] ) ) { + $stats['by_action'][ $action ] = 0; } - $stats['by_action'][ $log['action'] ]++; + $stats['by_action'][ $action ]++; // Count recent logs (last 7 days). - if ( $log['timestamp'] >= $recent_cutoff ) { + if ( $ts >= $recent_cutoff ) { $stats['recent_count']++; } } diff --git a/includes/class-bulk-actions.php b/includes/class-bulk-actions.php index cd3489c..90f71df 100644 --- a/includes/class-bulk-actions.php +++ b/includes/class-bulk-actions.php @@ -50,9 +50,9 @@ class Two_Factor_Extended_Bulk_Actions { * * @since 0.1.0 * - * @param array $actions Existing bulk actions. + * @param array $actions Existing bulk actions. * - * @return array Modified bulk actions. + * @return array Modified bulk actions. */ public function register_bulk_actions( array $actions ): array { if ( ! current_user_can( 'manage_options' ) ) { @@ -72,7 +72,7 @@ class Two_Factor_Extended_Bulk_Actions { * * @param string $redirect_to Redirect URL. * @param string $action Action name. - * @param array $user_ids User IDs. + * @param int[] $user_ids User IDs. * * @return string Modified redirect URL. */ @@ -111,14 +111,18 @@ class Two_Factor_Extended_Bulk_Actions { * * @since 0.1.0 * - * @param array $user_ids User IDs. + * @param int[] $user_ids User IDs. * * @return int Number of users processed. */ private function bulk_require_2fa( array $user_ids ): int { - $processed = 0; + $processed = 0; $enforcement = two_factor_extended()->get_enforcement(); + if ( null === $enforcement ) { + return 0; + } + foreach ( $user_ids as $user_id ) { // Skip if user doesn't exist. $user = get_userdata( $user_id ); @@ -138,17 +142,21 @@ class Two_Factor_Extended_Bulk_Actions { update_user_meta( $user_id, Two_Factor_Extended_Enforcement::META_ENFORCEMENT_START, time() ); // Log the action. - two_factor_extended()->get_audit_log()->log_event( - 'bulk_require_2fa', - sprintf( - 'Bulk action: Require 2FA for user %s', - $user->user_login - ), - $user_id, - array( - 'required_providers' => $required, - ) - ); + $audit_log = two_factor_extended()->get_audit_log(); + + if ( null !== $audit_log ) { + $audit_log->log_event( + 'bulk_require_2fa', + sprintf( + 'Bulk action: Require 2FA for user %s', + $user->user_login + ), + $user_id, + array( + 'required_providers' => $required, + ) + ); + } $processed++; } @@ -161,7 +169,7 @@ class Two_Factor_Extended_Bulk_Actions { * * @since 0.1.0 * - * @param array $user_ids User IDs. + * @param int[] $user_ids User IDs. * * @return int Number of users processed. */ @@ -182,14 +190,18 @@ class Two_Factor_Extended_Bulk_Actions { delete_user_meta( $user_id, Two_Factor_Extended_Enforcement::META_GRACE_NOTIFIED ); // Log the action. - two_factor_extended()->get_audit_log()->log_event( - 'bulk_reset_grace', - sprintf( - 'Bulk action: Reset grace period for user %s', - $user->user_login - ), - $user_id - ); + $audit_log = two_factor_extended()->get_audit_log(); + + if ( null !== $audit_log ) { + $audit_log->log_event( + 'bulk_reset_grace', + sprintf( + 'Bulk action: Reset grace period for user %s', + $user->user_login + ), + $user_id + ); + } $processed++; } diff --git a/includes/class-cli-commands.php b/includes/class-cli-commands.php index d29afe8..b47793b 100644 --- a/includes/class-cli-commands.php +++ b/includes/class-cli-commands.php @@ -39,15 +39,21 @@ class Two_Factor_Extended_CLI_Commands { * * @since 0.1.0 * - * @param array $args Positional arguments. - * @param array $assoc_args Associative arguments. + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. */ public function status( array $args, array $assoc_args ): void { $role = $assoc_args['role'] ?? ''; $format = $assoc_args['format'] ?? 'table'; $compliance = two_factor_extended()->get_compliance_report(); - $stats = $compliance->get_compliance_stats( array( 'role' => $role ) ); + + if ( null === $compliance ) { + WP_CLI::error( 'Compliance report module is not available.' ); + return; + } + + $stats = $compliance->get_compliance_stats( array( 'role' => $role ) ); if ( 'json' === $format ) { WP_CLI::line( wp_json_encode( $stats, JSON_PRETTY_PRINT ) ); @@ -129,8 +135,8 @@ class Two_Factor_Extended_CLI_Commands { * * @since 0.1.0 * - * @param array $args Positional arguments. - * @param array $assoc_args Associative arguments. + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. */ public function enforce( array $args, array $assoc_args ): void { if ( empty( $assoc_args['role'] ) ) { @@ -150,7 +156,13 @@ class Two_Factor_Extended_CLI_Commands { } $enforcement = two_factor_extended()->get_enforcement(); - $processed = 0; + + if ( null === $enforcement ) { + WP_CLI::error( 'Enforcement module is not available.' ); + return; + } + + $processed = 0; $progress = WP_CLI\Utils\make_progress_bar( 'Enforcing 2FA requirements', count( $users ) ); @@ -170,15 +182,19 @@ class Two_Factor_Extended_CLI_Commands { } // Log the action. - two_factor_extended()->get_audit_log()->log_event( - 'cli_enforce', - sprintf( 'WP-CLI: Enforced 2FA for user %s', $user->user_login ), - $user->ID, - array( - 'role' => $role, - 'required_providers' => $required, - ) - ); + $audit_log = two_factor_extended()->get_audit_log(); + + if ( null !== $audit_log ) { + $audit_log->log_event( + 'cli_enforce', + sprintf( 'WP-CLI: Enforced 2FA for user %s', $user->user_login ), + $user->ID, + array( + 'role' => $role, + 'required_providers' => $required, + ) + ); + } $processed++; $progress->tick(); @@ -217,16 +233,21 @@ class Two_Factor_Extended_CLI_Commands { * * @since 0.1.0 * - * @param array $args Positional arguments. - * @param array $assoc_args Associative arguments. + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. */ public function report( array $args, array $assoc_args ): void { - $role = $assoc_args['role'] ?? ''; - $format = $assoc_args['format'] ?? 'table'; + $role = $assoc_args['role'] ?? ''; + $format = $assoc_args['format'] ?? 'table'; $non_compliant_only = isset( $assoc_args['non-compliant-only'] ); $compliance = two_factor_extended()->get_compliance_report(); + if ( null === $compliance ) { + WP_CLI::error( 'Compliance report module is not available.' ); + return; + } + if ( $non_compliant_only ) { $users = $compliance->get_non_compliant_users( array( 'role' => $role ) ); @@ -237,12 +258,18 @@ class Two_Factor_Extended_CLI_Commands { $items = array(); foreach ( $users as $user_data ) { + $roles_raw = isset( $user_data['roles'] ) && is_array( $user_data['roles'] ) ? $user_data['roles'] : array(); + $missing_providers_raw = isset( $user_data['missing_providers'] ) && is_array( $user_data['missing_providers'] ) ? $user_data['missing_providers'] : array(); + + $roles = array_filter( $roles_raw, 'is_string' ); + $missing_providers = array_filter( $missing_providers_raw, 'is_string' ); + $items[] = array( 'User ID' => $user_data['user_id'], 'Username' => $user_data['user_login'], 'Email' => $user_data['user_email'], - 'Roles' => implode( ', ', $user_data['roles'] ), - 'Missing Providers' => implode( ', ', $user_data['missing_providers'] ), + 'Roles' => implode( ', ', $roles ), + 'Missing Providers' => implode( ', ', $missing_providers ), 'Grace Period' => $user_data['in_grace_period'] ? 'Yes' : 'No', 'Days Remaining' => $user_data['grace_remaining'], ); @@ -275,8 +302,8 @@ class Two_Factor_Extended_CLI_Commands { * * @since 0.1.0 * - * @param array $args Positional arguments. - * @param array $assoc_args Associative arguments. + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. */ public function reset( array $args, array $assoc_args ): void { if ( empty( $assoc_args['user'] ) ) { @@ -288,7 +315,7 @@ class Two_Factor_Extended_CLI_Commands { $user_identifier = $assoc_args['user']; if ( is_numeric( $user_identifier ) ) { - $user = get_userdata( $user_identifier ); + $user = get_userdata( (int) $user_identifier ); } elseif ( is_email( $user_identifier ) ) { $user = get_user_by( 'email', $user_identifier ); } else { @@ -307,11 +334,15 @@ class Two_Factor_Extended_CLI_Commands { delete_user_meta( $user->ID, Two_Factor_Extended_Enforcement::META_GRACE_NOTIFIED ); // Log the action. - two_factor_extended()->get_audit_log()->log_event( - 'cli_reset_grace', - sprintf( 'WP-CLI: Reset grace period for user %s', $user->user_login ), - $user->ID - ); + $audit_log = two_factor_extended()->get_audit_log(); + + if ( null !== $audit_log ) { + $audit_log->log_event( + 'cli_reset_grace', + sprintf( 'WP-CLI: Reset grace period for user %s', $user->user_login ), + $user->ID + ); + } WP_CLI::success( sprintf( diff --git a/includes/class-compliance-report.php b/includes/class-compliance-report.php index ad22a46..1ade977 100644 --- a/includes/class-compliance-report.php +++ b/includes/class-compliance-report.php @@ -25,9 +25,9 @@ class Two_Factor_Extended_Compliance_Report { * * @since 0.1.0 * - * @param array $args Optional arguments (role, blog_id). + * @param array $args Optional arguments (role, blog_id). * - * @return array Compliance statistics. + * @return array{total_users: int, compliant_users: int, non_compliant: int, grace_period: int, no_requirements: int, by_role: array} Compliance statistics. */ public function get_compliance_stats( array $args = array() ): array { $defaults = array( @@ -63,6 +63,10 @@ class Two_Factor_Extended_Compliance_Report { $enforcement = two_factor_extended()->get_enforcement(); + if ( null === $enforcement ) { + return $stats; + } + foreach ( $users as $user ) { $required = $enforcement->get_required_providers_for_user( $user->ID ); @@ -72,7 +76,7 @@ class Two_Factor_Extended_Compliance_Report { } $compliant = $enforcement->user_meets_requirements( $user->ID, $required ); - $in_grace = $enforcement->is_in_grace_period( $user->ID ); + $in_grace = $enforcement->is_in_grace_period( $user->ID ); if ( $compliant ) { $stats['compliant_users']++; @@ -112,9 +116,9 @@ class Two_Factor_Extended_Compliance_Report { * * @since 0.1.0 * - * @param array $args Optional arguments. + * @param array $args Optional arguments. * - * @return array Array of non-compliant user data. + * @return array> Array of non-compliant user data. */ public function get_non_compliant_users( array $args = array() ): array { $defaults = array( @@ -136,9 +140,13 @@ class Two_Factor_Extended_Compliance_Report { $user_args['blog_id'] = $args['blog_id']; } - $users = get_users( $user_args ); + $users = get_users( $user_args ); $non_compliant = array(); - $enforcement = two_factor_extended()->get_enforcement(); + $enforcement = two_factor_extended()->get_enforcement(); + + if ( null === $enforcement ) { + return $non_compliant; + } foreach ( $users as $user ) { $required = $enforcement->get_required_providers_for_user( $user->ID ); @@ -150,24 +158,23 @@ class Two_Factor_Extended_Compliance_Report { $compliant = $enforcement->user_meets_requirements( $user->ID, $required ); if ( ! $compliant ) { - $enabled = Two_Factor_Extended_Provider_Detector::get_user_enabled_providers( $user->ID ); - $missing = array_diff( $required, array_keys( $enabled ) ); - + $enabled = Two_Factor_Extended_Provider_Detector::get_user_enabled_providers( $user->ID ); + $missing = array_diff( $required, array_keys( $enabled ) ); $provider_names = Two_Factor_Extended_Provider_Detector::get_provider_names(); - $missing_names = array(); + $missing_names = array(); foreach ( $missing as $class ) { $missing_names[] = $provider_names[ $class ] ?? $class; } $non_compliant[] = array( - 'user_id' => $user->ID, - 'user_login' => $user->user_login, - 'user_email' => $user->user_email, - 'roles' => Two_Factor_Extended_Role_Manager::get_user_roles( $user->ID ), + 'user_id' => $user->ID, + 'user_login' => $user->user_login, + 'user_email' => $user->user_email, + 'roles' => Two_Factor_Extended_Role_Manager::get_user_roles( $user->ID ), 'missing_providers' => $missing_names, - 'in_grace_period' => $enforcement->is_in_grace_period( $user->ID ), - 'grace_remaining' => $enforcement->get_grace_period_remaining_days( $user->ID ), + 'in_grace_period' => $enforcement->is_in_grace_period( $user->ID ), + 'grace_remaining' => $enforcement->get_grace_period_remaining_days( $user->ID ), ); } } @@ -180,7 +187,7 @@ class Two_Factor_Extended_Compliance_Report { * * @since 0.1.0 * - * @return array Network compliance report. + * @return array Network compliance report. */ public function get_network_report(): array { if ( ! is_multisite() ) { @@ -197,7 +204,7 @@ class Two_Factor_Extended_Compliance_Report { ); foreach ( $sites as $site ) { - switch_to_blog( $site->blog_id ); + switch_to_blog( (int) $site->blog_id ); $site_stats = $this->get_compliance_stats( array( 'blog_id' => $site->blog_id ) ); @@ -224,7 +231,7 @@ class Two_Factor_Extended_Compliance_Report { * * @since 0.1.0 * - * @param array $args Optional arguments. + * @param array $args Optional arguments. * * @return string CSV content. */ @@ -235,27 +242,41 @@ class Two_Factor_Extended_Compliance_Report { $csv[] = array( 'User ID', 'Username', 'Email', 'Roles', 'Missing Providers', 'Grace Period', 'Days Remaining' ); foreach ( $non_compliant as $user_data ) { + $roles_raw = isset( $user_data['roles'] ) && is_array( $user_data['roles'] ) ? $user_data['roles'] : array(); + $missing_providers_raw = isset( $user_data['missing_providers'] ) && is_array( $user_data['missing_providers'] ) ? $user_data['missing_providers'] : array(); + $roles = array_filter( $roles_raw, 'is_string' ); + $missing_providers = array_filter( $missing_providers_raw, 'is_string' ); + $user_id_val = isset( $user_data['user_id'] ) && is_int( $user_data['user_id'] ) ? (string) $user_data['user_id'] : ''; + $user_login_val = isset( $user_data['user_login'] ) && is_string( $user_data['user_login'] ) ? $user_data['user_login'] : ''; + $user_email_val = isset( $user_data['user_email'] ) && is_string( $user_data['user_email'] ) ? $user_data['user_email'] : ''; + $grace_remaining_val = isset( $user_data['grace_remaining'] ) && is_scalar( $user_data['grace_remaining'] ) ? (string) $user_data['grace_remaining'] : ''; + $in_grace_val = ! empty( $user_data['in_grace_period'] ) ? 'Yes' : 'No'; + $csv[] = array( - $user_data['user_id'], - $user_data['user_login'], - $user_data['user_email'], - implode( ', ', $user_data['roles'] ), - implode( ', ', $user_data['missing_providers'] ), - $user_data['in_grace_period'] ? 'Yes' : 'No', - $user_data['grace_remaining'], + $user_id_val, + $user_login_val, + $user_email_val, + implode( ', ', $roles ), + implode( ', ', $missing_providers ), + $in_grace_val, + $grace_remaining_val, ); } ob_start(); $handle = fopen( 'php://output', 'w' ); - foreach ( $csv as $row ) { - fputcsv( $handle, $row ); + if ( false !== $handle ) { + foreach ( $csv as $row ) { + fputcsv( $handle, $row ); + } + + fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Simple CSV export } - fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Simple CSV export + $output = ob_get_clean(); - return ob_get_clean(); + return false !== $output ? $output : ''; } /** @@ -263,8 +284,8 @@ class Two_Factor_Extended_Compliance_Report { * * @since 0.1.0 * - * @param string $to Recipient email address. - * @param array $args Optional arguments for report. + * @param string $to Recipient email address. + * @param array $args Optional arguments for report. * * @return bool True on success, false on failure. */ diff --git a/includes/class-enforcement.php b/includes/class-enforcement.php index 660c40d..cdb881a 100644 --- a/includes/class-enforcement.php +++ b/includes/class-enforcement.php @@ -63,7 +63,7 @@ class Two_Factor_Extended_Enforcement { * * @param WP_User|WP_Error|null $user User object or error. * - * @return WP_User|WP_Error User object or error if requirements not met. + * @return WP_User|WP_Error|null User object or error if requirements not met. */ public function check_user_requirements( $user ) { // Skip if not a user object. @@ -111,7 +111,7 @@ class Two_Factor_Extended_Enforcement { * * @param int $user_id User ID. * - * @return array Array of required provider class names. + * @return array Array of required provider class names. */ public function get_required_providers_for_user( int $user_id ): array { $required = array(); @@ -119,15 +119,25 @@ class Two_Factor_Extended_Enforcement { // Check if user is super admin (Multisite). if ( is_multisite() && Two_Factor_Extended_Role_Manager::is_super_admin( $user_id ) ) { $network_settings = get_site_option( TWO_FACTOR_EXTENDED_NETWORK_OPTION_SETTINGS, array() ); + if ( ! is_array( $network_settings ) ) { + $network_settings = array(); + } if ( isset( $network_settings['super_admin_requirements'] ) && is_array( $network_settings['super_admin_requirements'] ) ) { - $required = array_merge( $required, $network_settings['super_admin_requirements'] ); + foreach ( $network_settings['super_admin_requirements'] as $provider ) { + if ( is_string( $provider ) ) { + $required[] = $provider; + } + } } } // Check network-wide settings (Multisite). if ( is_multisite() ) { $network_settings = get_site_option( TWO_FACTOR_EXTENDED_NETWORK_OPTION_SETTINGS, array() ); + if ( ! is_array( $network_settings ) ) { + $network_settings = array(); + } if ( ! empty( $network_settings['enforce_network_wide'] ) ) { // Network enforcement is enabled. @@ -136,35 +146,46 @@ class Two_Factor_Extended_Enforcement { if ( isset( $network_settings['role_requirements'] ) && is_array( $network_settings['role_requirements'] ) ) { foreach ( $user_roles as $role ) { if ( isset( $network_settings['role_requirements'][ $role ] ) && is_array( $network_settings['role_requirements'][ $role ] ) ) { - $required = array_merge( $required, $network_settings['role_requirements'][ $role ] ); + foreach ( $network_settings['role_requirements'][ $role ] as $provider ) { + if ( is_string( $provider ) ) { + $required[] = $provider; + } + } } } } // If site override is not allowed, return only network requirements. if ( empty( $network_settings['allow_site_override'] ) ) { - return array_unique( $required ); + return array_values( array_unique( $required ) ); } } } // Get site-level requirements. - $settings = get_option( TWO_FACTOR_EXTENDED_OPTION_SETTINGS, array() ); + $settings = get_option( TWO_FACTOR_EXTENDED_OPTION_SETTINGS, array() ); + if ( ! is_array( $settings ) ) { + $settings = array(); + } $user_roles = Two_Factor_Extended_Role_Manager::get_user_roles( $user_id ); - if ( ! empty( $user_roles ) && isset( $settings['role_requirements'] ) ) { + if ( ! empty( $user_roles ) && isset( $settings['role_requirements'] ) && is_array( $settings['role_requirements'] ) ) { foreach ( $user_roles as $role ) { if ( isset( $settings['role_requirements'][ $role ] ) ) { $role_requirements = $settings['role_requirements'][ $role ]; if ( is_array( $role_requirements ) ) { - $required = array_merge( $required, $role_requirements ); + foreach ( $role_requirements as $provider ) { + if ( is_string( $provider ) ) { + $required[] = $provider; + } + } } } } } - return array_unique( $required ); + return array_values( array_unique( $required ) ); } /** @@ -172,8 +193,8 @@ class Two_Factor_Extended_Enforcement { * * @since 0.1.0 * - * @param int $user_id User ID. - * @param array $required_providers Required provider class names. + * @param int $user_id User ID. + * @param array $required_providers Required provider class names. * * @return bool True if user meets requirements, false otherwise. */ @@ -204,7 +225,10 @@ class Two_Factor_Extended_Enforcement { */ public function is_in_grace_period( int $user_id ): bool { $settings = get_option( TWO_FACTOR_EXTENDED_OPTION_SETTINGS, array() ); - $grace_days = isset( $settings['grace_period_days'] ) ? (int) $settings['grace_period_days'] : 0; + if ( ! is_array( $settings ) ) { + $settings = array(); + } + $grace_days = isset( $settings['grace_period_days'] ) && is_numeric( $settings['grace_period_days'] ) ? (int) $settings['grace_period_days'] : 0; // No grace period configured. if ( 0 === $grace_days ) { @@ -219,7 +243,7 @@ class Two_Factor_Extended_Enforcement { return true; } - $days_elapsed = ( time() - (int) $start_date ) / DAY_IN_SECONDS; + $days_elapsed = ( time() - ( is_numeric( $start_date ) ? (int) $start_date : 0 ) ) / DAY_IN_SECONDS; return $days_elapsed < $grace_days; } @@ -234,15 +258,18 @@ class Two_Factor_Extended_Enforcement { * @return int Remaining days in grace period. */ public function get_grace_period_remaining_days( int $user_id ): int { - $settings = get_option( TWO_FACTOR_EXTENDED_OPTION_SETTINGS, array() ); - $grace_days = isset( $settings['grace_period_days'] ) ? (int) $settings['grace_period_days'] : 0; + $settings = get_option( TWO_FACTOR_EXTENDED_OPTION_SETTINGS, array() ); + if ( ! is_array( $settings ) ) { + $settings = array(); + } + $grace_days = isset( $settings['grace_period_days'] ) && is_numeric( $settings['grace_period_days'] ) ? (int) $settings['grace_period_days'] : 0; $start_date = get_user_meta( $user_id, self::META_ENFORCEMENT_START, true ); if ( empty( $start_date ) || 0 === $grace_days ) { return 0; } - $days_elapsed = ( time() - (int) $start_date ) / DAY_IN_SECONDS; + $days_elapsed = ( time() - ( is_numeric( $start_date ) ? (int) $start_date : 0 ) ) / DAY_IN_SECONDS; $remaining = $grace_days - $days_elapsed; return max( 0, (int) ceil( $remaining ) ); @@ -292,7 +319,7 @@ class Two_Factor_Extended_Enforcement { $provider_classes Array of provider class names. * - * @return array Array of provider labels. + * @return array Array of provider labels. */ private function get_provider_labels( array $provider_classes ): array { $names = Two_Factor_Extended_Provider_Detector::get_provider_names(); diff --git a/includes/class-network-settings.php b/includes/class-network-settings.php index 274a6af..d8b7214 100644 --- a/includes/class-network-settings.php +++ b/includes/class-network-settings.php @@ -173,7 +173,7 @@ class Two_Factor_Extended_Network_Settings { type="number" id="network_grace_period" name="network_settings[grace_period_days]" - value="" + value="" min="0" max="365" class="small-text" @@ -204,11 +204,11 @@ class Two_Factor_Extended_Network_Settings { * * @since 0.1.0 * - * @param array $settings Current network settings. + * @param array $settings Current network settings. */ private function render_super_admin_requirements( array $settings ): void { - $providers = Two_Factor_Extended_Provider_Detector::get_provider_names(); - $super_admin_reqs = $settings['super_admin_requirements'] ?? array(); + $providers = Two_Factor_Extended_Provider_Detector::get_provider_names(); + $super_admin_reqs = isset( $settings['super_admin_requirements'] ) && is_array( $settings['super_admin_requirements'] ) ? $settings['super_admin_requirements'] : array(); if ( empty( $providers ) ) { ?> @@ -244,12 +244,12 @@ class Two_Factor_Extended_Network_Settings { * * @since 0.1.0 * - * @param array $settings Current network settings. + * @param array $settings Current network settings. */ private function render_network_role_requirements( array $settings ): void { $roles = Two_Factor_Extended_Role_Manager::get_all_roles(); $providers = Two_Factor_Extended_Provider_Detector::get_provider_names(); - $network_reqs = $settings['role_requirements'] ?? array(); + $network_reqs = isset( $settings['role_requirements'] ) && is_array( $settings['role_requirements'] ) ? $settings['role_requirements'] : array(); if ( empty( $providers ) ) { ?> @@ -274,7 +274,7 @@ class Two_Factor_Extended_Network_Settings { $name ) : ?> @@ -308,7 +308,7 @@ class Two_Factor_Extended_Network_Settings { } // Verify nonce. - $nonce = isset( $_POST['two_factor_extended_network_nonce'] ) + $nonce = isset( $_POST['two_factor_extended_network_nonce'] ) && is_string( $_POST['two_factor_extended_network_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['two_factor_extended_network_nonce'] ) ) : ''; @@ -344,7 +344,7 @@ class Two_Factor_Extended_Network_Settings { * * @since 0.1.0 * - * @return array Network settings. + * @return array Network settings. */ public function get_network_settings(): array { $defaults = array( @@ -357,6 +357,10 @@ class Two_Factor_Extended_Network_Settings { $settings = get_site_option( TWO_FACTOR_EXTENDED_NETWORK_OPTION_SETTINGS, array() ); + if ( ! is_array( $settings ) ) { + $settings = array(); + } + return wp_parse_args( $settings, $defaults ); } @@ -365,9 +369,9 @@ class Two_Factor_Extended_Network_Settings { * * @since 0.1.0 * - * @param array $input Raw input. + * @param array $input Raw input. * - * @return array Sanitized settings. + * @return array Sanitized settings. */ private function sanitize_network_settings( array $input ): array { $sanitized = array(); @@ -379,14 +383,19 @@ class Two_Factor_Extended_Network_Settings { $sanitized['allow_site_override'] = ! empty( $input['allow_site_override'] ); // Grace period days. - if ( isset( $input['grace_period_days'] ) ) { + if ( isset( $input['grace_period_days'] ) && is_numeric( $input['grace_period_days'] ) ) { $days = (int) $input['grace_period_days']; $sanitized['grace_period_days'] = max( 0, min( 365, $days ) ); } // Super admin requirements. if ( isset( $input['super_admin_requirements'] ) && is_array( $input['super_admin_requirements'] ) ) { - $sanitized['super_admin_requirements'] = array_map( 'sanitize_text_field', $input['super_admin_requirements'] ); + $sanitized['super_admin_requirements'] = array_map( + function ( mixed $item ): string { + return sanitize_text_field( is_string( $item ) ? $item : '' ); + }, + $input['super_admin_requirements'] + ); } // Role requirements. @@ -394,10 +403,15 @@ class Two_Factor_Extended_Network_Settings { $sanitized['role_requirements'] = array(); foreach ( $input['role_requirements'] as $role => $providers ) { - $role_slug = sanitize_key( $role ); + $role_slug = sanitize_key( is_string( $role ) ? $role : '' ); if ( Two_Factor_Extended_Role_Manager::role_exists( $role_slug ) && is_array( $providers ) ) { - $sanitized['role_requirements'][ $role_slug ] = array_map( 'sanitize_text_field', $providers ); + $sanitized['role_requirements'][ $role_slug ] = array_map( + function ( mixed $item ): string { + return sanitize_text_field( is_string( $item ) ? $item : '' ); + }, + $providers + ); } } } diff --git a/includes/class-provider-detector.php b/includes/class-provider-detector.php index 6b80dfc..259abe5 100644 --- a/includes/class-provider-detector.php +++ b/includes/class-provider-detector.php @@ -25,7 +25,7 @@ class Two_Factor_Extended_Provider_Detector { * * @since 0.1.0 * - * @return array Array of provider class names. + * @return array Array of provider instances keyed by class name. */ public static function get_all_providers(): array { if ( ! class_exists( 'Two_Factor_Core' ) ) { @@ -40,14 +40,14 @@ class Two_Factor_Extended_Provider_Detector { * * @since 0.1.0 * - * @return array Array of provider class => display name. + * @return array Array of provider class => display name. */ public static function get_provider_names(): array { $providers = self::get_all_providers(); $names = array(); foreach ( $providers as $class_name => $provider ) { - if ( is_object( $provider ) && method_exists( $provider, 'get_label' ) ) { + if ( method_exists( $provider, 'get_label' ) ) { $names[ $class_name ] = $provider->get_label(); } else { $names[ $class_name ] = $class_name; @@ -64,7 +64,7 @@ class Two_Factor_Extended_Provider_Detector { * * @param int $user_id User ID. * - * @return array Array of enabled provider class names. + * @return array Array of enabled provider instances keyed by class name. */ public static function get_user_enabled_providers( int $user_id ): array { if ( ! class_exists( 'Two_Factor_Core' ) ) { @@ -90,7 +90,13 @@ class Two_Factor_Extended_Provider_Detector { $primary = Two_Factor_Core::get_primary_provider_for_user( $user_id ); - return $primary ? get_class( $primary ) : null; + if ( ! $primary ) { + return null; + } + + $class = get_class( $primary ); + + return false !== $class ? $class : null; } /** diff --git a/includes/class-provider-filter.php b/includes/class-provider-filter.php index b8bda8c..3d909d4 100644 --- a/includes/class-provider-filter.php +++ b/includes/class-provider-filter.php @@ -48,9 +48,9 @@ class Two_Factor_Extended_Provider_Filter { * * @since 0.1.0 * - * @param array $providers Available providers. + * @param array $providers Available providers. * - * @return array Filtered providers. + * @return array Filtered providers. */ public function filter_providers_by_role( array $providers ): array { // IMPORTANT: Only filter providers on user profile pages, NOT on admin settings pages. @@ -104,13 +104,16 @@ class Two_Factor_Extended_Provider_Filter { * * @param int $user_id User ID. * - * @return array|null Array of visible provider class names, or null if no rules. + * @return array|null Array of visible provider class names, or null if no rules. */ public function get_visible_providers_for_user( int $user_id ): ?array { - $settings = get_option( TWO_FACTOR_EXTENDED_OPTION_SETTINGS, array() ); + $settings = get_option( TWO_FACTOR_EXTENDED_OPTION_SETTINGS, array() ); + if ( ! is_array( $settings ) ) { + $settings = array(); + } $user_roles = Two_Factor_Extended_Role_Manager::get_user_roles( $user_id ); - if ( empty( $user_roles ) || ! isset( $settings['provider_visibility'] ) ) { + if ( empty( $user_roles ) || ! isset( $settings['provider_visibility'] ) || ! is_array( $settings['provider_visibility'] ) ) { return null; } @@ -122,7 +125,11 @@ class Two_Factor_Extended_Provider_Filter { $role_visible = $settings['provider_visibility'][ $role ]; if ( is_array( $role_visible ) ) { - $visible = array_merge( $visible, $role_visible ); + foreach ( $role_visible as $provider ) { + if ( is_string( $provider ) ) { + $visible[] = $provider; + } + } } } } @@ -132,7 +139,7 @@ class Two_Factor_Extended_Provider_Filter { return null; } - return array_unique( $visible ); + return array_values( array_unique( $visible ) ); } /** @@ -166,9 +173,9 @@ class Two_Factor_Extended_Provider_Filter { * * @since 0.1.0 * - * @return int|null User ID or null. + * @return int User ID. */ - private function get_profile_user_id(): ?int { + private function get_profile_user_id(): int { // Check if editing another user's profile (e.g., wp-admin/user-edit.php?user_id=123). // Use filter_input() for safe access to GET parameter. $user_id = filter_input( INPUT_GET, 'user_id', FILTER_VALIDATE_INT ); @@ -196,9 +203,13 @@ class Two_Factor_Extended_Provider_Filter { return; } - $all_providers = Two_Factor_Extended_Provider_Detector::get_all_providers(); - $required_providers = two_factor_extended()->get_enforcement()->get_required_providers_for_user( $user->ID ); - $provider_names = Two_Factor_Extended_Provider_Detector::get_provider_names(); + $all_providers = Two_Factor_Extended_Provider_Detector::get_all_providers(); + $enforcement = two_factor_extended()->get_enforcement(); + $provider_names = Two_Factor_Extended_Provider_Detector::get_provider_names(); + + $required_providers = null !== $enforcement + ? $enforcement->get_required_providers_for_user( $user->ID ) + : array(); // Calculate hidden providers. $hidden_providers = array(); @@ -242,15 +253,15 @@ class Two_Factor_Extended_Provider_Filter { * * @since 0.1.0 * - * @param array $roles Array of role slugs. + * @param array $roles Array of role slugs. * - * @return array Array of visible provider class names. + * @return array Array of visible provider class names. */ public function get_inherited_visibility( array $roles ): array { $settings = get_option( TWO_FACTOR_EXTENDED_OPTION_SETTINGS, array() ); $visible = array(); - if ( ! isset( $settings['provider_visibility'] ) ) { + if ( ! is_array( $settings ) || ! isset( $settings['provider_visibility'] ) || ! is_array( $settings['provider_visibility'] ) ) { return array(); } @@ -259,12 +270,16 @@ class Two_Factor_Extended_Provider_Filter { $role_visible = $settings['provider_visibility'][ $role ]; if ( is_array( $role_visible ) ) { - $visible = array_merge( $visible, $role_visible ); + foreach ( $role_visible as $provider ) { + if ( is_string( $provider ) ) { + $visible[] = $provider; + } + } } } } - return array_unique( $visible ); + return array_values( array_unique( $visible ) ); } /** diff --git a/includes/class-rest-api.php b/includes/class-rest-api.php index 6c27f11..62f4ba2 100644 --- a/includes/class-rest-api.php +++ b/includes/class-rest-api.php @@ -185,7 +185,18 @@ class Two_Factor_Extended_REST_API { $role = $request->get_param( 'role' ); $compliance = two_factor_extended()->get_compliance_report(); - $stats = $compliance->get_compliance_stats( array( 'role' => $role ) ); + + if ( null === $compliance ) { + return new WP_REST_Response( + array( + 'success' => false, + 'message' => __( 'Compliance report module is not available.', 'two-factor-extended' ), + ), + 500 + ); + } + + $stats = $compliance->get_compliance_stats( array( 'role' => is_string( $role ) ? $role : '' ) ); return new WP_REST_Response( array( @@ -217,33 +228,54 @@ class Two_Factor_Extended_REST_API { public function get_users( WP_REST_Request $request ): WP_REST_Response { $role = $request->get_param( 'role' ); $non_compliant_only = $request->get_param( 'non_compliant_only' ); + $role_string = is_string( $role ) ? $role : ''; $compliance = two_factor_extended()->get_compliance_report(); + if ( null === $compliance ) { + return new WP_REST_Response( + array( + 'success' => false, + 'message' => __( 'Compliance report module is not available.', 'two-factor-extended' ), + ), + 500 + ); + } + if ( $non_compliant_only ) { - $users = $compliance->get_non_compliant_users( array( 'role' => $role ) ); + $users = $compliance->get_non_compliant_users( array( 'role' => $role_string ) ); } else { // Get all users with compliance status. $user_args = array( 'fields' => 'all' ); - if ( ! empty( $role ) ) { - $user_args['role'] = $role; + if ( ! empty( $role_string ) ) { + $user_args['role'] = $role_string; } - $all_users = get_users( $user_args ); + $all_users = get_users( $user_args ); $enforcement = two_factor_extended()->get_enforcement(); - $users = array(); + $users = array(); + + if ( null === $enforcement ) { + return new WP_REST_Response( + array( + 'success' => false, + 'message' => __( 'Enforcement module is not available.', 'two-factor-extended' ), + ), + 500 + ); + } foreach ( $all_users as $user ) { $required = $enforcement->get_required_providers_for_user( $user->ID ); $users[] = array( - 'user_id' => $user->ID, - 'user_login' => $user->user_login, - 'user_email' => $user->user_email, - 'roles' => Two_Factor_Extended_Role_Manager::get_user_roles( $user->ID ), - 'compliant' => empty( $required ) || $enforcement->user_meets_requirements( $user->ID, $required ), - 'in_grace' => $enforcement->is_in_grace_period( $user->ID ), + 'user_id' => $user->ID, + 'user_login' => $user->user_login, + 'user_email' => $user->user_email, + 'roles' => Two_Factor_Extended_Role_Manager::get_user_roles( $user->ID ), + 'compliant' => empty( $required ) || $enforcement->user_meets_requirements( $user->ID, $required ), + 'in_grace' => $enforcement->is_in_grace_period( $user->ID ), 'grace_remaining' => $enforcement->get_grace_period_remaining_days( $user->ID ), ); } @@ -272,19 +304,45 @@ class Two_Factor_Extended_REST_API { $user_ids = $request->get_param( 'user_ids' ); $reset_grace = $request->get_param( 'reset_grace' ); + if ( ! is_array( $user_ids ) ) { + return new WP_REST_Response( + array( + 'success' => false, + 'message' => __( 'Invalid user_ids parameter.', 'two-factor-extended' ), + ), + 400 + ); + } + $enforcement = two_factor_extended()->get_enforcement(); - $processed = 0; - $errors = array(); + + if ( null === $enforcement ) { + return new WP_REST_Response( + array( + 'success' => false, + 'message' => __( 'Enforcement module is not available.', 'two-factor-extended' ), + ), + 500 + ); + } + + $processed = 0; + $errors = array(); foreach ( $user_ids as $user_id ) { - $user = get_userdata( $user_id ); + if ( ! is_numeric( $user_id ) ) { + $errors[] = 'Invalid user_id value provided.'; + continue; + } + $user_id_int = (int) $user_id; + $user = get_userdata( $user_id_int ); if ( ! $user ) { - $errors[] = sprintf( 'User not found: %d', $user_id ); + $errors[] = sprintf( 'User not found: %d', $user_id_int ); continue; } - $required = $enforcement->get_required_providers_for_user( $user_id ); + $required = $enforcement->get_required_providers_for_user( $user_id_int ); if ( empty( $required ) ) { $errors[] = sprintf( 'No 2FA requirements for user: %s', $user->user_login ); @@ -292,19 +350,23 @@ class Two_Factor_Extended_REST_API { } // Set enforcement start date. - update_user_meta( $user_id, Two_Factor_Extended_Enforcement::META_ENFORCEMENT_START, time() ); + update_user_meta( $user_id_int, Two_Factor_Extended_Enforcement::META_ENFORCEMENT_START, time() ); if ( $reset_grace ) { - delete_user_meta( $user_id, Two_Factor_Extended_Enforcement::META_GRACE_NOTIFIED ); + delete_user_meta( $user_id_int, Two_Factor_Extended_Enforcement::META_GRACE_NOTIFIED ); } // Log the action. - two_factor_extended()->get_audit_log()->log_event( - 'api_enforce', - sprintf( 'REST API: Enforced 2FA for user %s', $user->user_login ), - $user_id, - array( 'required_providers' => $required ) - ); + $audit_log = two_factor_extended()->get_audit_log(); + + if ( null !== $audit_log ) { + $audit_log->log_event( + 'api_enforce', + sprintf( 'REST API: Enforced 2FA for user %s', $user->user_login ), + $user_id_int, + array( 'required_providers' => $required ) + ); + } $processed++; } @@ -333,26 +395,45 @@ class Two_Factor_Extended_REST_API { $processed = 0; $errors = array(); + if ( ! is_array( $user_ids ) ) { + return new WP_REST_Response( + array( + 'success' => false, + 'message' => __( 'Invalid user_ids parameter.', 'two-factor-extended' ), + ), + 400 + ); + } + foreach ( $user_ids as $user_id ) { - $user = get_userdata( $user_id ); + if ( ! is_numeric( $user_id ) ) { + $errors[] = 'Invalid user_id value provided.'; + continue; + } + $user_id_int = (int) $user_id; + $user = get_userdata( $user_id_int ); if ( ! $user ) { - $errors[] = sprintf( 'User not found: %d', $user_id ); + $errors[] = sprintf( 'User not found: %d', $user_id_int ); continue; } // Reset enforcement start date. - update_user_meta( $user_id, Two_Factor_Extended_Enforcement::META_ENFORCEMENT_START, time() ); + update_user_meta( $user_id_int, Two_Factor_Extended_Enforcement::META_ENFORCEMENT_START, time() ); // Clear grace period notified flag. - delete_user_meta( $user_id, Two_Factor_Extended_Enforcement::META_GRACE_NOTIFIED ); + delete_user_meta( $user_id_int, Two_Factor_Extended_Enforcement::META_GRACE_NOTIFIED ); // Log the action. - two_factor_extended()->get_audit_log()->log_event( - 'api_reset_grace', - sprintf( 'REST API: Reset grace period for user %s', $user->user_login ), - $user_id - ); + $audit_log = two_factor_extended()->get_audit_log(); + + if ( null !== $audit_log ) { + $audit_log->log_event( + 'api_reset_grace', + sprintf( 'REST API: Reset grace period for user %s', $user->user_login ), + $user_id_int + ); + } $processed++; } @@ -377,13 +458,24 @@ class Two_Factor_Extended_REST_API { * @return WP_REST_Response Response object. */ public function get_report( WP_REST_Request $request ): WP_REST_Response { - $role = $request->get_param( 'role' ); - $format = $request->get_param( 'format' ); + $role = $request->get_param( 'role' ); + $format = $request->get_param( 'format' ); + $role_string = is_string( $role ) ? $role : ''; $compliance = two_factor_extended()->get_compliance_report(); + if ( null === $compliance ) { + return new WP_REST_Response( + array( + 'success' => false, + 'message' => __( 'Compliance report module is not available.', 'two-factor-extended' ), + ), + 500 + ); + } + if ( 'csv' === $format ) { - $csv = $compliance->export_to_csv( array( 'role' => $role ) ); + $csv = $compliance->export_to_csv( array( 'role' => $role_string ) ); return new WP_REST_Response( array( @@ -399,14 +491,14 @@ class Two_Factor_Extended_REST_API { } // JSON format. - $non_compliant = $compliance->get_non_compliant_users( array( 'role' => $role ) ); - $stats = $compliance->get_compliance_stats( array( 'role' => $role ) ); + $non_compliant = $compliance->get_non_compliant_users( array( 'role' => $role_string ) ); + $stats = $compliance->get_compliance_stats( array( 'role' => $role_string ) ); return new WP_REST_Response( array( - 'success' => true, - 'statistics' => $stats, - 'non_compliant' => $non_compliant, + 'success' => true, + 'statistics' => $stats, + 'non_compliant' => $non_compliant, ), 200 ); diff --git a/includes/class-role-manager.php b/includes/class-role-manager.php index 28c21c3..5784e81 100644 --- a/includes/class-role-manager.php +++ b/includes/class-role-manager.php @@ -25,7 +25,7 @@ class Two_Factor_Extended_Role_Manager { * * @since 0.1.0 * - * @return array Array of role slugs and names. + * @return array Array of role slugs and names. */ public static function get_all_roles(): array { if ( ! function_exists( 'get_editable_roles' ) ) { @@ -64,12 +64,12 @@ class Two_Factor_Extended_Role_Manager { * * @param int $user_id User ID. * - * @return array Array of role slugs. + * @return array Array of role slugs. */ public static function get_user_roles( int $user_id ): array { $user = get_userdata( $user_id ); - if ( ! $user || ! isset( $user->roles ) ) { + if ( ! $user ) { return array(); } @@ -130,7 +130,7 @@ class Two_Factor_Extended_Role_Manager { * @param int $user_id User ID. * @param int $blog_id Blog ID (optional, defaults to current blog). * - * @return array Array of role slugs for the specified blog. + * @return array Array of role slugs for the specified blog. */ public static function get_user_roles_for_blog( int $user_id, int $blog_id = 0 ): array { if ( ! is_multisite() ) { @@ -148,8 +148,9 @@ class Two_Factor_Extended_Role_Manager { } // Get roles for specific blog. - $roles_key = $GLOBALS['wpdb']->get_blog_prefix( $blog_id ) . 'capabilities'; - $roles = isset( $user->{$roles_key} ) ? array_keys( $user->{$roles_key} ) : array(); + global $wpdb; + $roles_key = $wpdb->get_blog_prefix( $blog_id ) . 'capabilities'; + $roles = isset( $user->{$roles_key} ) && is_array( $user->{$roles_key} ) ? array_keys( $user->{$roles_key} ) : array(); return $roles; } diff --git a/includes/class-settings.php b/includes/class-settings.php index 6d84b8e..a1580a3 100644 --- a/includes/class-settings.php +++ b/includes/class-settings.php @@ -28,21 +28,6 @@ class Two_Factor_Extended_Settings { */ const PAGE_SLUG = 'two-factor-extended'; - /** - * Settings sections. - * - * @since 0.1.0 - * @var array - */ - private array $sections = array(); - - /** - * Settings fields. - * - * @since 0.1.0 - * @var array - */ - private array $fields = array(); /** * Constructor. @@ -59,8 +44,9 @@ class Two_Factor_Extended_Settings { * @since 0.1.0 */ private function init_hooks(): void { - add_action( 'admin_menu', array( $this, 'register_settings_page' ) ); - add_action( 'network_admin_menu', array( $this, 'register_network_settings_page' ) ); + // Priority 20 ensures our page registers after Two Factor (default priority 10). + add_action( 'admin_menu', array( $this, 'register_settings_page' ), 20 ); + add_action( 'network_admin_menu', array( $this, 'register_network_settings_page' ), 20 ); add_action( 'admin_init', array( $this, 'register_settings' ) ); add_action( 'admin_init', array( $this, 'handle_import_export' ) ); add_action( 'admin_init', array( $this, 'handle_reset_settings' ) ); @@ -186,13 +172,6 @@ class Two_Factor_Extended_Settings { array( $this, 'render_data_section' ), self::PAGE_SLUG ); - - $this->sections = array( - 'two_factor_extended_general', - 'two_factor_extended_role_requirements', - 'two_factor_extended_provider_visibility', - 'two_factor_extended_data', - ); } /** @@ -380,31 +359,31 @@ class Two_Factor_Extended_Settings { * * @since 0.1.0 * - * @param array $args Field arguments. + * @param array $args Field arguments. */ public function render_number_field( array $args ): void { - $option_name = $args['option_name'] ?? ''; - $id = $args['id'] ?? ''; - $field_key = $args['field_key'] ?? ''; - $description = $args['description'] ?? ''; - $min = $args['min'] ?? 0; - $max = $args['max'] ?? 999; - $default = $args['default'] ?? 0; + $option_name = isset( $args['option_name'] ) && is_string( $args['option_name'] ) ? $args['option_name'] : ''; + $id = isset( $args['id'] ) && is_string( $args['id'] ) ? $args['id'] : ''; + $field_key = isset( $args['field_key'] ) && is_string( $args['field_key'] ) ? $args['field_key'] : ''; + $description = isset( $args['description'] ) && is_string( $args['description'] ) ? $args['description'] : ''; + $min = isset( $args['min'] ) && is_numeric( $args['min'] ) ? (int) $args['min'] : 0; + $max = isset( $args['max'] ) && is_numeric( $args['max'] ) ? (int) $args['max'] : 999; + $default = isset( $args['default'] ) && is_numeric( $args['default'] ) ? (int) $args['default'] : 0; if ( empty( $option_name ) || empty( $id ) ) { return; } $settings = get_option( $option_name, array() ); - $value = $settings[ $field_key ] ?? $default; + $value = is_array( $settings ) && isset( $settings[ $field_key ] ) && is_numeric( $settings[ $field_key ] ) ? (int) $settings[ $field_key ] : $default; ?> $args Field arguments. */ public function render_role_requirements_field( array $args ): void { // Clear any object cache to ensure fresh data. wp_cache_delete( TWO_FACTOR_EXTENDED_OPTION_SETTINGS, 'options' ); wp_cache_flush(); - $settings = get_option( TWO_FACTOR_EXTENDED_OPTION_SETTINGS, array() ); - $roles = Two_Factor_Extended_Role_Manager::get_all_roles(); + $raw_settings = get_option( TWO_FACTOR_EXTENDED_OPTION_SETTINGS, array() ); + $settings = is_array( $raw_settings ) ? $raw_settings : array(); + $roles = Two_Factor_Extended_Role_Manager::get_all_roles(); - // Force fresh provider detection directly from Two Factor Core + // Force fresh provider detection directly from Two Factor Core. $providers = array(); if ( class_exists( 'Two_Factor_Core' ) ) { $all_providers = Two_Factor_Core::get_providers(); @@ -456,7 +436,7 @@ class Two_Factor_Extended_Settings { return; } - $requirements = $settings['role_requirements'] ?? array(); + $requirements = isset( $settings['role_requirements'] ) && is_array( $settings['role_requirements'] ) ? $settings['role_requirements'] : array(); ?> @@ -473,7 +453,8 @@ class Two_Factor_Extended_Settings { - - - + + + @@ -1214,7 +1259,21 @@ class Two_Factor_Extended_Settings {

- +

@@ -1233,21 +1292,38 @@ class Two_Factor_Extended_Settings { - - - + + + - + @@ -1282,6 +1358,10 @@ class Two_Factor_Extended_Settings { $network_settings = get_site_option( TWO_FACTOR_EXTENDED_NETWORK_OPTION_SETTINGS, array() ); + if ( ! is_array( $network_settings ) ) { + return false; + } + return ! empty( $network_settings['enforce_network_wide'] ) && empty( $network_settings['allow_site_override'] ); } @@ -1298,6 +1378,10 @@ class Two_Factor_Extended_Settings { $audit_log = two_factor_extended()->get_audit_log(); + if ( null === $audit_log ) { + wp_die( esc_html__( 'Audit log module is not available.', 'two-factor-extended' ) ); + } + // Handle export. if ( isset( $_GET['action'] ) && 'export' === $_GET['action'] ) { check_admin_referer( 'two_factor_extended_export_audit' ); @@ -1364,14 +1448,19 @@ class Two_Factor_Extended_Settings { - - - + + + - + @@ -1399,6 +1488,10 @@ class Two_Factor_Extended_Settings { $compliance = two_factor_extended()->get_compliance_report(); + if ( null === $compliance ) { + wp_die( esc_html__( 'Compliance report module is not available.', 'two-factor-extended' ) ); + } + // Handle export. if ( isset( $_GET['action'] ) && 'export' === $_GET['action'] ) { check_admin_referer( 'two_factor_extended_export_compliance' ); @@ -1415,7 +1508,7 @@ class Two_Factor_Extended_Settings { if ( isset( $_POST['action'] ) && 'email' === $_POST['action'] ) { check_admin_referer( 'two_factor_extended_email_compliance' ); - $email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : ''; + $email = isset( $_POST['email'] ) && is_string( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : ''; if ( ! empty( $email ) ) { $sent = $compliance->email_report( $email ); @@ -1440,23 +1533,23 @@ class Two_Factor_Extended_Settings {
$provider_name ) : $checked = in_array( $provider_class, $role_requirements, true ); @@ -504,17 +485,18 @@ class Two_Factor_Extended_Settings { * * @since 0.1.0 * - * @param array $args Field arguments. + * @param array $args Field arguments. */ public function render_provider_visibility_field( array $args ): void { // Clear any object cache to ensure fresh data. wp_cache_delete( TWO_FACTOR_EXTENDED_OPTION_SETTINGS, 'options' ); wp_cache_flush(); - $settings = get_option( TWO_FACTOR_EXTENDED_OPTION_SETTINGS, array() ); - $roles = Two_Factor_Extended_Role_Manager::get_all_roles(); + $raw_settings = get_option( TWO_FACTOR_EXTENDED_OPTION_SETTINGS, array() ); + $settings = is_array( $raw_settings ) ? $raw_settings : array(); + $roles = Two_Factor_Extended_Role_Manager::get_all_roles(); - // Force fresh provider detection directly from Two Factor Core + // Force fresh provider detection directly from Two Factor Core. $providers = array(); if ( class_exists( 'Two_Factor_Core' ) ) { $all_providers = Two_Factor_Core::get_providers(); @@ -537,8 +519,8 @@ class Two_Factor_Extended_Settings { return; } - $visibility = $settings['provider_visibility'] ?? array(); - $requirements = $settings['role_requirements'] ?? array(); + $visibility = isset( $settings['provider_visibility'] ) && is_array( $settings['provider_visibility'] ) ? $settings['provider_visibility'] : array(); + $requirements = isset( $settings['role_requirements'] ) && is_array( $settings['role_requirements'] ) ? $settings['role_requirements'] : array(); ?> @@ -555,11 +537,13 @@ class Two_Factor_Extended_Settings { + - - - + + + - + @@ -1143,6 +1183,11 @@ class Two_Factor_Extended_Settings { private function render_compliance_tab(): void { $compliance = two_factor_extended()->get_compliance_report(); + if ( null === $compliance ) { + echo '

' . esc_html__( 'Compliance report module is not available.', 'two-factor-extended' ) . '

'; + return; + } + // Handle export. if ( isset( $_GET['action'] ) && 'export' === $_GET['action'] ) { check_admin_referer( 'two_factor_extended_export_compliance' ); @@ -1164,23 +1209,23 @@ class Two_Factor_Extended_Settings {
$provider_name ) : - $checked = in_array( $provider_class, $role_visibility, true ); + $checked = in_array( $provider_class, $role_visibility, true ); $is_required = in_array( $provider_class, $role_required, true ); $disabled = $is_required ? 'disabled' : ''; $checked = $checked || $is_required; @@ -606,13 +590,13 @@ class Two_Factor_Extended_Settings { * * @since 0.1.0 * - * @param array $args Field arguments. + * @param array $args Field arguments. */ public function render_checkbox_field( array $args ): void { - $option_name = $args['option_name'] ?? ''; - $id = $args['id'] ?? ''; - $label = $args['label'] ?? ''; - $description = $args['description'] ?? ''; + $option_name = isset( $args['option_name'] ) && is_string( $args['option_name'] ) ? $args['option_name'] : ''; + $id = isset( $args['id'] ) && is_string( $args['id'] ) ? $args['id'] : ''; + $label = isset( $args['label'] ) && is_string( $args['label'] ) ? $args['label'] : ''; + $description = isset( $args['description'] ) && is_string( $args['description'] ) ? $args['description'] : ''; if ( empty( $option_name ) || empty( $id ) ) { return; @@ -650,7 +634,8 @@ class Two_Factor_Extended_Settings { return; } - $network_settings = get_site_option( TWO_FACTOR_EXTENDED_NETWORK_OPTION_SETTINGS, array() ); + $network_settings_raw = get_site_option( TWO_FACTOR_EXTENDED_NETWORK_OPTION_SETTINGS, array() ); + $network_settings = is_array( $network_settings_raw ) ? $network_settings_raw : array(); if ( empty( $network_settings['enforce_network_wide'] ) ) { return; @@ -688,7 +673,7 @@ class Two_Factor_Extended_Settings { } // Inline CSS for tabs. - $css = " + $css = ' .tab-content { margin-top: 20px; } @@ -696,7 +681,7 @@ class Two_Factor_Extended_Settings { max-width: none; margin-bottom: 20px; } - "; + '; wp_add_inline_style( 'wp-admin', $css ); // Inline script for import validation. @@ -775,6 +760,8 @@ class Two_Factor_Extended_Settings { * Export settings to JSON file. * * @since 0.1.0 + * + * @throws Exception If JSON encoding fails. */ private function export_settings(): void { try { @@ -794,11 +781,14 @@ class Two_Factor_Extended_Settings { } // Log the export. - two_factor_extended()->get_audit_log()->log_event( - 'settings_exported', - 'Plugin settings exported', - 0 - ); + $audit_log = two_factor_extended()->get_audit_log(); + if ( null !== $audit_log ) { + $audit_log->log_event( + 'settings_exported', + 'Plugin settings exported', + 0 + ); + } // Set headers for download. header( 'Content-Type: application/json; charset=utf-8' ); @@ -810,12 +800,15 @@ class Two_Factor_Extended_Settings { exit; } catch ( Exception $e ) { // Log error without exposing details to user. - two_factor_extended()->get_audit_log()->log_event( - 'settings_export_failed', - 'Failed to export settings', - 0, - array( 'error' => 'Export failed' ) - ); + $audit_log = two_factor_extended()->get_audit_log(); + if ( null !== $audit_log ) { + $audit_log->log_event( + 'settings_export_failed', + 'Failed to export settings', + 0, + array( 'error' => 'Export failed' ) + ); + } wp_die( esc_html__( 'Unable to export settings. Please try again later.', 'two-factor-extended' ), @@ -848,7 +841,16 @@ class Two_Factor_Extended_Settings { // phpcs:disable WordPress.Security.ValidatedSanitizedInput // Check file upload. - if ( ! isset( $_FILES['two_factor_extended_import_file'] ) || UPLOAD_ERR_OK !== $_FILES['two_factor_extended_import_file']['error'] ) { + $upload_file = isset( $_FILES['two_factor_extended_import_file'] ) && is_array( $_FILES['two_factor_extended_import_file'] ) + ? $_FILES['two_factor_extended_import_file'] + : array(); + + $upload_error = isset( $upload_file['error'] ) && is_int( $upload_file['error'] ) ? $upload_file['error'] : UPLOAD_ERR_NO_FILE; + $upload_size = isset( $upload_file['size'] ) && is_int( $upload_file['size'] ) ? $upload_file['size'] : 0; + $upload_name = isset( $upload_file['name'] ) && is_string( $upload_file['name'] ) ? $upload_file['name'] : ''; + $upload_tmp_name = isset( $upload_file['tmp_name'] ) && is_string( $upload_file['tmp_name'] ) ? $upload_file['tmp_name'] : ''; + + if ( empty( $upload_file ) || UPLOAD_ERR_OK !== $upload_error ) { add_settings_error( 'two_factor_extended_import', 'import_error', @@ -860,7 +862,7 @@ class Two_Factor_Extended_Settings { // Check file size (1MB limit). $max_size = 1024 * 1024; // 1MB. - if ( $_FILES['two_factor_extended_import_file']['size'] > $max_size ) { + if ( $upload_size > $max_size ) { add_settings_error( 'two_factor_extended_import', 'import_error', @@ -871,7 +873,7 @@ class Two_Factor_Extended_Settings { } // Check file extension. - $file_name = sanitize_file_name( $_FILES['two_factor_extended_import_file']['name'] ); + $file_name = sanitize_file_name( $upload_name ); if ( ! str_ends_with( strtolower( $file_name ), '.json' ) ) { add_settings_error( 'two_factor_extended_import', @@ -883,7 +885,7 @@ class Two_Factor_Extended_Settings { } // Read file contents. - $file_content = file_get_contents( $_FILES['two_factor_extended_import_file']['tmp_name'] ); + $file_content = file_get_contents( $upload_tmp_name ); // phpcs:enable WordPress.Security.NonceVerification.Missing // phpcs:enable WordPress.Security.ValidatedSanitizedInput @@ -922,19 +924,29 @@ class Two_Factor_Extended_Settings { return; } - // Sanitize and update settings. - $sanitized_settings = $this->sanitize_settings( $import_data['settings'] ); + // Sanitize and update settings. Ensure keys are strings (from JSON decode). + $settings_to_import = array(); + foreach ( $import_data['settings'] as $key => $value ) { + if ( is_string( $key ) ) { + $settings_to_import[ $key ] = $value; + } + } + $sanitized_settings = $this->sanitize_settings( $settings_to_import ); update_option( TWO_FACTOR_EXTENDED_OPTION_SETTINGS, $sanitized_settings ); // Log the import. - two_factor_extended()->get_audit_log()->log_event( - 'settings_imported', - 'Plugin settings imported', - 0, - array( - 'imported_version' => $import_data['version'] ?? 'unknown', - ) - ); + $audit_log = two_factor_extended()->get_audit_log(); + + if ( null !== $audit_log ) { + $audit_log->log_event( + 'settings_imported', + 'Plugin settings imported', + 0, + array( + 'imported_version' => $import_data['version'] ?? 'unknown', + ) + ); + } add_settings_error( 'two_factor_extended_import', @@ -957,7 +969,8 @@ class Two_Factor_Extended_Settings { // Get current tab. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Tab parameter for UI navigation - $current_tab = isset( $_GET['tab'] ) ? sanitize_key( $_GET['tab'] ) : 'settings'; + $tab_param = isset( $_GET['tab'] ) && is_string( $_GET['tab'] ) ? $_GET['tab'] : ''; + $current_tab = $tab_param ? sanitize_key( $tab_param ) : 'settings'; // Define tabs. $tabs = array( @@ -1012,7 +1025,8 @@ class Two_Factor_Extended_Settings { // Check if site settings are disabled by network. $settings_disabled = $this->are_site_settings_disabled(); - if ( $settings_disabled ) : ?> + if ( $settings_disabled ) : + ?>

@@ -1041,6 +1055,11 @@ class Two_Factor_Extended_Settings { private function render_audit_log_tab(): void { $audit_log = two_factor_extended()->get_audit_log(); + if ( null === $audit_log ) { + echo '

' . esc_html__( 'Audit log module is not available.', 'two-factor-extended' ) . '

'; + return; + } + // Handle export. if ( isset( $_GET['action'] ) && 'export' === $_GET['action'] ) { check_admin_referer( 'two_factor_extended_export_audit' ); @@ -1081,7 +1100,21 @@ class Two_Factor_Extended_Settings {

- +

@@ -1099,21 +1132,28 @@ class Two_Factor_Extended_Settings {
display_name ) : esc_html__( 'Unknown', 'two-factor-extended' ); } else { esc_html_e( 'System', 'two-factor-extended' ); } ?>
- + - + - + - + - +
@@ -1201,9 +1246,9 @@ class Two_Factor_Extended_Settings { $role_stats ) : ?>
display_name ) : esc_html( $user_data['user_login'] ); ?>display_name ) : esc_html( $user_login_val ); ?> - + @@ -1255,7 +1331,7 @@ class Two_Factor_Extended_Settings {
user_login ) : '-'; ?>
- + - + - + - + - +
@@ -1477,9 +1570,9 @@ class Two_Factor_Extended_Settings { $role_stats ) : ?> - - - + + + @@ -1512,19 +1605,29 @@ class Two_Factor_Extended_Settings { + - - - - + + + + - + @@ -1555,7 +1658,7 @@ class Two_Factor_Extended_Settings { // Handle form submission. if ( isset( $_POST['submit'] ) ) { // Verify nonce. - $nonce = isset( $_POST['two_factor_extended_network_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['two_factor_extended_network_nonce'] ) ) : ''; + $nonce = isset( $_POST['two_factor_extended_network_nonce'] ) && is_string( $_POST['two_factor_extended_network_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['two_factor_extended_network_nonce'] ) ) : ''; if ( ! wp_verify_nonce( $nonce, 'two_factor_extended_network_settings' ) ) { wp_die( esc_html__( 'Security check failed.', 'two-factor-extended' ) ); @@ -1616,7 +1719,7 @@ class Two_Factor_Extended_Settings { * * @since 0.1.0 * - * @return array Default settings. + * @return array Default settings. */ private function get_default_settings(): array { $all_roles = Two_Factor_Extended_Role_Manager::get_all_roles(); @@ -1641,11 +1744,15 @@ class Two_Factor_Extended_Settings { * * @since 0.1.0 * - * @return array Plugin settings. + * @return array Plugin settings. */ public function get_settings(): array { $settings = get_option( TWO_FACTOR_EXTENDED_OPTION_SETTINGS, array() ); + if ( ! is_array( $settings ) ) { + $settings = array(); + } + return wp_parse_args( $settings, $this->get_default_settings() ); } @@ -1654,7 +1761,7 @@ class Two_Factor_Extended_Settings { * * @since 0.1.0 * - * @param array $settings New settings values. + * @param array $settings New settings values. * * @return bool True if settings were updated successfully. */ @@ -1669,20 +1776,20 @@ class Two_Factor_Extended_Settings { * * @since 0.1.0 * - * @param array $input Raw settings input. + * @param array $input Raw settings input. * - * @return array Sanitized settings. + * @return array Sanitized settings. */ public function sanitize_settings( array $input ): array { $sanitized = array(); // Sanitize enabled setting. if ( isset( $input['enabled'] ) ) { - $sanitized['enabled'] = rest_sanitize_boolean( $input['enabled'] ); + $sanitized['enabled'] = (bool) $input['enabled']; } // Sanitize grace period days. - if ( isset( $input['grace_period_days'] ) ) { + if ( isset( $input['grace_period_days'] ) && is_numeric( $input['grace_period_days'] ) ) { $grace_days = (int) $input['grace_period_days']; $sanitized['grace_period_days'] = max( 0, min( 365, $grace_days ) ); } @@ -1701,6 +1808,9 @@ class Two_Factor_Extended_Settings { // Now merge any submitted role requirements data. if ( isset( $input['role_requirements'] ) && is_array( $input['role_requirements'] ) ) { foreach ( $input['role_requirements'] as $role => $providers ) { + if ( ! is_string( $role ) ) { + continue; + } $role_slug = sanitize_key( $role ); if ( ! Two_Factor_Extended_Role_Manager::role_exists( $role_slug ) ) { @@ -1708,7 +1818,12 @@ class Two_Factor_Extended_Settings { } if ( is_array( $providers ) ) { - $sanitized['role_requirements'][ $role_slug ] = array_map( 'sanitize_text_field', $providers ); + $sanitized['role_requirements'][ $role_slug ] = array_map( + function ( $item ) { + return sanitize_text_field( is_scalar( $item ) ? (string) $item : '' ); + }, + $providers + ); } } } @@ -1724,6 +1839,9 @@ class Two_Factor_Extended_Settings { // Now merge any submitted provider visibility data. if ( isset( $input['provider_visibility'] ) && is_array( $input['provider_visibility'] ) ) { foreach ( $input['provider_visibility'] as $role => $providers ) { + if ( ! is_string( $role ) ) { + continue; + } $role_slug = sanitize_key( $role ); if ( ! Two_Factor_Extended_Role_Manager::role_exists( $role_slug ) ) { @@ -1733,7 +1851,12 @@ class Two_Factor_Extended_Settings { if ( is_array( $providers ) ) { // Remove duplicates and sanitize. $sanitized['provider_visibility'][ $role_slug ] = array_unique( - array_map( 'sanitize_text_field', $providers ) + array_map( + function ( $item ) { + return sanitize_text_field( is_scalar( $item ) ? (string) $item : '' ); + }, + $providers + ) ); } } @@ -1750,13 +1873,14 @@ class Two_Factor_Extended_Settings { * * @since 0.1.0 * - * @param array $input Settings input to validate. + * @param array $input Settings input to validate. * * @return bool True if valid, false otherwise. */ public function validate_settings( array $input ): bool { // Basic validation - can be extended. - return is_array( $input ); + // Since parameter is typed as array, it's always valid. + return true; } /** @@ -1771,7 +1895,7 @@ class Two_Factor_Extended_Settings { } // Verify nonce. - if ( ! isset( $_POST['two_factor_extended_reset_nonce'] ) || + if ( ! isset( $_POST['two_factor_extended_reset_nonce'] ) || ! is_string( $_POST['two_factor_extended_reset_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['two_factor_extended_reset_nonce'] ) ), 'two_factor_extended_reset' ) ) { wp_die( esc_html__( 'Security check failed.', 'two-factor-extended' ) ); } @@ -1791,15 +1915,18 @@ class Two_Factor_Extended_Settings { // Clear user grace period data. $users = get_users( array( 'fields' => 'ID' ) ); foreach ( $users as $user_id ) { - delete_user_meta( $user_id, 'two_factor_extended_enforcement_date' ); + delete_user_meta( (int) $user_id, 'two_factor_extended_enforcement_date' ); } // Log the reset action. $audit_log = two_factor_extended()->get_audit_log(); - $audit_log->log_event( - 'settings_reset', - 'Plugin settings reset to default values' - ); + + if ( null !== $audit_log ) { + $audit_log->log_event( + 'settings_reset', + 'Plugin settings reset to default values' + ); + } // Redirect with success message. add_settings_error( diff --git a/includes/class-two-factor-extended.php b/includes/class-two-factor-extended.php index 32d1cd9..0ce32cb 100644 --- a/includes/class-two-factor-extended.php +++ b/includes/class-two-factor-extended.php @@ -34,7 +34,7 @@ class Two_Factor_Extended { * @since 0.1.0 * @var string */ - private string $version = '1.0.0'; + private string $version = '1.0.1'; /** * Plugin directory path. @@ -147,9 +147,9 @@ class Two_Factor_Extended { * @since 0.1.0 */ private function __construct() { - $this->plugin_path = plugin_dir_path( dirname( __FILE__ ) ); - $this->plugin_url = plugin_dir_url( dirname( __FILE__ ) ); - $this->plugin_basename = plugin_basename( dirname( dirname( __FILE__ ) ) . '/two-factor-extended.php' ); + $this->plugin_path = plugin_dir_path( __DIR__ ); + $this->plugin_url = plugin_dir_url( __DIR__ ); + $this->plugin_basename = plugin_basename( dirname( __DIR__ ) . '/two-factor-extended.php' ); $this->init(); } @@ -253,9 +253,9 @@ class Two_Factor_Extended { * * @since 0.1.0 * - * @return Two_Factor_Extended_Settings Settings instance. + * @return Two_Factor_Extended_Settings|null Settings instance or null if not initialized. */ - public function get_settings(): Two_Factor_Extended_Settings { + public function get_settings(): ?Two_Factor_Extended_Settings { return $this->settings; } @@ -264,9 +264,9 @@ class Two_Factor_Extended { * * @since 0.1.0 * - * @return Two_Factor_Extended_Enforcement Enforcement instance. + * @return Two_Factor_Extended_Enforcement|null Enforcement instance or null if not initialized. */ - public function get_enforcement(): Two_Factor_Extended_Enforcement { + public function get_enforcement(): ?Two_Factor_Extended_Enforcement { return $this->enforcement; } @@ -275,9 +275,9 @@ class Two_Factor_Extended { * * @since 0.1.0 * - * @return Two_Factor_Extended_Provider_Filter Provider filter instance. + * @return Two_Factor_Extended_Provider_Filter|null Provider filter instance or null if not initialized. */ - public function get_provider_filter(): Two_Factor_Extended_Provider_Filter { + public function get_provider_filter(): ?Two_Factor_Extended_Provider_Filter { return $this->provider_filter; } @@ -286,9 +286,9 @@ class Two_Factor_Extended { * * @since 0.1.0 * - * @return Two_Factor_Extended_Network_Settings Network settings instance. + * @return Two_Factor_Extended_Network_Settings|null Network settings instance or null if not initialized. */ - public function get_network_settings(): Two_Factor_Extended_Network_Settings { + public function get_network_settings(): ?Two_Factor_Extended_Network_Settings { return $this->network_settings; } @@ -297,9 +297,9 @@ class Two_Factor_Extended { * * @since 0.1.0 * - * @return Two_Factor_Extended_Audit_Log Audit log instance. + * @return Two_Factor_Extended_Audit_Log|null Audit log instance or null if not initialized. */ - public function get_audit_log(): Two_Factor_Extended_Audit_Log { + public function get_audit_log(): ?Two_Factor_Extended_Audit_Log { return $this->audit_log; } @@ -308,9 +308,9 @@ class Two_Factor_Extended { * * @since 0.1.0 * - * @return Two_Factor_Extended_Compliance_Report Compliance report instance. + * @return Two_Factor_Extended_Compliance_Report|null Compliance report instance or null if not initialized. */ - public function get_compliance_report(): Two_Factor_Extended_Compliance_Report { + public function get_compliance_report(): ?Two_Factor_Extended_Compliance_Report { return $this->compliance_report; } @@ -319,9 +319,9 @@ class Two_Factor_Extended { * * @since 0.1.0 * - * @return Two_Factor_Extended_Bulk_Actions Bulk actions instance. + * @return Two_Factor_Extended_Bulk_Actions|null Bulk actions instance or null if not initialized. */ - public function get_bulk_actions(): Two_Factor_Extended_Bulk_Actions { + public function get_bulk_actions(): ?Two_Factor_Extended_Bulk_Actions { return $this->bulk_actions; } @@ -330,9 +330,9 @@ class Two_Factor_Extended { * * @since 0.1.0 * - * @return Two_Factor_Extended_REST_API REST API instance. + * @return Two_Factor_Extended_REST_API|null REST API instance or null if not initialized. */ - public function get_rest_api(): Two_Factor_Extended_REST_API { + public function get_rest_api(): ?Two_Factor_Extended_REST_API { return $this->rest_api; } diff --git a/readme.txt b/readme.txt index d12d473..fecf7d0 100644 --- a/readme.txt +++ b/readme.txt @@ -3,9 +3,9 @@ Contributors: javiercasares, robotstxt Tags: two-factor, 2fa, authentication, security Requires at least: 6.7 Tested up to: 6.9 -Stable tag: 1.0.0 +Stable tag: 1.0.1 Requires PHP: 8.2 -Version: 1.0.0 +Version: 1.0.1 License: GPL-3.0-or-later License URI: https://www.gnu.org/licenses/gpl-3.0.txt diff --git a/robotstxt-updater.php b/robotstxt-updater.php index 15b5bb0..c252a03 100644 --- a/robotstxt-updater.php +++ b/robotstxt-updater.php @@ -13,6 +13,7 @@ if ( ! defined( 'ABSPATH' ) ) { exit; } +// phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound -- Shared generic updater library, intentionally unprefixed. if ( ! class_exists( 'Robotstxt_Updater' ) ) { /** * Class Robotstxt_Updater @@ -21,363 +22,387 @@ if ( ! class_exists( 'Robotstxt_Updater' ) ) { * Reads plugin headers and constructs update URL automatically. */ class Robotstxt_Updater { + // phpcs:enable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound - /** - * Plugin file path. - * - * @var string - */ - private string $plugin_file_path; + /** + * Plugin file path. + * + * @var string + */ + private string $plugin_file_path; - /** - * Plugin basename (e.g., 'my-plugin/my-plugin.php'). - * - * @var string - */ - private string $plugin_basename; + /** + * Plugin basename (e.g., 'my-plugin/my-plugin.php'). + * + * @var string + */ + private string $plugin_basename; - /** - * Plugin slug (directory name). - * - * @var string - */ - private string $plugin_slug; + /** + * Plugin slug (directory name). + * + * @var string + */ + private string $plugin_slug; - /** - * Remote JSON URL. - * - * @var string - */ - private string $json_url; + /** + * Remote JSON URL. + * + * @var string + */ + private string $json_url; - /** - * Cache key. - * - * @var string - */ - private string $cache_key; + /** + * Cache key. + * + * @var string + */ + private string $cache_key; - /** - * Plugin headers. - * - * @var array - */ - private array $plugin_data; + /** + * Plugin headers. + * + * @var array + */ + private array $plugin_data; - /** - * Initialize the updater. - * - * Usage in your main plugin file: - * require_once __DIR__ . '/robotstxt-updater.php'; - * Robotstxt_Updater::init( __FILE__ ); - * - * @param string $plugin_file_path Absolute path to the main plugin file. - */ - public static function init( string $plugin_file_path ): void { - $instance = new self( $plugin_file_path ); - $instance->register(); - } - - /** - * Constructor. - * - * @param string $plugin_file_path Absolute path to the main plugin file. - */ - private function __construct( string $plugin_file_path ) { - $this->plugin_file_path = $plugin_file_path; - $this->plugin_basename = plugin_basename( $plugin_file_path ); - $this->plugin_slug = dirname( $this->plugin_basename ); - $this->plugin_data = $this->get_plugin_data(); - $this->json_url = $this->build_json_url(); - $this->cache_key = 'robotstxt_updater_' . md5( $this->plugin_basename ); - } - - /** - * Register WordPress hooks. - */ - private function register(): void { - add_filter( 'pre_set_site_transient_update_plugins', array( $this, 'inject_update_info' ) ); - add_filter( 'plugins_api', array( $this, 'provide_plugin_details' ), 10, 3 ); - add_action( 'admin_init', array( $this, 'handle_cache_clear' ) ); - add_action( 'robotstxt_updater_clear_cache', array( $this, 'clear_cache' ) ); - } - - /** - * Get plugin headers. - * - * @return array Plugin data. - */ - private function get_plugin_data(): array { - if ( ! function_exists( 'get_plugin_data' ) ) { - require_once ABSPATH . 'wp-admin/includes/plugin.php'; + /** + * Initialize the updater. + * + * Usage in your main plugin file: + * require_once __DIR__ . '/robotstxt-updater.php'; + * Robotstxt_Updater::init( __FILE__ ); + * + * @param string $plugin_file_path Absolute path to the main plugin file. + */ + public static function init( string $plugin_file_path ): void { + $instance = new self( $plugin_file_path ); + $instance->register(); } - return get_plugin_data( $this->plugin_file_path, false, false ); - } + /** + * Constructor. + * + * @param string $plugin_file_path Absolute path to the main plugin file. + */ + private function __construct( string $plugin_file_path ) { + $this->plugin_file_path = $plugin_file_path; + $this->plugin_basename = plugin_basename( $plugin_file_path ); + $this->plugin_slug = dirname( $this->plugin_basename ); + $this->plugin_data = $this->get_plugin_data(); + $this->json_url = $this->build_json_url(); + $this->cache_key = 'robotstxt_updater_' . md5( $this->plugin_basename ); + } - /** - * Build JSON URL from plugin headers. - * - * Tries to use "Gitea Plugin URI" header to construct the URL. - * Falls back to Plugin URI if Gitea URI is not available. - * - * @return string JSON URL. - */ - private function build_json_url(): string { - // Try Gitea Plugin URI (format: "OWNER/REPO" or full URL). - if ( ! empty( $this->plugin_data['Gitea Plugin URI'] ) ) { - $gitea_uri = $this->plugin_data['Gitea Plugin URI']; + /** + * Register WordPress hooks. + */ + private function register(): void { + add_filter( 'pre_set_site_transient_update_plugins', array( $this, 'inject_update_info' ) ); + add_filter( 'plugins_api', array( $this, 'provide_plugin_details' ), 10, 3 ); + add_action( 'admin_init', array( $this, 'handle_cache_clear' ) ); + add_action( 'robotstxt_updater_clear_cache', array( $this, 'clear_cache' ) ); + } - // If it's already a full URL, use it. - if ( str_starts_with( $gitea_uri, 'http' ) ) { - // Extract base URL and construct JSON path. - return rtrim( $gitea_uri, '/' ) . '/raw/branch/main/update.json'; + /** + * Get plugin headers. + * + * @return array Plugin data. + */ + private function get_plugin_data(): array { + if ( ! function_exists( 'get_plugin_data' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; } - // If it's in format "OWNER/REPO", construct full URL. - if ( preg_match( '#^[^/]+/[^/]+$#', $gitea_uri ) ) { - return "https://git.robotstxt.es/{$gitea_uri}/raw/branch/main/update.json"; - } + return get_plugin_data( $this->plugin_file_path, false, false ); } - // Fallback: try to extract from Plugin URI. - if ( ! empty( $this->plugin_data['PluginURI'] ) ) { - $plugin_uri = $this->plugin_data['PluginURI']; - if ( str_contains( $plugin_uri, 'git.robotstxt.es' ) ) { - return rtrim( $plugin_uri, '/' ) . '/raw/branch/main/update.json'; - } - } + /** + * Build JSON URL from plugin headers. + * + * Tries to use "Gitea Plugin URI" header to construct the URL. + * Falls back to Plugin URI if Gitea URI is not available. + * + * @return string JSON URL. + */ + private function build_json_url(): string { + // Try Gitea Plugin URI (format: "OWNER/REPO" or full URL). + if ( ! empty( $this->plugin_data['Gitea Plugin URI'] ) && is_string( $this->plugin_data['Gitea Plugin URI'] ) ) { + $gitea_uri = $this->plugin_data['Gitea Plugin URI']; - // Last resort: construct from plugin slug. - return "https://git.robotstxt.es/ROBOTSTXT/{$this->plugin_slug}/raw/branch/main/update.json"; - } - - /** - * Inject update info into WP's plugin update transient. - * - * @param object|mixed $transient The update_plugins transient. - * - * @return object The modified transient. - */ - public function inject_update_info( $transient ) { - if ( ! is_object( $transient ) ) { - $transient = new stdClass(); - } - - if ( empty( $transient->checked ) || ! is_array( $transient->checked ) ) { - return $transient; - } - - if ( empty( $transient->checked[ $this->plugin_basename ] ) ) { - return $transient; - } - - $current_version = $transient->checked[ $this->plugin_basename ]; - $remote = $this->get_remote_data(); - - if ( empty( $remote['version'] ) || empty( $remote['download_url'] ) ) { - return $transient; - } - - if ( ! $this->is_compatible( $remote ) ) { - return $transient; - } - - if ( version_compare( $remote['version'], $current_version, '>' ) ) { - $update = (object) array( - 'slug' => $remote['slug'] ?? $this->plugin_slug, - 'plugin' => $this->plugin_basename, - 'new_version' => $remote['version'], - 'url' => $remote['homepage'] ?? $this->plugin_data['PluginURI'] ?? '', - 'package' => $remote['download_url'], - 'tested' => $remote['tested'] ?? '', - 'requires' => $remote['requires'] ?? '', - 'requires_php' => $remote['requires_php'] ?? '', - ); - - $transient->response[ $this->plugin_basename ] = $update; - } - - return $transient; - } - - /** - * Provide "View details" modal content. - * - * @param false|object|array $result The result object or array. - * @param string $action The type of information being requested. - * @param object $args Plugin API arguments. - * - * @return false|object The plugin information object or false. - */ - public function provide_plugin_details( $result, string $action, object $args ) { - if ( 'plugin_information' !== $action ) { - return $result; - } - - if ( empty( $args->slug ) || $args->slug !== $this->plugin_slug ) { - return $result; - } - - $remote = $this->get_remote_data(); - - if ( empty( $remote['version'] ) ) { - return $result; - } - - return (object) array( - 'name' => $remote['name'] ?? $this->plugin_data['Name'] ?? $this->plugin_slug, - 'slug' => $remote['slug'] ?? $this->plugin_slug, - 'version' => $remote['version'], - 'author' => $remote['author'] ?? $this->plugin_data['Author'] ?? '', - 'homepage' => $remote['homepage'] ?? $this->plugin_data['PluginURI'] ?? '', - 'requires' => $remote['requires'] ?? '', - 'tested' => $remote['tested'] ?? '', - 'requires_php' => $remote['requires_php'] ?? '', - 'sections' => array( - 'description' => $remote['description'] ?? $this->plugin_data['Description'] ?? '', - 'changelog' => $remote['changelog'] ?? '', - ), - 'download_link' => $remote['download_url'] ?? '', - ); - } - - /** - * Get remote data with caching and HMAC signature verification. - * - * @return array Remote data. - */ - private function get_remote_data(): array { - $cached = get_site_transient( $this->cache_key ); - - // Verify HMAC signature if AUTH_SALT is defined and cache has signature. - if ( false !== $cached && defined( 'AUTH_SALT' ) && '' !== AUTH_SALT ) { - if ( is_array( $cached ) && isset( $cached['signature'], $cached['data'] ) ) { - $expected_sig = hash_hmac( 'sha256', $this->cache_key . serialize( $cached['data'] ), AUTH_SALT ); - - if ( hash_equals( $expected_sig, $cached['signature'] ) ) { - // Signature valid, return data. - return is_array( $cached['data'] ) ? $cached['data'] : array(); + // If it's already a full URL, use it. + if ( str_starts_with( $gitea_uri, 'http' ) ) { + // Extract base URL and construct JSON path. + return rtrim( $gitea_uri, '/' ) . '/raw/branch/main/update.json'; } - // Signature invalid, delete corrupted cache. - delete_site_transient( $this->cache_key ); - $cached = false; + // If it's in format "OWNER/REPO", construct full URL. + if ( preg_match( '#^[^/]+/[^/]+$#', $gitea_uri ) ) { + return "https://git.robotstxt.es/{$gitea_uri}/raw/branch/main/update.json"; + } } + + // Fallback: try to extract from Plugin URI. + if ( ! empty( $this->plugin_data['PluginURI'] ) && is_string( $this->plugin_data['PluginURI'] ) ) { + $plugin_uri = $this->plugin_data['PluginURI']; + if ( str_contains( $plugin_uri, 'git.robotstxt.es' ) ) { + return rtrim( $plugin_uri, '/' ) . '/raw/branch/main/update.json'; + } + } + + // Last resort: construct from plugin slug. + return "https://git.robotstxt.es/ROBOTSTXT/{$this->plugin_slug}/raw/branch/main/update.json"; } - // If no valid cache, fetch fresh data. - if ( false === $cached ) { - $remote = $this->fetch_json(); + /** + * Inject update info into WP's plugin update transient. + * + * @param mixed $transient The update_plugins transient. + * + * @return stdClass The modified transient. + */ + public function inject_update_info( $transient ): stdClass { + if ( ! ( $transient instanceof stdClass ) ) { + $transient = new stdClass(); + } - // Store with HMAC signature if AUTH_SALT is available. - if ( defined( 'AUTH_SALT' ) && '' !== AUTH_SALT ) { - $payload = array( - 'data' => $remote ?: array(), - 'timestamp' => time(), - 'signature' => hash_hmac( 'sha256', $this->cache_key . serialize( $remote ?: array() ), AUTH_SALT ), + if ( empty( $transient->checked ) || ! is_array( $transient->checked ) ) { + return $transient; + } + + if ( empty( $transient->checked[ $this->plugin_basename ] ) ) { + return $transient; + } + + $current_version_raw = $transient->checked[ $this->plugin_basename ]; + $current_version = is_string( $current_version_raw ) ? $current_version_raw : ''; + $remote = $this->get_remote_data(); + + if ( empty( $remote['version'] ) || empty( $remote['download_url'] ) ) { + return $transient; + } + + if ( ! $this->is_compatible( $remote ) ) { + return $transient; + } + + $remote_version = is_string( $remote['version'] ) ? $remote['version'] : ''; + + if ( version_compare( $remote_version, $current_version, '>' ) ) { + $plugin_uri = isset( $this->plugin_data['PluginURI'] ) && is_string( $this->plugin_data['PluginURI'] ) ? $this->plugin_data['PluginURI'] : ''; + + $update = (object) array( + 'slug' => isset( $remote['slug'] ) && is_string( $remote['slug'] ) ? $remote['slug'] : $this->plugin_slug, + 'plugin' => $this->plugin_basename, + 'new_version' => $remote_version, + 'url' => isset( $remote['homepage'] ) && is_string( $remote['homepage'] ) ? $remote['homepage'] : $plugin_uri, + 'package' => is_string( $remote['download_url'] ) ? $remote['download_url'] : '', + 'tested' => isset( $remote['tested'] ) && is_string( $remote['tested'] ) ? $remote['tested'] : '', + 'requires' => isset( $remote['requires'] ) && is_string( $remote['requires'] ) ? $remote['requires'] : '', + 'requires_php' => isset( $remote['requires_php'] ) && is_string( $remote['requires_php'] ) ? $remote['requires_php'] : '', ); - set_site_transient( $this->cache_key, $payload, 6 * HOUR_IN_SECONDS ); - } else { - // Fallback to standard caching. - set_site_transient( $this->cache_key, $remote ?: array(), 6 * HOUR_IN_SECONDS ); + + if ( ! property_exists( $transient, 'response' ) || ! is_array( $transient->response ) ) { + $transient->response = array(); + } + + $transient->response[ $this->plugin_basename ] = $update; } - return is_array( $remote ) ? $remote : array(); + return $transient; } - // Legacy cache format without signature (backward compatibility). - return is_array( $cached ) ? $cached : array(); - } + /** + * Provide "View details" modal content. + * + * @param false|object|array $result The result object or array. + * @param string $action The type of information being requested. + * @param object $args Plugin API arguments. + * + * @return false|object|array The plugin information object or false. + */ + public function provide_plugin_details( $result, string $action, object $args ) { + if ( 'plugin_information' !== $action ) { + return $result; + } - /** - * Fetch JSON from remote URL. - * - * @return array Decoded JSON data. - */ - private function fetch_json(): array { - $response = wp_remote_get( - $this->json_url, - array( - 'timeout' => 10, - 'headers' => array( - 'Accept' => 'application/json', + if ( empty( $args->slug ) || $args->slug !== $this->plugin_slug ) { + return $result; + } + + $remote = $this->get_remote_data(); + + if ( empty( $remote['version'] ) ) { + return $result; + } + + $plugin_name = isset( $remote['name'] ) && is_string( $remote['name'] ) ? $remote['name'] : ( isset( $this->plugin_data['Name'] ) && is_string( $this->plugin_data['Name'] ) ? $this->plugin_data['Name'] : $this->plugin_slug ); + $plugin_slug = isset( $remote['slug'] ) && is_string( $remote['slug'] ) ? $remote['slug'] : $this->plugin_slug; + $plugin_version = is_string( $remote['version'] ) ? $remote['version'] : ''; + $plugin_author = isset( $remote['author'] ) && is_string( $remote['author'] ) ? $remote['author'] : ( isset( $this->plugin_data['Author'] ) && is_string( $this->plugin_data['Author'] ) ? $this->plugin_data['Author'] : '' ); + $plugin_homepage = isset( $remote['homepage'] ) && is_string( $remote['homepage'] ) ? $remote['homepage'] : ( isset( $this->plugin_data['PluginURI'] ) && is_string( $this->plugin_data['PluginURI'] ) ? $this->plugin_data['PluginURI'] : '' ); + $plugin_requires = isset( $remote['requires'] ) && is_string( $remote['requires'] ) ? $remote['requires'] : ''; + $plugin_tested = isset( $remote['tested'] ) && is_string( $remote['tested'] ) ? $remote['tested'] : ''; + $plugin_requires_php = isset( $remote['requires_php'] ) && is_string( $remote['requires_php'] ) ? $remote['requires_php'] : ''; + $plugin_description = isset( $remote['description'] ) && is_string( $remote['description'] ) ? $remote['description'] : ( isset( $this->plugin_data['Description'] ) && is_string( $this->plugin_data['Description'] ) ? $this->plugin_data['Description'] : '' ); + $plugin_changelog = isset( $remote['changelog'] ) && is_string( $remote['changelog'] ) ? $remote['changelog'] : ''; + $plugin_download = isset( $remote['download_url'] ) && is_string( $remote['download_url'] ) ? $remote['download_url'] : ''; + + return (object) array( + 'name' => $plugin_name, + 'slug' => $plugin_slug, + 'version' => $plugin_version, + 'author' => $plugin_author, + 'homepage' => $plugin_homepage, + 'requires' => $plugin_requires, + 'tested' => $plugin_tested, + 'requires_php' => $plugin_requires_php, + 'sections' => array( + 'description' => $plugin_description, + 'changelog' => $plugin_changelog, ), - ) - ); - - if ( is_wp_error( $response ) ) { - return array(); + 'download_link' => $plugin_download, + ); } - $code = (int) wp_remote_retrieve_response_code( $response ); - if ( $code < 200 || $code >= 300 ) { - return array(); - } + /** + * Get remote data with caching and HMAC signature verification. + * + * @return array Remote data. + */ + private function get_remote_data(): array { + $cached = get_site_transient( $this->cache_key ); - $body = wp_remote_retrieve_body( $response ); - $data = json_decode( $body, true ); + // Verify HMAC signature if AUTH_SALT is defined and cache has signature. + if ( false !== $cached && defined( 'AUTH_SALT' ) && '' !== AUTH_SALT ) { + if ( is_array( $cached ) && isset( $cached['signature'], $cached['data'] ) ) { + $expected_sig = hash_hmac( 'sha256', $this->cache_key . serialize( $cached['data'] ), AUTH_SALT ); - return is_array( $data ) ? $data : array(); - } + $cached_sig = is_string( $cached['signature'] ) ? $cached['signature'] : ''; - /** - * Check compatibility. - * - * @param array $remote Remote data. - * - * @return bool True if compatible. - */ - private function is_compatible( array $remote ): bool { - if ( ! empty( $remote['requires_php'] ) ) { - if ( version_compare( PHP_VERSION, $remote['requires_php'], '<' ) ) { - return false; + if ( hash_equals( $expected_sig, $cached_sig ) ) { + // Signature valid, return data. + return is_array( $cached['data'] ) ? $cached['data'] : array(); + } + + // Signature invalid, delete corrupted cache. + delete_site_transient( $this->cache_key ); + $cached = false; + } } - } - if ( ! empty( $remote['requires'] ) ) { - if ( version_compare( get_bloginfo( 'version' ), $remote['requires'], '<' ) ) { - return false; + // If no valid cache, fetch fresh data. + if ( false === $cached ) { + $remote = $this->fetch_json(); + + // Store with HMAC signature if AUTH_SALT is available. + if ( defined( 'AUTH_SALT' ) && '' !== AUTH_SALT ) { + $payload = array( + 'data' => $remote, + 'timestamp' => time(), + 'signature' => hash_hmac( 'sha256', $this->cache_key . serialize( $remote ), AUTH_SALT ), + ); + set_site_transient( $this->cache_key, $payload, 6 * HOUR_IN_SECONDS ); + } else { + // Fallback to standard caching. + set_site_transient( $this->cache_key, $remote, 6 * HOUR_IN_SECONDS ); + } + + return $remote; } + + // Legacy cache format without signature (backward compatibility). + return is_array( $cached ) ? $cached : array(); } - return true; - } + /** + * Fetch JSON from remote URL. + * + * @return array Decoded JSON data. + */ + private function fetch_json(): array { + $response = wp_remote_get( + $this->json_url, + array( + 'timeout' => 10, + 'headers' => array( + 'Accept' => 'application/json', + ), + ) + ); - /** - * Handle manual cache clear via URL parameter. - */ - public function handle_cache_clear(): void { - // Check if this is a cache clear request first. - $clear_cache = filter_input( INPUT_GET, 'robotstxt_clear_update_cache', FILTER_UNSAFE_RAW ); - if ( null === $clear_cache ) { - return; + if ( is_wp_error( $response ) ) { + return array(); + } + + $code = (int) wp_remote_retrieve_response_code( $response ); + if ( $code < 200 || $code >= 300 ) { + return array(); + } + + $body = wp_remote_retrieve_body( $response ); + $data = json_decode( $body, true ); + + return is_array( $data ) ? $data : array(); } - // This is a cache clear request - now verify nonce. - $nonce_raw = filter_input( INPUT_GET, '_wpnonce', FILTER_UNSAFE_RAW ); - $nonce = $nonce_raw ? sanitize_text_field( wp_unslash( $nonce_raw ) ) : ''; + /** + * Check compatibility. + * + * @param array $remote Remote data. + * + * @return bool True if compatible. + */ + private function is_compatible( array $remote ): bool { + if ( ! empty( $remote['requires_php'] ) && is_string( $remote['requires_php'] ) ) { + if ( version_compare( PHP_VERSION, $remote['requires_php'], '<' ) ) { + return false; + } + } - if ( ! wp_verify_nonce( $nonce, 'robotstxt_clear_update_cache' ) ) { - wp_die( esc_html__( 'Security check failed', 'two-factor-extended' ) ); + if ( ! empty( $remote['requires'] ) && is_string( $remote['requires'] ) ) { + if ( version_compare( get_bloginfo( 'version' ), $remote['requires'], '<' ) ) { + return false; + } + } + + return true; } - // Check permissions. - if ( ! current_user_can( 'update_plugins' ) ) { - wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'two-factor-extended' ) ); + /** + * Handle manual cache clear via URL parameter. + */ + public function handle_cache_clear(): void { + // Check if this is a cache clear request first. + $clear_cache = filter_input( INPUT_GET, 'robotstxt_clear_update_cache', FILTER_UNSAFE_RAW ); + if ( null === $clear_cache ) { + return; + } + + // This is a cache clear request - now verify nonce. + $nonce_raw = filter_input( INPUT_GET, '_wpnonce', FILTER_UNSAFE_RAW ); + $nonce = $nonce_raw ? sanitize_text_field( wp_unslash( $nonce_raw ) ) : ''; + + if ( ! wp_verify_nonce( $nonce, 'robotstxt_clear_update_cache' ) ) { + wp_die( esc_html__( 'Security check failed', 'two-factor-extended' ) ); + } + + // Check permissions. + if ( ! current_user_can( 'update_plugins' ) ) { + wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'two-factor-extended' ) ); + } + + $this->clear_cache(); + wp_safe_redirect( remove_query_arg( array( 'robotstxt_clear_update_cache', '_wpnonce' ) ) ); + exit; } - $this->clear_cache(); - wp_safe_redirect( remove_query_arg( array( 'robotstxt_clear_update_cache', '_wpnonce' ) ) ); - exit; - } - - /** - * Clear update cache. - */ - public function clear_cache(): void { - delete_site_transient( $this->cache_key ); - delete_site_transient( 'update_plugins' ); - } + /** + * Clear update cache. + */ + public function clear_cache(): void { + delete_site_transient( $this->cache_key ); + delete_site_transient( 'update_plugins' ); + } } } diff --git a/two-factor-extended.php b/two-factor-extended.php index 241e9c7..bc8ae08 100644 --- a/two-factor-extended.php +++ b/two-factor-extended.php @@ -3,7 +3,7 @@ * Plugin Name: Two Factor Extended * Plugin URI: https://git.robotstxt.es/ROBOTSTXT/two-factor-extended * Description: Extends the WordPress Two Factor plugin with advanced role-based controls, forced 2FA methods, and enhanced administrative features for single-site and Multisite installations. - * Version: 1.0.0 + * Version: 1.0.1 * Requires at least: 6.7 * Requires PHP: 8.2 * Requires Plugins: two-factor @@ -18,7 +18,7 @@ * Primary Branch: main * * @package TwoFactorExtended - * @version 1.0.0 + * @version 1.0.1 */ // Prevent direct access. @@ -27,7 +27,7 @@ if ( ! defined( 'ABSPATH' ) ) { } // Define plugin constants. -define( 'TWO_FACTOR_EXTENDED_VERSION', '1.0.0' ); +define( 'TWO_FACTOR_EXTENDED_VERSION', '1.0.1' ); define( 'TWO_FACTOR_EXTENDED_PLUGIN_FILE', __FILE__ ); define( 'TWO_FACTOR_EXTENDED_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); define( 'TWO_FACTOR_EXTENDED_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); @@ -48,7 +48,7 @@ define( 'TWO_FACTOR_EXTENDED_NETWORK_OPTION_VERSION', 'two_factor_extended_netwo * * @since 0.1.0 */ -function two_factor_extended_activate() { +function two_factor_extended_activate(): void { // Store plugin version. update_option( TWO_FACTOR_EXTENDED_OPTION_VERSION, TWO_FACTOR_EXTENDED_VERSION ); @@ -70,7 +70,7 @@ function two_factor_extended_activate() { * * @since 0.1.0 */ -function two_factor_extended_deactivate() { +function two_factor_extended_deactivate(): void { // Clear any scheduled cron events. wp_clear_scheduled_hook( 'two_factor_extended_daily_cleanup' ); diff --git a/uninstall.php b/uninstall.php index 59386f8..3b44b7a 100644 --- a/uninstall.php +++ b/uninstall.php @@ -20,7 +20,7 @@ if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) { * * @since 0.1.0 */ -function two_factor_extended_uninstall() { +function two_factor_extended_uninstall(): void { // Get user preference for data removal. $remove_data = get_option( 'two_factor_extended_remove_data_on_uninstall', false ); @@ -65,7 +65,7 @@ function two_factor_extended_uninstall() { * * @since 1.0.0 */ -function two_factor_extended_delete_all_transients() { +function two_factor_extended_delete_all_transients(): void { global $wpdb; // Get all transient keys using WordPress API wrapper (not direct SQL). @@ -97,7 +97,7 @@ function two_factor_extended_delete_all_transients() { * * @since 1.0.0 */ -function two_factor_extended_delete_all_site_transients() { +function two_factor_extended_delete_all_site_transients(): void { global $wpdb; // Get all site transient keys using WordPress API wrapper (not direct SQL). @@ -128,7 +128,7 @@ function two_factor_extended_delete_all_site_transients() { * * @since 1.0.0 */ -function two_factor_extended_delete_all_user_meta() { +function two_factor_extended_delete_all_user_meta(): void { // Get all user IDs (using WordPress API). $user_ids = get_users( array( diff --git a/update.json b/update.json index 7228cc4..71c67bb 100644 --- a/update.json +++ b/update.json @@ -1,20 +1,20 @@ { "name": "Two Factor Extended", "slug": "two-factor-extended", - "version": "1.0.0", - "download_url": "https://git.robotstxt.es/ROBOTSTXT/two-factor-extended/releases/download/1.0.0/two-factor-extended-1.0.0.zip", + "version": "1.0.1", + "download_url": "https://git.robotstxt.es/ROBOTSTXT/two-factor-extended/releases/download/1.0.1/two-factor-extended-1.0.1.zip", "requires": "6.7", "requires_php": "8.2", "tested": "6.9", - "last_updated": "2026-02-17", + "last_updated": "2026-03-28", "author": "javiercasares, ROBOTSTXT", "author_profile": "https://www.robotstxt.es/", "homepage": "https://git.robotstxt.es/ROBOTSTXT/two-factor-extended", "description": "Extends the WordPress Two Factor plugin with advanced role-based controls, forced 2FA methods, and enhanced administrative features for single-site and Multisite installations.", - "changelog": "

1.0.0 - 2026-02-17

  • Initial Production Release: Complete enterprise-level two-factor authentication management for WordPress
  • Core Features: Role-based 2FA requirements, provider visibility control, grace period enforcement, WordPress Multisite support
  • Advanced Features: Audit logging, compliance reporting, bulk operations, WP-CLI integration, REST API, import/export settings
  • Security: Grade A security audit, comprehensive input validation, output escaping, CSRF protection, XSS prevention
  • Quality: 28 unit tests, WCAG 2.1 Level AA compliant, performance optimized, WordPress Coding Standards compliant
  • Documentation: Complete user and developer guides, security audit report, testing matrix, accessibility documentation
  • Compatibility: WordPress 6.7-6.9, PHP 8.2-8.5, Two Factor 0.15.0 tested
", + "changelog": "

1.0.1 - 2026-03-28

  • Compatibility: Two Factor 0.16 settings panel — settings menu now always appears after Two Factor in admin
  • Code Quality: PHPStan level 9 clean, PHPCS WordPress Coding Standards clean
  • Tests: PHPUnit 9.6 compatible, 28 unit tests passing (PHP 8.5, WordPress 6.7)

1.0.0 - 2026-02-17

  • Initial Production Release: Complete enterprise-level two-factor authentication management for WordPress
  • Core Features: Role-based 2FA requirements, provider visibility control, grace period enforcement, WordPress Multisite support
  • Advanced Features: Audit logging, compliance reporting, bulk operations, WP-CLI integration, REST API, import/export settings
  • Security: Grade A security audit, comprehensive input validation, output escaping, CSRF protection, XSS prevention
  • Quality: 28 unit tests, WCAG 2.1 Level AA compliant, performance optimized, WordPress Coding Standards compliant
  • Compatibility: WordPress 6.7-6.9, PHP 8.2-8.5, Two Factor 0.15.0 tested
", "sections": { "description": "Two Factor Extended is a comprehensive extension for the WordPress Two Factor plugin that provides administrators with enterprise-level controls over two-factor authentication across their site.

Core Features:
  • Role-Based 2FA Requirements
  • Provider Visibility Control
  • Grace Period Enforcement (0-365 days)
  • WordPress Multisite Support
  • Compliance Reporting
  • Audit Logging
  • Bulk Operations
  • WP-CLI Commands
  • REST API Endpoints
  • Import/Export Settings
Security: Grade A security audit, comprehensive security hardening, CSRF protection, XSS prevention, SQL injection protection.

Quality Assurance: 28 unit tests, WCAG 2.1 Level AA compliant, WordPress Coding Standards compliant, PHP 8.2-8.5 compatible.", - "changelog": "

1.0.0 - 2026-02-17

  • Initial Production Release: Complete enterprise-level two-factor authentication management for WordPress
  • Core Features: Role-based 2FA requirements, provider visibility control, grace period enforcement, WordPress Multisite support
  • Advanced Features: Audit logging, compliance reporting, bulk operations, WP-CLI integration, REST API, import/export settings
  • Security: Grade A security audit, comprehensive input validation, output escaping, CSRF protection, XSS prevention
  • Quality: 28 unit tests, WCAG 2.1 Level AA compliant, performance optimized, WordPress Coding Standards compliant
  • Documentation: Complete user and developer guides, security audit report, testing matrix, accessibility documentation
  • Compatibility: WordPress 6.7-6.9, PHP 8.2-8.5, Two Factor 0.15.0 tested
", + "changelog": "

1.0.1 - 2026-03-28

  • Compatibility: Two Factor 0.16 settings panel — settings menu now always appears after Two Factor in admin
  • Code Quality: PHPStan level 9 clean, PHPCS WordPress Coding Standards clean
  • Tests: PHPUnit 9.6 compatible, 28 unit tests passing (PHP 8.5, WordPress 6.7)

1.0.0 - 2026-02-17

  • Initial Production Release: Complete enterprise-level two-factor authentication management for WordPress
  • Core Features: Role-based 2FA requirements, provider visibility control, grace period enforcement, WordPress Multisite support
  • Advanced Features: Audit logging, compliance reporting, bulk operations, WP-CLI integration, REST API, import/export settings
  • Security: Grade A security audit, comprehensive input validation, output escaping, CSRF protection, XSS prevention
  • Quality: 28 unit tests, WCAG 2.1 Level AA compliant, performance optimized, WordPress Coding Standards compliant
  • Compatibility: WordPress 6.7-6.9, PHP 8.2-8.5, Two Factor 0.15.0 tested
", "installation": "
  1. Install the Two Factor plugin (required dependency)
  2. Upload and activate Two Factor Extended plugin
  3. Go to Settings → Two Factor Extended
  4. Configure grace period (7-14 days recommended)
  5. Set role-based 2FA requirements
  6. Configure provider visibility per role
  7. Monitor compliance via Compliance tab
  8. Review audit logs via Audit Log tab
", "faq": "

Does this plugin replace the Two Factor plugin?

No, Two Factor Extended is an extension that works alongside the Two Factor plugin. Both plugins must be installed and activated.

Is this compatible with WordPress Multisite?

Yes! Two Factor Extended fully supports WordPress Multisite with network-wide settings and site-level overrides.

Which PHP versions are supported?

Two Factor Extended requires PHP 8.2 or higher.

Can users bypass the 2FA requirements?

No. When a 2FA method is marked as required for a user's role, they cannot disable or remove it.

" },