From bc7e0a42c7a8b2b916a5d21a6f351bcaafea78f4 Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Sat, 26 Sep 2026 02:48:07 +0600 Subject: [PATCH 1/2] Add personal data exporter and eraser Register the plugin with the WordPress personal data export and erasure tools. - Two_Factor_Core registers both privacy filters and implements the callbacks, keeping the data provider-agnostic. - Providers contribute via two optional base methods on Two_Factor_Provider: privacy_export_data() and privacy_eraser_user_meta_keys(), mirroring the existing uninstall_user_meta_keys() pattern. - The eraser removes short-lived records (login nonce, rate limit counters, email tokens, TOTP replay marker) and keeps credentials (TOTP secret, backup codes), reporting them as retained so the second factor stays active on the account. - Exports never include secret material: the TOTP key, hashed email tokens and backup codes stay out of the payload. - Tests cover the export contents, the erasure behavior, and that no secret material appears in the export payload. Fixes #954 --- class-two-factor-core.php | 221 ++++++++++++++ providers/class-two-factor-backup-codes.php | 29 ++ providers/class-two-factor-email.php | 47 +++ providers/class-two-factor-provider.php | 33 ++ providers/class-two-factor-totp.php | 49 +++ tests/class-two-factor-core.php | 287 ++++++++++++++++++ .../class-two-factor-backup-codes.php | 36 +++ tests/providers/class-two-factor-email.php | 60 ++++ tests/providers/class-two-factor-provider.php | 21 ++ tests/providers/class-two-factor-totp.php | 52 ++++ 10 files changed, 835 insertions(+) diff --git a/class-two-factor-core.php b/class-two-factor-core.php index 5fdf1232..332570ce 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -157,6 +157,11 @@ public static function add_hooks( $compat ) { add_action( 'login_enqueue_scripts', array( __CLASS__, 'login_enqueue_scripts' ), 5 ); add_action( 'admin_init', array( __CLASS__, 'trigger_user_settings_action' ) ); add_action( 'admin_init', array( __CLASS__, 'add_privacy_policy_content' ) ); + + // Personal data export and erasure tools. + add_filter( 'wp_privacy_personal_data_exporters', array( __CLASS__, 'register_personal_data_exporter' ) ); + add_filter( 'wp_privacy_personal_data_erasers', array( __CLASS__, 'register_personal_data_eraser' ) ); + add_filter( 'two_factor_providers', array( __CLASS__, 'enable_dummy_method_for_debug' ) ); // Add Settings link to plugin action links. @@ -2963,4 +2968,220 @@ public static function add_privacy_policy_content() { wp_kses_post( wpautop( $content, false ) ) ); } + + /** + * Registers the personal data exporter. + * + * @since 0.17.0 + * + * @param array $exporters List of personal data exporters. + * @return array + */ + public static function register_personal_data_exporter( $exporters ) { + $exporters['two-factor'] = array( + 'exporter_friendly_name' => __( 'Two Factor Authentication Data', 'two-factor' ), + 'callback' => array( __CLASS__, 'personal_data_exporter' ), + ); + + return $exporters; + } + + /** + * Registers the personal data eraser. + * + * @since 0.17.0 + * + * @param array $erasers List of personal data erasers. + * @return array + */ + public static function register_personal_data_eraser( $erasers ) { + $erasers['two-factor'] = array( + 'eraser_friendly_name' => __( 'Two Factor Authentication Data', 'two-factor' ), + 'callback' => array( __CLASS__, 'personal_data_eraser' ), + ); + + return $erasers; + } + + /** + * Exports the Two Factor data stored for a user. + * + * Credentials are described, never included. The TOTP secret, the backup + * codes and the email token hash stay out of the export file so that it + * remains safe to share. + * + * @since 0.17.0 + * + * @param string $email_address The email address of the user. + * @param int $page The page of data being requested. + * @return array + */ + public static function personal_data_exporter( $email_address, $page = 1 ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Pagination is not needed, all data fits on one page. + $user = get_user_by( 'email', $email_address ); + + if ( ! $user ) { + return array( + 'data' => array(), + 'done' => true, + ); + } + + $data = array(); + + $enabled_providers = get_user_meta( $user->ID, self::ENABLED_PROVIDERS_USER_META_KEY, true ); + if ( $enabled_providers ) { + $data[] = array( + 'name' => __( 'Enabled Two Factor methods', 'two-factor' ), + 'value' => implode( ', ', (array) $enabled_providers ), + ); + } + + $primary_provider = get_user_meta( $user->ID, self::PROVIDER_USER_META_KEY, true ); + if ( $primary_provider ) { + $data[] = array( + 'name' => __( 'Primary Two Factor method', 'two-factor' ), + 'value' => $primary_provider, + ); + } + + $failed_attempts = get_user_meta( $user->ID, self::USER_FAILED_LOGIN_ATTEMPTS_KEY, true ); + if ( $failed_attempts ) { + $data[] = array( + 'name' => __( 'Failed Two Factor login attempts', 'two-factor' ), + 'value' => $failed_attempts, + ); + } + + $last_failure = get_user_meta( $user->ID, self::USER_RATE_LIMIT_KEY, true ); + if ( $last_failure ) { + $data[] = array( + 'name' => __( 'Last failed Two Factor login', 'two-factor' ), + 'value' => self::format_privacy_timestamp( $last_failure ), + ); + } + + foreach ( self::get_providers() as $provider ) { + $provider_data = $provider->privacy_export_data( $user ); + + if ( ! empty( $provider_data ) ) { + $data = array_merge( $data, $provider_data ); + } + } + + if ( empty( $data ) ) { + return array( + 'data' => array(), + 'done' => true, + ); + } + + return array( + 'data' => array( + array( + 'group_id' => 'two-factor', + 'group_label' => __( 'Two Factor Authentication', 'two-factor' ), + 'group_description' => __( 'Two Factor authentication data for the user.', 'two-factor' ), + 'item_id' => 'two-factor', + 'data' => $data, + ), + ), + 'done' => true, + ); + } + + /** + * Erases the Two Factor data stored for a user. + * + * Only the short-lived records are removed. The authentication credentials + * are kept, because the erasure tool does not delete the user account and + * removing the credentials would leave it protected by a password only. + * + * @since 0.17.0 + * + * @param string $email_address The email address of the user. + * @param int $page The page of data being processed. + * @return array + */ + public static function personal_data_eraser( $email_address, $page = 1 ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Pagination is not needed, all data fits on one page. + $user = get_user_by( 'email', $email_address ); + + if ( ! $user ) { + return array( + 'items_removed' => false, + 'items_retained' => false, + 'messages' => array(), + 'done' => true, + ); + } + + $meta_keys = array( + self::USER_META_NONCE_KEY, + self::USER_RATE_LIMIT_KEY, + self::USER_FAILED_LOGIN_ATTEMPTS_KEY, + self::USER_PASSWORD_WAS_RESET_KEY, + ); + + $retained_keys = array( + self::PROVIDER_USER_META_KEY, + self::ENABLED_PROVIDERS_USER_META_KEY, + ); + + foreach ( self::get_providers() as $provider ) { + $eraser_keys = $provider::privacy_eraser_user_meta_keys(); + + $meta_keys = array_merge( $meta_keys, $eraser_keys ); + + // Credentials the provider keeps are disclosed as retained. + $retained_keys = array_merge( + $retained_keys, + array_diff( $provider::uninstall_user_meta_keys(), $eraser_keys ) + ); + } + + $items_removed = false; + foreach ( array_unique( $meta_keys ) as $meta_key ) { + if ( delete_user_meta( $user->ID, $meta_key ) ) { + $items_removed = true; + } + } + + $items_retained = false; + foreach ( array_unique( $retained_keys ) as $meta_key ) { + if ( get_user_meta( $user->ID, $meta_key, true ) ) { + $items_retained = true; + break; + } + } + + $messages = array(); + if ( $items_retained ) { + $messages[] = __( 'Two Factor authentication credentials were retained because erasing them would remove the second factor from an account that still exists. They are removed when the user account is deleted.', 'two-factor' ); + } + + return array( + 'items_removed' => $items_removed, + 'items_retained' => $items_retained, + 'messages' => $messages, + 'done' => true, + ); + } + + /** + * Formats a timestamp for the export and erasure reports. + * + * @since 0.17.0 + * + * @param int|string $timestamp Unix timestamp to format. + * @return string Formatted date and time, or an empty string when no timestamp is set. + */ + public static function format_privacy_timestamp( $timestamp ) { + if ( empty( $timestamp ) ) { + return ''; + } + + return wp_date( + get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), + (int) $timestamp + ); + } } diff --git a/providers/class-two-factor-backup-codes.php b/providers/class-two-factor-backup-codes.php index ec958a3d..9ed95068 100644 --- a/providers/class-two-factor-backup-codes.php +++ b/providers/class-two-factor-backup-codes.php @@ -533,4 +533,33 @@ public static function uninstall_user_meta_keys() { self::BACKUP_CODES_META_KEY, ); } + + /** + * Return the personal data stored for a user for the exporter. + * + * The codes and their hashes are never included, only how many are left. + * + * @since 0.17.0 + * + * @param WP_User $user WP_User object of the user. + * @return array + */ + public function privacy_export_data( $user ) { + $remaining = self::codes_remaining_for_user( $user ); + + if ( ! $remaining ) { + return array(); + } + + return array( + array( + 'name' => __( 'Recovery codes', 'two-factor' ), + 'value' => sprintf( + /* translators: %d: number of unused codes */ + _n( '%d unused code', '%d unused codes', $remaining, 'two-factor' ), + $remaining + ), + ), + ); + } } diff --git a/providers/class-two-factor-email.php b/providers/class-two-factor-email.php index aafb7a0d..9db9bbdb 100644 --- a/providers/class-two-factor-email.php +++ b/providers/class-two-factor-email.php @@ -472,4 +472,51 @@ public static function uninstall_user_meta_keys() { self::TOKEN_META_KEY_TIMESTAMP, ); } + + /** + * Return the user meta keys that the personal data eraser should delete. + * + * Both keys hold short-lived data about a pending code. + * + * @since 0.17.0 + * + * @return array + */ + public static function privacy_eraser_user_meta_keys() { + return array( + self::TOKEN_META_KEY, + self::TOKEN_META_KEY_TIMESTAMP, + ); + } + + /** + * Return the personal data stored for a user for the exporter. + * + * The hashed token is never included, only when the code was sent. + * The timestamp outlives the token after the code is consumed, so it + * is reported on its own. + * + * @since 0.17.0 + * + * @param WP_User $user WP_User object of the user. + * @return array + */ + public function privacy_export_data( $user ) { + $timestamp = (int) get_user_meta( $user->ID, self::TOKEN_META_KEY_TIMESTAMP, true ); + + if ( ! $timestamp ) { + return array(); + } + + return array( + array( + 'name' => __( 'Email login code', 'two-factor' ), + 'value' => sprintf( + /* translators: %s: date and time */ + __( 'A code was sent on %s.', 'two-factor' ), + Two_Factor_Core::format_privacy_timestamp( $timestamp ) + ), + ), + ); + } } diff --git a/providers/class-two-factor-provider.php b/providers/class-two-factor-provider.php index 5659309b..13a2c7da 100644 --- a/providers/class-two-factor-provider.php +++ b/providers/class-two-factor-provider.php @@ -211,4 +211,37 @@ public static function uninstall_user_meta_keys() { public static function uninstall_options() { return array(); } + + /** + * Return the user meta keys that the personal data eraser should delete. + * + * Only keys holding short-lived data belong here. Keys holding credentials + * are kept, because the erasure tool does not delete the user account and + * removing them would leave the account protected by a password only. + * + * @since 0.17.0 + * + * Note: this method doesn't have access to the instantiated provider object. + * + * @return array + */ + public static function privacy_eraser_user_meta_keys() { + return array(); + } + + /** + * Return the personal data that the provider stores for a user. + * + * Returns name-value pairs for the personal data exporter. Secrets and + * hashes must not be included, describe the credential instead so that + * the export file stays safe to share. + * + * @since 0.17.0 + * + * @param WP_User $user WP_User object of the user. + * @return array + */ + public function privacy_export_data( $user ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- Base implementation keeps the provider interface signature but does not use the user. + return array(); + } } diff --git a/providers/class-two-factor-totp.php b/providers/class-two-factor-totp.php index 55c602fc..646d3bf1 100644 --- a/providers/class-two-factor-totp.php +++ b/providers/class-two-factor-totp.php @@ -902,4 +902,53 @@ public static function uninstall_user_meta_keys() { self::LAST_SUCCESSFUL_LOGIN_META_KEY, ); } + + /** + * Return the user meta keys that the personal data eraser should delete. + * + * The secret key is kept because erasing it would disable the provider + * on an account that still exists. + * + * @since 0.17.0 + * + * @return array + */ + public static function privacy_eraser_user_meta_keys() { + return array( + self::LAST_SUCCESSFUL_LOGIN_META_KEY, + ); + } + + /** + * Return the personal data stored for a user for the exporter. + * + * The secret key is never included, the credential is only described. + * + * @since 0.17.0 + * + * @param WP_User $user WP_User object of the user. + * @return array + */ + public function privacy_export_data( $user ) { + $data = array(); + + if ( ! $this->get_user_totp_key( $user->ID ) ) { + return $data; + } + + $data[] = array( + 'name' => __( 'Authenticator app (TOTP)', 'two-factor' ), + 'value' => __( 'Configured', 'two-factor' ), + ); + + $last_login = (int) get_user_meta( $user->ID, self::LAST_SUCCESSFUL_LOGIN_META_KEY, true ); + if ( $last_login ) { + $data[] = array( + 'name' => __( 'Last successful login', 'two-factor' ), + 'value' => Two_Factor_Core::format_privacy_timestamp( $last_login ), + ); + } + + return $data; + } } diff --git a/tests/class-two-factor-core.php b/tests/class-two-factor-core.php index b44046c6..68035db2 100644 --- a/tests/class-two-factor-core.php +++ b/tests/class-two-factor-core.php @@ -3351,4 +3351,291 @@ public function test_add_settings_action_link() { $this->assertStringContainsString( 'Settings', $first ); $this->assertStringContainsString( 'options-general.php', $first ); } + + /** + * Create a user with every Two Factor record in place. + * + * Returns the user along with the sensitive strings that must never + * show up in an export. + * + * @return array + */ + private function get_fully_configured_user() { + $user = self::factory()->user->create_and_get(); + + update_user_meta( $user->ID, Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY, array( 'Two_Factor_Totp', 'Two_Factor_Email', 'Two_Factor_Backup_Codes' ) ); + update_user_meta( $user->ID, Two_Factor_Core::PROVIDER_USER_META_KEY, 'Two_Factor_Totp' ); + update_user_meta( $user->ID, Two_Factor_Core::USER_FAILED_LOGIN_ATTEMPTS_KEY, 3 ); + update_user_meta( $user->ID, Two_Factor_Core::USER_RATE_LIMIT_KEY, time() - 100 ); + update_user_meta( $user->ID, Two_Factor_Core::USER_META_NONCE_KEY, 'login-nonce' ); + update_user_meta( $user->ID, Two_Factor_Core::USER_PASSWORD_WAS_RESET_KEY, true ); + + $totp = Two_Factor_Totp::get_instance(); + $totp->set_user_totp_key( $user->ID, Two_Factor_Totp::generate_key() ); + update_user_meta( $user->ID, Two_Factor_Totp::LAST_SUCCESSFUL_LOGIN_META_KEY, time() - 50 ); + + $email = Two_Factor_Email::get_instance(); + $token = $email->generate_token( $user->ID ); + $token_hashed = get_user_meta( $user->ID, Two_Factor_Email::TOKEN_META_KEY, true ); + + $codes = Two_Factor_Backup_Codes::get_instance()->generate_codes( $user ); + $codes_hashed = (array) get_user_meta( $user->ID, Two_Factor_Backup_Codes::BACKUP_CODES_META_KEY, true ); + + $sensitive = array_merge( + array( $totp->get_user_totp_key( $user->ID ), $token, $token_hashed ), + $codes, + $codes_hashed + ); + + return array( + 'user' => $user, + 'sensitive' => array_filter( $sensitive ), + ); + } + + /** + * Verify the personal data exporter and eraser are registered. + * + * @covers Two_Factor_Core::add_hooks + */ + public function test_add_hooks_privacy_filters() { + Two_Factor_Core::add_hooks( new Two_Factor_Compat() ); + + $this->assertGreaterThan( + 0, + has_filter( + 'wp_privacy_personal_data_exporters', + array( 'Two_Factor_Core', 'register_personal_data_exporter' ) + ) + ); + $this->assertGreaterThan( + 0, + has_filter( + 'wp_privacy_personal_data_erasers', + array( 'Two_Factor_Core', 'register_personal_data_eraser' ) + ) + ); + } + + /** + * Verify the exporter registration adds the plugin entry. + * + * @covers Two_Factor_Core::register_personal_data_exporter + */ + public function test_register_personal_data_exporter() { + $exporters = Two_Factor_Core::register_personal_data_exporter( array() ); + + $this->assertArrayHasKey( 'two-factor', $exporters ); + $this->assertSame( array( 'Two_Factor_Core', 'personal_data_exporter' ), $exporters['two-factor']['callback'] ); + $this->assertNotEmpty( $exporters['two-factor']['exporter_friendly_name'] ); + } + + /** + * Verify the eraser registration adds the plugin entry. + * + * @covers Two_Factor_Core::register_personal_data_eraser + */ + public function test_register_personal_data_eraser() { + $erasers = Two_Factor_Core::register_personal_data_eraser( array() ); + + $this->assertArrayHasKey( 'two-factor', $erasers ); + $this->assertSame( array( 'Two_Factor_Core', 'personal_data_eraser' ), $erasers['two-factor']['callback'] ); + $this->assertNotEmpty( $erasers['two-factor']['eraser_friendly_name'] ); + } + + /** + * Verify the exporter returns nothing for an unknown email address. + * + * @covers Two_Factor_Core::personal_data_exporter + */ + public function test_personal_data_exporter_unknown_email() { + $response = Two_Factor_Core::personal_data_exporter( 'nobody@example.com' ); + + $this->assertSame( array(), $response['data'] ); + $this->assertTrue( $response['done'] ); + } + + /** + * Verify the exporter returns nothing for a user without Two Factor data. + * + * @covers Two_Factor_Core::personal_data_exporter + */ + public function test_personal_data_exporter_user_without_two_factor() { + $user = self::factory()->user->create_and_get(); + $response = Two_Factor_Core::personal_data_exporter( $user->user_email ); + + $this->assertSame( array(), $response['data'] ); + $this->assertTrue( $response['done'] ); + } + + /** + * Verify the exporter includes the records kept by the core. + * + * @covers Two_Factor_Core::personal_data_exporter + */ + public function test_personal_data_exporter_includes_core_records() { + $setup = $this->get_fully_configured_user(); + $user = $setup['user']; + $response = Two_Factor_Core::personal_data_exporter( $user->user_email ); + + $this->assertTrue( $response['done'] ); + $this->assertSame( 'two-factor', $response['data'][0]['group_id'] ); + + $items = $response['data'][0]['data']; + $names = wp_list_pluck( $items, 'name' ); + + $this->assertContains( 'Enabled Two Factor methods', $names ); + $this->assertContains( 'Primary Two Factor method', $names ); + $this->assertContains( 'Failed Two Factor login attempts', $names ); + $this->assertContains( 'Last failed Two Factor login', $names ); + + $values = array_combine( $names, wp_list_pluck( $items, 'value' ) ); + $this->assertSame( 'Two_Factor_Totp, Two_Factor_Email, Two_Factor_Backup_Codes', $values['Enabled Two Factor methods'] ); + $this->assertSame( 'Two_Factor_Totp', $values['Primary Two Factor method'] ); + $this->assertEquals( 3, $values['Failed Two Factor login attempts'] ); + $this->assertNotEmpty( $values['Last failed Two Factor login'] ); + } + + /** + * Verify the exporter includes the records kept by the providers. + * + * @covers Two_Factor_Core::personal_data_exporter + */ + public function test_personal_data_exporter_includes_provider_records() { + $setup = $this->get_fully_configured_user(); + $user = $setup['user']; + $response = Two_Factor_Core::personal_data_exporter( $user->user_email ); + + $items = $response['data'][0]['data']; + $names = wp_list_pluck( $items, 'name' ); + + $this->assertContains( 'Authenticator app (TOTP)', $names ); + $this->assertContains( 'Last successful login', $names ); + $this->assertContains( 'Email login code', $names ); + $this->assertContains( 'Recovery codes', $names ); + + $values = array_combine( $names, wp_list_pluck( $items, 'value' ) ); + $this->assertSame( 'Configured', $values['Authenticator app (TOTP)'] ); + $this->assertNotEmpty( $values['Last successful login'] ); + $this->assertStringContainsString( 'was sent on', $values['Email login code'] ); + $this->assertStringContainsString( '10 unused codes', $values['Recovery codes'] ); + } + + /** + * Verify the export contains no secret, code or hash. + * + * @covers Two_Factor_Core::personal_data_exporter + */ + public function test_personal_data_exporter_does_not_leak_secrets() { + $setup = $this->get_fully_configured_user(); + $user = $setup['user']; + $response = Two_Factor_Core::personal_data_exporter( $user->user_email ); + + $payload = wp_json_encode( $response ); + + foreach ( $setup['sensitive'] as $secret ) { + $this->assertStringNotContainsString( $secret, $payload ); + } + } + + /** + * Verify the eraser does nothing for an unknown email address. + * + * @covers Two_Factor_Core::personal_data_eraser + */ + public function test_personal_data_eraser_unknown_email() { + $response = Two_Factor_Core::personal_data_eraser( 'nobody@example.com' ); + + $this->assertFalse( $response['items_removed'] ); + $this->assertFalse( $response['items_retained'] ); + $this->assertSame( array(), $response['messages'] ); + $this->assertTrue( $response['done'] ); + } + + /** + * Verify the eraser does nothing for a user without Two Factor data. + * + * @covers Two_Factor_Core::personal_data_eraser + */ + public function test_personal_data_eraser_user_without_two_factor() { + $user = self::factory()->user->create_and_get(); + $response = Two_Factor_Core::personal_data_eraser( $user->user_email ); + + $this->assertFalse( $response['items_removed'] ); + $this->assertFalse( $response['items_retained'] ); + $this->assertTrue( $response['done'] ); + } + + /** + * Verify the eraser removes the short-lived records, including the + * TOTP replay timestamp and the pending email code. + * + * @covers Two_Factor_Core::personal_data_eraser + */ + public function test_personal_data_eraser_removes_short_lived_records() { + $setup = $this->get_fully_configured_user(); + $user = $setup['user']; + $response = Two_Factor_Core::personal_data_eraser( $user->user_email ); + + $this->assertTrue( $response['items_removed'] ); + $this->assertTrue( $response['done'] ); + + $this->assertEmpty( get_user_meta( $user->ID, Two_Factor_Core::USER_META_NONCE_KEY, true ) ); + $this->assertEmpty( get_user_meta( $user->ID, Two_Factor_Core::USER_RATE_LIMIT_KEY, true ) ); + $this->assertEmpty( get_user_meta( $user->ID, Two_Factor_Core::USER_FAILED_LOGIN_ATTEMPTS_KEY, true ) ); + $this->assertEmpty( get_user_meta( $user->ID, Two_Factor_Core::USER_PASSWORD_WAS_RESET_KEY, true ) ); + $this->assertEmpty( get_user_meta( $user->ID, Two_Factor_Totp::LAST_SUCCESSFUL_LOGIN_META_KEY, true ) ); + $this->assertEmpty( get_user_meta( $user->ID, Two_Factor_Email::TOKEN_META_KEY, true ) ); + $this->assertEmpty( get_user_meta( $user->ID, Two_Factor_Email::TOKEN_META_KEY_TIMESTAMP, true ) ); + } + + /** + * Verify the eraser keeps the credentials so the account stays protected. + * + * @covers Two_Factor_Core::personal_data_eraser + */ + public function test_personal_data_eraser_keeps_credentials() { + $setup = $this->get_fully_configured_user(); + $user = $setup['user']; + $response = Two_Factor_Core::personal_data_eraser( $user->user_email ); + + $this->assertSame( + array( 'Two_Factor_Totp', 'Two_Factor_Email', 'Two_Factor_Backup_Codes' ), + get_user_meta( $user->ID, Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY, true ) + ); + $this->assertSame( 'Two_Factor_Totp', get_user_meta( $user->ID, Two_Factor_Core::PROVIDER_USER_META_KEY, true ) ); + $this->assertNotEmpty( Two_Factor_Totp::get_instance()->get_user_totp_key( $user->ID ) ); + $this->assertNotEmpty( get_user_meta( $user->ID, Two_Factor_Backup_Codes::BACKUP_CODES_META_KEY, true ) ); + } + + /** + * Verify the eraser reports the retained credentials through a message. + * + * @covers Two_Factor_Core::personal_data_eraser + */ + public function test_personal_data_eraser_reports_retained_credentials() { + $setup = $this->get_fully_configured_user(); + $user = $setup['user']; + $response = Two_Factor_Core::personal_data_eraser( $user->user_email ); + + $this->assertTrue( $response['items_retained'] ); + $this->assertNotEmpty( $response['messages'] ); + $this->assertStringContainsString( 'Two Factor', $response['messages'][0] ); + } + + /** + * Verify format_privacy_timestamp() handles empty and set timestamps. + * + * @covers Two_Factor_Core::format_privacy_timestamp + */ + public function test_format_privacy_timestamp() { + $this->assertSame( '', Two_Factor_Core::format_privacy_timestamp( '' ) ); + $this->assertSame( '', Two_Factor_Core::format_privacy_timestamp( 0 ) ); + + $timestamp = time() - 100; + $formatted = Two_Factor_Core::format_privacy_timestamp( $timestamp ); + + $this->assertNotEmpty( $formatted ); + $this->assertStringContainsString( gmdate( 'Y', $timestamp ), $formatted ); + } } diff --git a/tests/providers/class-two-factor-backup-codes.php b/tests/providers/class-two-factor-backup-codes.php index 0d6041ab..a6cbe7e0 100644 --- a/tests/providers/class-two-factor-backup-codes.php +++ b/tests/providers/class-two-factor-backup-codes.php @@ -224,4 +224,40 @@ function () { remove_all_filters( 'two_factor_backup_code_length' ); } + + /** + * Verify privacy_export_data reports nothing for a user without codes. + * + * @covers Two_Factor_Backup_Codes::privacy_export_data + */ + public function test_privacy_export_data_without_codes() { + $user = self::factory()->user->create_and_get(); + + $this->assertSame( array(), $this->provider->privacy_export_data( $user ) ); + } + + /** + * Verify privacy_export_data reports the remaining count, not the codes. + * + * @covers Two_Factor_Backup_Codes::privacy_export_data + */ + public function test_privacy_export_data_with_codes() { + $user = self::factory()->user->create_and_get(); + $codes = $this->provider->generate_codes( $user, array( 'number' => 2 ) ); + $codes_hashed = (array) get_user_meta( $user->ID, Two_Factor_Backup_Codes::BACKUP_CODES_META_KEY, true ); + + $data = $this->provider->privacy_export_data( $user ); + + $this->assertCount( 1, $data ); + $this->assertSame( 'Recovery codes', $data[0]['name'] ); + $this->assertStringContainsString( '2 unused codes', $data[0]['value'] ); + + $payload = wp_json_encode( $data ); + foreach ( $codes as $code ) { + $this->assertStringNotContainsString( $code, $payload ); + } + foreach ( $codes_hashed as $hashed_code ) { + $this->assertStringNotContainsString( $hashed_code, $payload ); + } + } } diff --git a/tests/providers/class-two-factor-email.php b/tests/providers/class-two-factor-email.php index 516103f8..e6e92021 100644 --- a/tests/providers/class-two-factor-email.php +++ b/tests/providers/class-two-factor-email.php @@ -527,4 +527,64 @@ public function test_uninstall_user_meta_keys() { $this->assertContains( Two_Factor_Email::TOKEN_META_KEY, $keys ); $this->assertContains( Two_Factor_Email::TOKEN_META_KEY_TIMESTAMP, $keys ); } + + /** + * Verify privacy_eraser_user_meta_keys returns both short-lived keys. + * + * @covers Two_Factor_Email::privacy_eraser_user_meta_keys + */ + public function test_privacy_eraser_user_meta_keys() { + $this->assertSame( + array( Two_Factor_Email::TOKEN_META_KEY, Two_Factor_Email::TOKEN_META_KEY_TIMESTAMP ), + Two_Factor_Email::privacy_eraser_user_meta_keys() + ); + } + + /** + * Verify privacy_export_data reports nothing for a user without a token. + * + * @covers Two_Factor_Email::privacy_export_data + */ + public function test_privacy_export_data_without_token() { + $user = self::factory()->user->create_and_get(); + + $this->assertSame( array(), $this->provider->privacy_export_data( $user ) ); + } + + /** + * Verify privacy_export_data reports when the code was sent, not the hash. + * + * @covers Two_Factor_Email::privacy_export_data + */ + public function test_privacy_export_data_with_token() { + $user = self::factory()->user->create_and_get(); + $token = $this->provider->generate_token( $user->ID ); + + $data = $this->provider->privacy_export_data( $user ); + + $this->assertCount( 1, $data ); + $this->assertSame( 'Email login code', $data[0]['name'] ); + $this->assertStringContainsString( 'was sent on', $data[0]['value'] ); + + $payload = wp_json_encode( $data ); + $this->assertStringNotContainsString( $token, $payload ); + $this->assertStringNotContainsString( wp_hash( $token ), $payload ); + } + + /** + * Verify privacy_export_data still reports the send time after the + * token was consumed, because the timestamp meta stays stored. + * + * @covers Two_Factor_Email::privacy_export_data + */ + public function test_privacy_export_data_after_token_deleted() { + $user = self::factory()->user->create_and_get(); + $this->provider->generate_token( $user->ID ); + $this->provider->delete_token( $user->ID ); + + $data = $this->provider->privacy_export_data( $user ); + + $this->assertCount( 1, $data ); + $this->assertStringContainsString( 'was sent on', $data[0]['value'] ); + } } diff --git a/tests/providers/class-two-factor-provider.php b/tests/providers/class-two-factor-provider.php index 86a4bee3..6ab38a63 100644 --- a/tests/providers/class-two-factor-provider.php +++ b/tests/providers/class-two-factor-provider.php @@ -175,4 +175,25 @@ public function test_uninstall_user_meta_keys_base_returns_empty() { public function test_uninstall_options_base_returns_empty() { $this->assertSame( array(), Two_Factor_Dummy::uninstall_options() ); } + + /** + * Verify the base privacy_eraser_user_meta_keys() returns an empty array. + * + * @covers Two_Factor_Provider::privacy_eraser_user_meta_keys + */ + public function test_privacy_eraser_user_meta_keys_base_returns_empty() { + $this->assertSame( array(), Two_Factor_Dummy::privacy_eraser_user_meta_keys() ); + } + + /** + * Verify the base privacy_export_data() returns an empty array. + * + * @covers Two_Factor_Provider::privacy_export_data + */ + public function test_privacy_export_data_base_returns_empty() { + $provider = Two_Factor_Dummy::get_instance(); + $user = self::factory()->user->create_and_get(); + + $this->assertSame( array(), $provider->privacy_export_data( $user ) ); + } } diff --git a/tests/providers/class-two-factor-totp.php b/tests/providers/class-two-factor-totp.php index b4f674a4..93c03957 100644 --- a/tests/providers/class-two-factor-totp.php +++ b/tests/providers/class-two-factor-totp.php @@ -525,4 +525,56 @@ public function test_pad_secret_zero_length_throws_exception() { $this->expectException( InvalidArgumentException::class ); $this->call_pad_secret( 'ABC', 0 ); } + + /** + * Verify privacy_eraser_user_meta_keys() returns only the replay timestamp. + * + * The secret key is kept so the provider stays configured. + * + * @covers Two_Factor_Totp::privacy_eraser_user_meta_keys + */ + public function test_privacy_eraser_user_meta_keys() { + $this->assertSame( + array( Two_Factor_Totp::LAST_SUCCESSFUL_LOGIN_META_KEY ), + Two_Factor_Totp::privacy_eraser_user_meta_keys() + ); + } + + /** + * Verify privacy_export_data() reports nothing for a user without a key. + * + * @covers Two_Factor_Totp::privacy_export_data + */ + public function test_privacy_export_data_without_key() { + $user = self::factory()->user->create_and_get(); + $provider = Two_Factor_Totp::get_instance(); + + $this->assertSame( array(), $provider->privacy_export_data( $user ) ); + } + + /** + * Verify privacy_export_data() reports configuration and the last login + * date, but not the secret. + * + * @covers Two_Factor_Totp::privacy_export_data + */ + public function test_privacy_export_data_with_key() { + $user = self::factory()->user->create_and_get(); + $provider = Two_Factor_Totp::get_instance(); + + $key = Two_Factor_Totp::generate_key(); + $provider->set_user_totp_key( $user->ID, $key ); + update_user_meta( $user->ID, Two_Factor_Totp::LAST_SUCCESSFUL_LOGIN_META_KEY, time() - 50 ); + + $data = $provider->privacy_export_data( $user ); + + $this->assertCount( 2, $data ); + $this->assertSame( 'Authenticator app (TOTP)', $data[0]['name'] ); + $this->assertSame( 'Configured', $data[0]['value'] ); + $this->assertSame( 'Last successful login', $data[1]['name'] ); + $this->assertNotEmpty( $data[1]['value'] ); + + $payload = wp_json_encode( $data ); + $this->assertStringNotContainsString( $key, $payload ); + } } From 18ff905cf2bd38e82a1a9ff22db8a3e345cd4fea Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Mon, 28 Sep 2026 15:10:11 +0600 Subject: [PATCH 2/2] Address privacy export and erasure feedback --- class-two-factor-core.php | 51 ++++++++++++++++++--- providers/class-two-factor-backup-codes.php | 2 +- providers/class-two-factor-email.php | 4 +- providers/class-two-factor-provider.php | 4 +- providers/class-two-factor-totp.php | 15 +++--- tests/class-two-factor-core.php | 45 +++++++++++++++++- tests/providers/class-two-factor-totp.php | 8 ++-- 7 files changed, 105 insertions(+), 24 deletions(-) diff --git a/class-two-factor-core.php b/class-two-factor-core.php index 332570ce..1ed023bc 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -2972,7 +2972,7 @@ public static function add_privacy_policy_content() { /** * Registers the personal data exporter. * - * @since 0.17.0 + * @since 0.18.0 * * @param array $exporters List of personal data exporters. * @return array @@ -2989,7 +2989,7 @@ public static function register_personal_data_exporter( $exporters ) { /** * Registers the personal data eraser. * - * @since 0.17.0 + * @since 0.18.0 * * @param array $erasers List of personal data erasers. * @return array @@ -3010,7 +3010,7 @@ public static function register_personal_data_eraser( $erasers ) { * codes and the email token hash stay out of the export file so that it * remains safe to share. * - * @since 0.17.0 + * @since 0.18.0 * * @param string $email_address The email address of the user. * @param int $page The page of data being requested. @@ -3060,7 +3060,7 @@ public static function personal_data_exporter( $email_address, $page = 1 ) { // ); } - foreach ( self::get_providers() as $provider ) { + foreach ( self::get_privacy_providers() as $provider ) { $provider_data = $provider->privacy_export_data( $user ); if ( ! empty( $provider_data ) ) { @@ -3096,7 +3096,7 @@ public static function personal_data_exporter( $email_address, $page = 1 ) { // * are kept, because the erasure tool does not delete the user account and * removing the credentials would leave it protected by a password only. * - * @since 0.17.0 + * @since 0.18.0 * * @param string $email_address The email address of the user. * @param int $page The page of data being processed. @@ -3126,7 +3126,7 @@ public static function personal_data_eraser( $email_address, $page = 1 ) { // ph self::ENABLED_PROVIDERS_USER_META_KEY, ); - foreach ( self::get_providers() as $provider ) { + foreach ( self::get_privacy_providers() as $provider ) { $eraser_keys = $provider::privacy_eraser_user_meta_keys(); $meta_keys = array_merge( $meta_keys, $eraser_keys ); @@ -3166,10 +3166,47 @@ public static function personal_data_eraser( $email_address, $page = 1 ) { // ph ); } + /** + * Get all registered providers for the personal data exporter and eraser. + * + * Same as get_providers(), but providers that are disabled in the + * site-wide settings are not removed from the list. Data stored for a + * user must be exported and erased even while its provider is disabled. + * + * @since 0.18.0 + * + * @return Two_Factor_Provider[] List of provider instances indexed by provider key. + */ + private static function get_privacy_providers() { + $providers = self::get_default_providers(); + + /** This filter is documented in the get_providers() method */ + $additional_providers = apply_filters( 'two_factor_providers', $providers ); + + // Merge them with the default providers so that providers removed + // by the site-wide setting are still included. + if ( ! empty( $additional_providers ) ) { + $providers = array_merge( $providers, $additional_providers ); + } + + // Map provider keys to classes so that we can instantiate them. + $providers = self::get_providers_classes( $providers ); + + foreach ( $providers as $provider_key => $provider_class ) { + try { + $providers[ $provider_key ] = call_user_func( array( $provider_class, 'get_instance' ) ); + } catch ( Exception $e ) { + unset( $providers[ $provider_key ] ); + } + } + + return $providers; + } + /** * Formats a timestamp for the export and erasure reports. * - * @since 0.17.0 + * @since 0.18.0 * * @param int|string $timestamp Unix timestamp to format. * @return string Formatted date and time, or an empty string when no timestamp is set. diff --git a/providers/class-two-factor-backup-codes.php b/providers/class-two-factor-backup-codes.php index 9ed95068..3e594704 100644 --- a/providers/class-two-factor-backup-codes.php +++ b/providers/class-two-factor-backup-codes.php @@ -539,7 +539,7 @@ public static function uninstall_user_meta_keys() { * * The codes and their hashes are never included, only how many are left. * - * @since 0.17.0 + * @since 0.18.0 * * @param WP_User $user WP_User object of the user. * @return array diff --git a/providers/class-two-factor-email.php b/providers/class-two-factor-email.php index 9db9bbdb..945b04e6 100644 --- a/providers/class-two-factor-email.php +++ b/providers/class-two-factor-email.php @@ -478,7 +478,7 @@ public static function uninstall_user_meta_keys() { * * Both keys hold short-lived data about a pending code. * - * @since 0.17.0 + * @since 0.18.0 * * @return array */ @@ -496,7 +496,7 @@ public static function privacy_eraser_user_meta_keys() { * The timestamp outlives the token after the code is consumed, so it * is reported on its own. * - * @since 0.17.0 + * @since 0.18.0 * * @param WP_User $user WP_User object of the user. * @return array diff --git a/providers/class-two-factor-provider.php b/providers/class-two-factor-provider.php index 13a2c7da..4dbb1801 100644 --- a/providers/class-two-factor-provider.php +++ b/providers/class-two-factor-provider.php @@ -219,7 +219,7 @@ public static function uninstall_options() { * are kept, because the erasure tool does not delete the user account and * removing them would leave the account protected by a password only. * - * @since 0.17.0 + * @since 0.18.0 * * Note: this method doesn't have access to the instantiated provider object. * @@ -236,7 +236,7 @@ public static function privacy_eraser_user_meta_keys() { * hashes must not be included, describe the credential instead so that * the export file stays safe to share. * - * @since 0.17.0 + * @since 0.18.0 * * @param WP_User $user WP_User object of the user. * @return array diff --git a/providers/class-two-factor-totp.php b/providers/class-two-factor-totp.php index 646d3bf1..0dfe15db 100644 --- a/providers/class-two-factor-totp.php +++ b/providers/class-two-factor-totp.php @@ -906,17 +906,18 @@ public static function uninstall_user_meta_keys() { /** * Return the user meta keys that the personal data eraser should delete. * - * The secret key is kept because erasing it would disable the provider - * on an account that still exists. + * Nothing is erased. The secret key is kept because erasing it would + * disable the provider on an account that still exists. The last + * successful login timestamp is kept with it because it still blocks + * reuse of the most recent code, so erasing it would briefly weaken + * replay protection. Both are reported as retained by the eraser. * - * @since 0.17.0 + * @since 0.18.0 * * @return array */ public static function privacy_eraser_user_meta_keys() { - return array( - self::LAST_SUCCESSFUL_LOGIN_META_KEY, - ); + return array(); } /** @@ -924,7 +925,7 @@ public static function privacy_eraser_user_meta_keys() { * * The secret key is never included, the credential is only described. * - * @since 0.17.0 + * @since 0.18.0 * * @param WP_User $user WP_User object of the user. * @return array diff --git a/tests/class-two-factor-core.php b/tests/class-two-factor-core.php index 68035db2..69e32c28 100644 --- a/tests/class-two-factor-core.php +++ b/tests/class-two-factor-core.php @@ -3538,6 +3538,26 @@ public function test_personal_data_exporter_does_not_leak_secrets() { } } + /** + * Verify the exporter includes providers that are disabled in the + * site-wide settings. + * + * @covers Two_Factor_Core::personal_data_exporter + */ + public function test_personal_data_exporter_includes_site_disabled_providers() { + update_option( Two_Factor_Core::ENABLED_PROVIDERS_OPTION_KEY, array( 'Two_Factor_Email' ) ); + + $setup = $this->get_fully_configured_user(); + $user = $setup['user']; + $response = Two_Factor_Core::personal_data_exporter( $user->user_email ); + + $items = $response['data'][0]['data']; + $names = wp_list_pluck( $items, 'name' ); + + $this->assertContains( 'Authenticator app (TOTP)', $names ); + $this->assertContains( 'Recovery codes', $names ); + } + /** * Verify the eraser does nothing for an unknown email address. * @@ -3568,7 +3588,8 @@ public function test_personal_data_eraser_user_without_two_factor() { /** * Verify the eraser removes the short-lived records, including the - * TOTP replay timestamp and the pending email code. + * pending email code. The TOTP replay timestamp is not removed, it + * still blocks reuse of the most recent code. * * @covers Two_Factor_Core::personal_data_eraser */ @@ -3584,9 +3605,11 @@ public function test_personal_data_eraser_removes_short_lived_records() { $this->assertEmpty( get_user_meta( $user->ID, Two_Factor_Core::USER_RATE_LIMIT_KEY, true ) ); $this->assertEmpty( get_user_meta( $user->ID, Two_Factor_Core::USER_FAILED_LOGIN_ATTEMPTS_KEY, true ) ); $this->assertEmpty( get_user_meta( $user->ID, Two_Factor_Core::USER_PASSWORD_WAS_RESET_KEY, true ) ); - $this->assertEmpty( get_user_meta( $user->ID, Two_Factor_Totp::LAST_SUCCESSFUL_LOGIN_META_KEY, true ) ); $this->assertEmpty( get_user_meta( $user->ID, Two_Factor_Email::TOKEN_META_KEY, true ) ); $this->assertEmpty( get_user_meta( $user->ID, Two_Factor_Email::TOKEN_META_KEY_TIMESTAMP, true ) ); + + // The TOTP replay timestamp is retained to keep blocking code reuse. + $this->assertNotEmpty( get_user_meta( $user->ID, Two_Factor_Totp::LAST_SUCCESSFUL_LOGIN_META_KEY, true ) ); } /** @@ -3605,6 +3628,7 @@ public function test_personal_data_eraser_keeps_credentials() { ); $this->assertSame( 'Two_Factor_Totp', get_user_meta( $user->ID, Two_Factor_Core::PROVIDER_USER_META_KEY, true ) ); $this->assertNotEmpty( Two_Factor_Totp::get_instance()->get_user_totp_key( $user->ID ) ); + $this->assertNotEmpty( get_user_meta( $user->ID, Two_Factor_Totp::LAST_SUCCESSFUL_LOGIN_META_KEY, true ) ); $this->assertNotEmpty( get_user_meta( $user->ID, Two_Factor_Backup_Codes::BACKUP_CODES_META_KEY, true ) ); } @@ -3623,6 +3647,23 @@ public function test_personal_data_eraser_reports_retained_credentials() { $this->assertStringContainsString( 'Two Factor', $response['messages'][0] ); } + /** + * Verify the eraser processes the keys of providers that are disabled + * in the site-wide settings. + * + * @covers Two_Factor_Core::personal_data_eraser + */ + public function test_personal_data_eraser_covers_site_disabled_providers() { + update_option( Two_Factor_Core::ENABLED_PROVIDERS_OPTION_KEY, array( 'Two_Factor_Backup_Codes' ) ); + + $setup = $this->get_fully_configured_user(); + $user = $setup['user']; + $response = Two_Factor_Core::personal_data_eraser( $user->user_email ); + + $this->assertTrue( $response['items_removed'] ); + $this->assertEmpty( get_user_meta( $user->ID, Two_Factor_Email::TOKEN_META_KEY, true ) ); + } + /** * Verify format_privacy_timestamp() handles empty and set timestamps. * diff --git a/tests/providers/class-two-factor-totp.php b/tests/providers/class-two-factor-totp.php index 93c03957..0ea87b08 100644 --- a/tests/providers/class-two-factor-totp.php +++ b/tests/providers/class-two-factor-totp.php @@ -527,15 +527,17 @@ public function test_pad_secret_zero_length_throws_exception() { } /** - * Verify privacy_eraser_user_meta_keys() returns only the replay timestamp. + * Verify privacy_eraser_user_meta_keys() erases nothing. * - * The secret key is kept so the provider stays configured. + * The secret is kept so the provider stays configured, and the last + * successful login timestamp is kept with it because it still blocks + * reuse of the most recent code after erasure. * * @covers Two_Factor_Totp::privacy_eraser_user_meta_keys */ public function test_privacy_eraser_user_meta_keys() { $this->assertSame( - array( Two_Factor_Totp::LAST_SUCCESSFUL_LOGIN_META_KEY ), + array(), Two_Factor_Totp::privacy_eraser_user_meta_keys() ); }