diff --git a/TESTS.md b/TESTS.md index 924c8713..8b0ae7a8 100644 --- a/TESTS.md +++ b/TESTS.md @@ -75,6 +75,18 @@ Tests the abstract `Two_Factor_Provider` base class: - `is_supported_for_user` (globally registered vs. not) - Default implementations of `get_alternative_provider_label`, `pre_process_authentication`, `uninstall_user_meta_keys`, `uninstall_options` +### Provider Settings REST API — `tests/class-two-factor-rest-api.php` + +**Class:** `Tests_Two_Factor_REST_API` · **Groups:** `core`, `rest-api` +Tests the authenticated provider settings endpoints: + +- Self and administrator permission boundaries +- Provider status and TOTP secret redaction +- Email and recovery-code management +- Configured, enabled, and primary-provider invariants +- Recent revalidation responses and targets +- Session invalidation for self-service and administrator changes + ### TOTP Provider — `tests/providers/class-two-factor-totp.php` **Class:** `Tests_Two_Factor_Totp` · **Groups:** `providers`, `totp` @@ -95,6 +107,7 @@ Extends `WP_Test_REST_TestCase`. Tests the TOTP REST endpoints: - Setting a TOTP key with a valid/invalid/missing auth code - Updating an existing TOTP key +- Server-owned enrollment creation, confirmation, replacement, expiry, and replay prevention - Deleting own secret - Admin deleting another user's secret - Non-admin cannot delete another user's secret diff --git a/class-two-factor-core.php b/class-two-factor-core.php index 7c0ad9a0..c49b6d4f 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -144,6 +144,7 @@ public static function add_hooks( $compat ) { add_filter( 'attach_session_information', array( __CLASS__, 'filter_session_information' ), 10, 2 ); add_action( 'login_enqueue_scripts', array( __CLASS__, 'login_enqueue_scripts' ), 5 ); + add_action( 'rest_api_init', array( __CLASS__, 'register_rest_routes' ) ); add_action( 'admin_init', array( __CLASS__, 'trigger_user_settings_action' ) ); add_filter( 'two_factor_providers', array( __CLASS__, 'enable_dummy_method_for_debug' ) ); @@ -1548,6 +1549,29 @@ public static function current_user_can_update_two_factor_options( $context = 'd return false; } + /** + * Validate that the current user can edit an existing user. + * + * Capability is checked before existence to avoid exposing user IDs to + * unauthorized callers. + * + * @since NEXT + * @param int $user_id The user ID being accessed. + * + * @return bool|\WP_Error + */ + public static function rest_api_can_edit_user( $user_id ) { + if ( ! current_user_can( 'edit_user', $user_id ) ) { + return false; + } + + if ( ! self::fetch_user( $user_id ) ) { + return new WP_Error( 'rest_user_invalid_id', __( 'Invalid user ID.', 'two-factor' ), array( 'status' => 404 ) ); + } + + return true; + } + /** * Validate that the current user can edit the specified user. If two-factor is required by the account, also verify that it's within the revalidation grace period. * @@ -1557,12 +1581,20 @@ public static function current_user_can_update_two_factor_options( $context = 'd * @return bool|\WP_Error */ public static function rest_api_can_edit_user_and_update_two_factor_options( $user_id ) { - if ( ! current_user_can( 'edit_user', $user_id ) ) { - return false; + $can_edit = self::rest_api_can_edit_user( $user_id ); + if ( true !== $can_edit ) { + return $can_edit; } if ( ! self::current_user_can_update_two_factor_options( 'save' ) ) { - return new WP_Error( 'revalidation_required', __( 'Two Factor Revalidation required.', 'two-factor' ) ); + return new WP_Error( + 'revalidation_required', + __( 'Two Factor Revalidation required.', 'two-factor' ), + array( + 'status' => 403, + 'revalidation' => self::get_rest_revalidation(), + ) + ); } /** @@ -1576,6 +1608,143 @@ public static function rest_api_can_edit_user_and_update_two_factor_options( $us return apply_filters( 'two_factor_rest_api_can_edit_user', true, $user_id ); } + /** + * Register REST routes for managing a user's providers. + * + * @since NEXT + * + * @return void + */ + public static function register_rest_routes() { + register_rest_route( + self::REST_NAMESPACE, + '/users/(?P[\d]+)/providers', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( __CLASS__, 'rest_get_user_provider_settings' ), + 'permission_callback' => function ( $request ) { + return self::rest_api_can_edit_user( $request['user_id'] ); + }, + 'args' => array( + 'user_id' => array( + 'required' => true, + 'type' => 'integer', + ), + ), + ), + array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( __CLASS__, 'rest_update_user_provider_settings' ), + 'permission_callback' => function ( $request ) { + return self::rest_api_can_edit_user_and_update_two_factor_options( $request['user_id'] ); + }, + 'args' => array( + 'user_id' => array( + 'required' => true, + 'type' => 'integer', + ), + 'enabled_providers' => array( + 'required' => true, + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + ), + 'primary_provider' => array( + 'required' => true, + 'type' => 'string', + ), + ), + ), + ) + ); + } + + /** + * Return structured information for launching the existing revalidation flow. + * + * @since NEXT + * + * @return array Revalidation state and target. + */ + private static function get_rest_revalidation() { + return array( + 'required' => ! self::current_user_can_update_two_factor_options( 'save' ), + 'action' => 'revalidate_2fa', + 'url' => self::get_user_two_factor_revalidate_url( true ), + ); + } + + /** + * Get provider settings for a user via REST. + * + * @since NEXT + * + * @param WP_REST_Request $request Request object. + * @return array|WP_Error Provider settings or an error. + */ + public static function rest_get_user_provider_settings( $request ) { + $user = self::fetch_user( $request['user_id'] ); + if ( ! $user ) { + return new WP_Error( 'rest_user_invalid_id', __( 'Invalid user ID.', 'two-factor' ), array( 'status' => 404 ) ); + } + + $providers = self::get_providers(); + $supported = self::get_supported_providers_for_user( $user ); + $enabled = self::get_enabled_providers_for_user( $user ); + $primary_provider = self::get_primary_provider_for_user( $user ); + $primary_key = $primary_provider ? $primary_provider->get_key() : ''; + $provider_settings = array(); + + foreach ( $providers as $provider_key => $provider ) { + $is_supported = isset( $supported[ $provider_key ] ); + $remaining = null; + + if ( $provider instanceof Two_Factor_Backup_Codes ) { + $remaining = $provider::codes_remaining_for_user( $user ); + } + + $provider_settings[] = array( + 'key' => $provider_key, + 'label' => $provider->get_label(), + 'supported' => $is_supported, + 'configured' => $is_supported && $provider->is_available_for_user( $user ), + 'enabled' => in_array( $provider_key, $enabled, true ), + 'primary' => $provider_key === $primary_key, + 'remaining' => $remaining, + ); + } + + return array( + 'user_id' => $user->ID, + 'providers' => $provider_settings, + 'revalidation' => self::get_rest_revalidation(), + ); + } + + /** + * Update provider settings for a user via REST. + * + * @since NEXT + * + * @param WP_REST_Request $request Request object. + * @return array|WP_Error Updated settings or an error. + */ + public static function rest_update_user_provider_settings( $request ) { + $result = self::update_user_provider_settings( + $request['user_id'], + $request['enabled_providers'], + $request['primary_provider'] + ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + return self::rest_get_user_provider_settings( $request ); + } + /** * Login form validation handler. * @@ -2424,6 +2593,100 @@ public static function disable_provider_for_user( $user_id, $provider_to_delete return (bool) update_user_meta( $user_id, self::ENABLED_PROVIDERS_USER_META_KEY, $enabled_providers ); } + /** + * Validate and update a user's provider settings. + * + * The caller is responsible for checking permission to edit the user. + * + * @since NEXT + * + * @param int $user_id User ID. + * @param string[] $enabled_providers Provider keys to enable. + * @param string $primary_provider Provider key to use as primary, or an empty string for the default. + * @return true|WP_Error True on success, or an error when the requested settings are invalid. + */ + public static function update_user_provider_settings( $user_id, $enabled_providers, $primary_provider = '' ) { + $user = self::fetch_user( $user_id ); + if ( ! $user ) { + return new WP_Error( 'rest_user_invalid_id', __( 'Invalid user ID.', 'two-factor' ), array( 'status' => 404 ) ); + } + + $supported_providers = self::get_supported_providers_for_user( $user ); + $enabled_providers = array_values( array_unique( $enabled_providers ) ); + + foreach ( $enabled_providers as $provider_key ) { + if ( ! isset( $supported_providers[ $provider_key ] ) ) { + return new WP_Error( 'two_factor_provider_not_supported', __( 'The requested provider is not supported for this user.', 'two-factor' ), array( 'status' => 400 ) ); + } + + if ( ! $supported_providers[ $provider_key ]->is_available_for_user( $user ) ) { + return new WP_Error( 'two_factor_provider_not_configured', __( 'The requested provider must be configured before it can be enabled.', 'two-factor' ), array( 'status' => 400 ) ); + } + } + + if ( $primary_provider && ! in_array( $primary_provider, $enabled_providers, true ) ) { + return new WP_Error( 'two_factor_primary_provider_not_enabled', __( 'The primary provider must be configured and enabled.', 'two-factor' ), array( 'status' => 400 ) ); + } + + self::save_user_provider_settings( $user_id, $enabled_providers, $primary_provider ); + + return true; + } + + /** + * Persist provider settings and apply the existing session invalidation rules. + * + * @since NEXT + * + * @param int $user_id User ID. + * @param string[] $enabled_providers Provider keys to enable. + * @param string $primary_provider Provider key to use as primary. + * @return void + */ + private static function save_user_provider_settings( $user_id, $enabled_providers, $primary_provider ) { + $existing_providers = self::get_enabled_providers_for_user( $user_id ); + + update_user_meta( $user_id, self::ENABLED_PROVIDERS_USER_META_KEY, array_values( $enabled_providers ) ); + + if ( $primary_provider ) { + update_user_meta( $user_id, self::PROVIDER_USER_META_KEY, $primary_provider ); + } else { + delete_user_meta( $user_id, self::PROVIDER_USER_META_KEY ); + } + + // Have we changed the two-factor settings for the current user? Alter their session metadata. + if ( get_current_user_id() === $user_id ) { + if ( $enabled_providers && ! $existing_providers && ! self::is_current_user_session_two_factor() ) { + // No provider was used for this session because two-factor was just enabled. + self::update_current_user_session( + array( + 'two-factor-provider' => '', + 'two-factor-login' => time(), + ) + ); + } elseif ( $existing_providers && ! $enabled_providers ) { + self::update_current_user_session( + array( + 'two-factor-provider' => null, + 'two-factor-login' => null, + ) + ); + } + } + + // Destroy other sessions on initial setup or when removing an active provider. + if ( + ( ! $existing_providers && $enabled_providers ) || + ( $existing_providers && $enabled_providers && array_diff( $existing_providers, $enabled_providers ) ) + ) { + if ( get_current_user_id() === $user_id ) { + wp_destroy_other_sessions(); + } else { + WP_Session_Tokens::get_instance( $user_id )->destroy_all(); + } + } + } + /** * Update the user meta value. * @@ -2446,10 +2709,9 @@ public static function user_two_factor_options_update( $user_id ) { return; } - $user = self::fetch_user( $user_id ); - $providers = self::get_supported_providers_for_user( $user_id ); - $enabled_providers = $_POST[ self::ENABLED_PROVIDERS_USER_META_KEY ]; - $existing_providers = self::get_enabled_providers_for_user( $user_id ); + $user = self::fetch_user( $user_id ); + $providers = self::get_supported_providers_for_user( $user_id ); + $enabled_providers = $_POST[ self::ENABLED_PROVIDERS_USER_META_KEY ]; // Enable only the available providers. $enabled_providers = array_intersect_key( $providers, array_flip( $enabled_providers ) ); @@ -2475,53 +2737,11 @@ public static function user_two_factor_options_update( $user_id ) { } } - update_user_meta( $user_id, self::ENABLED_PROVIDERS_USER_META_KEY, array_keys( $enabled_providers ) ); - // Primary provider must be enabled. $new_provider = isset( $_POST[ self::PROVIDER_USER_META_KEY ] ) ? $_POST[ self::PROVIDER_USER_META_KEY ] : ''; - if ( ! empty( $new_provider ) && isset( $enabled_providers[ $new_provider ] ) ) { - update_user_meta( $user_id, self::PROVIDER_USER_META_KEY, $new_provider ); - } else { - delete_user_meta( $user_id, self::PROVIDER_USER_META_KEY ); - } + $new_provider = ! empty( $new_provider ) && isset( $enabled_providers[ $new_provider ] ) ? $new_provider : ''; - // Have we changed the two-factor settings for the current user? Alter their session metadata. - if ( get_current_user_id() === $user_id ) { - - if ( $enabled_providers && ! $existing_providers && ! self::is_current_user_session_two_factor() ) { - // We've enabled two-factor from a non-two-factor session, set the key but not the provider, as no provider has been used yet. - self::update_current_user_session( - array( - 'two-factor-provider' => '', - 'two-factor-login' => time(), - ) - ); - } elseif ( $existing_providers && ! $enabled_providers ) { - // We've disabled two-factor, remove session metadata. - self::update_current_user_session( - array( - 'two-factor-provider' => null, - 'two-factor-login' => null, - ) - ); - } - } - - // Destroy other sessions if setup 2FA for the first time, or deactivated a provider. - if ( - // No providers, enabling one (or more). - ( ! $existing_providers && $enabled_providers ) || - // Has providers, and is disabling one (or more), but remaining with 2FA. - ( $existing_providers && $enabled_providers && array_diff( $existing_providers, array_keys( $enabled_providers ) ) ) - ) { - if ( get_current_user_id() === $user_id ) { - // Keep the current session, destroy others sessions for this user. - wp_destroy_other_sessions(); - } else { - // Destroy all sessions for the user. - WP_Session_Tokens::get_instance( $user_id )->destroy_all(); - } - } + self::save_user_provider_settings( $user_id, array_keys( $enabled_providers ), $new_provider ); } } @@ -2625,4 +2845,3 @@ public static function filter_session_information( $session, $user_id ) { return $session; } } - diff --git a/providers/class-two-factor-totp.php b/providers/class-two-factor-totp.php index 487ae887..5ca25d63 100644 --- a/providers/class-two-factor-totp.php +++ b/providers/class-two-factor-totp.php @@ -26,6 +26,20 @@ class Two_Factor_Totp extends Two_Factor_Provider { */ const LAST_SUCCESSFUL_LOGIN_META_KEY = '_two_factor_totp_last_successful_login'; + /** + * The user meta key for a pending REST enrollment. + * + * @var string + */ + const PENDING_ENROLLMENT_META_KEY = '_two_factor_totp_pending_enrollment'; + + /** + * Pending enrollments expire after ten minutes. + * + * @var int + */ + const PENDING_ENROLLMENT_TTL = 10 * MINUTE_IN_SECONDS; + const DEFAULT_KEY_BIT_SIZE = 160; const DEFAULT_CRYPTO = 'sha1'; const DEFAULT_DIGIT_COUNT = 6; @@ -139,6 +153,24 @@ public function register_rest_routes() { ), ) ); + + register_rest_route( + Two_Factor_Core::REST_NAMESPACE, + '/totp/enrollment', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'rest_begin_enrollment' ), + 'permission_callback' => function ( $request ) { + return Two_Factor_Core::rest_api_can_edit_user_and_update_two_factor_options( $request['user_id'] ); + }, + 'args' => array( + 'user_id' => array( + 'required' => true, + 'type' => 'integer', + ), + ), + ) + ); } /** @@ -238,11 +270,20 @@ public function rest_setup_totp( $request ) { $key = $request['key']; $code = preg_replace( '/\s+/', '', $request['code'] ); + if ( empty( $key ) ) { + $pending_enrollment = $this->get_pending_enrollment( $user_id ); + if ( is_wp_error( $pending_enrollment ) ) { + return $pending_enrollment; + } + + $key = $pending_enrollment['key']; + } + if ( ! $this->is_valid_key( $key ) ) { return new WP_Error( 'invalid_key', __( 'Invalid Two Factor Authentication secret key.', 'two-factor' ), array( 'status' => 400 ) ); } - if ( ! $this->is_valid_authcode( $key, $code ) ) { + if ( ! preg_match( '/^[0-9]{' . self::DEFAULT_DIGIT_COUNT . '}$/', $code ) || ! $this->is_valid_authcode( $key, $code ) ) { return new WP_Error( 'invalid_key_code', __( 'Invalid Two Factor Authentication code.', 'two-factor' ), array( 'status' => 400 ) ); } @@ -250,6 +291,8 @@ public function rest_setup_totp( $request ) { return new WP_Error( 'db_error', __( 'Unable to save Two Factor Authentication code. Please re-scan the QR code and enter the code provided by your application.', 'two-factor' ), array( 'status' => 500 ) ); } + delete_user_meta( $user_id, self::PENDING_ENROLLMENT_META_KEY ); + if ( $request->get_param( 'enable_provider' ) && ! Two_Factor_Core::enable_provider_for_user( $user_id, 'Two_Factor_Totp' ) ) { return new WP_Error( 'db_error', __( 'Unable to enable TOTP provider for this user.', 'two-factor' ), array( 'status' => 500 ) ); } @@ -264,6 +307,65 @@ public function rest_setup_totp( $request ) { ); } + /** + * Begin a server-owned TOTP enrollment. + * + * A subsequent request replaces any pending enrollment for the user. The + * secret is returned only by this response and must be confirmed via /totp. + * + * @since NEXT + * + * @param WP_REST_Request $request The REST request object. + * @return array|WP_Error Enrollment details or an error. + */ + public function rest_begin_enrollment( $request ) { + $user = get_user_by( 'id', $request['user_id'] ); + if ( ! $user ) { + return new WP_Error( 'rest_user_invalid_id', __( 'Invalid user ID.', 'two-factor' ), array( 'status' => 404 ) ); + } + + $key = $this->generate_key(); + $expires_at = self::time() + self::PENDING_ENROLLMENT_TTL; + $pending = array( + 'key' => $key, + 'expires_at' => $expires_at, + ); + + if ( ! update_user_meta( $user->ID, self::PENDING_ENROLLMENT_META_KEY, $pending ) && get_user_meta( $user->ID, self::PENDING_ENROLLMENT_META_KEY, true ) !== $pending ) { + return new WP_Error( 'db_error', __( 'Unable to begin Two Factor Authentication enrollment.', 'two-factor' ), array( 'status' => 500 ) ); + } + + return array( + 'secret' => $key, + 'otpauth_uri' => self::generate_qr_code_url( $user, $key ), + 'expires_at' => $expires_at, + ); + } + + /** + * Get a user's unexpired pending enrollment. + * + * @since NEXT + * + * @param int $user_id User ID. + * @return array|WP_Error Pending enrollment or an error. + */ + private function get_pending_enrollment( $user_id ) { + $pending = get_user_meta( $user_id, self::PENDING_ENROLLMENT_META_KEY, true ); + + if ( ! is_array( $pending ) || empty( $pending['key'] ) || empty( $pending['expires_at'] ) ) { + return new WP_Error( 'totp_enrollment_not_found', __( 'No pending TOTP enrollment was found.', 'two-factor' ), array( 'status' => 400 ) ); + } + + if ( self::time() >= $pending['expires_at'] ) { + delete_user_meta( $user_id, self::PENDING_ENROLLMENT_META_KEY ); + + return new WP_Error( 'totp_enrollment_expired', __( 'The pending TOTP enrollment has expired.', 'two-factor' ), array( 'status' => 400 ) ); + } + + return $pending; + } + /** * Generates a URL that can be used to create a QR code. * @@ -343,8 +445,8 @@ public static function generate_qr_code_url( $user, $secret_key ) { */ public function user_two_factor_options( $user ) { if ( ! ( $user instanceof WP_User ) ) { - return; - } + return; + } $key = $this->get_user_totp_key( $user->ID ); @@ -900,6 +1002,7 @@ public static function uninstall_user_meta_keys() { return array( self::SECRET_META_KEY, self::LAST_SUCCESSFUL_LOGIN_META_KEY, + self::PENDING_ENROLLMENT_META_KEY, ); } } diff --git a/tests/class-two-factor-rest-api.php b/tests/class-two-factor-rest-api.php new file mode 100644 index 00000000..ced32358 --- /dev/null +++ b/tests/class-two-factor-rest-api.php @@ -0,0 +1,314 @@ +user->create( array( 'role' => 'administrator' ) ); + self::$subscriber_id = $factory->user->create( array( 'role' => 'subscriber' ) ); + } + + /** + * Clean up shared fixtures. + */ + public static function wpTearDownAfterClass() { + self::delete_user( self::$admin_id ); + self::delete_user( self::$subscriber_id ); + } + + /** + * Build a request for the provider settings route. + * + * @param string $method HTTP method. + * @param int $user_id User ID. + * @param array $params Optional body parameters. + * @return WP_REST_Request Request object. + */ + private function get_request( $method, $user_id, $params = array() ) { + $request = new WP_REST_Request( $method, '/' . Two_Factor_Core::REST_NAMESPACE . '/users/' . $user_id . '/providers' ); + $request->set_body_params( $params ); + + return $request; + } + + /** + * Find provider data in a settings response. + * + * @param array $data Response data. + * @param string $provider_key Provider key. + * @return array Provider data. + */ + private function get_provider_data( $data, $provider_key ) { + foreach ( $data['providers'] as $provider ) { + if ( $provider_key === $provider['key'] ) { + return $provider; + } + } + + $this->fail( 'Provider not present in response: ' . $provider_key ); + } + + /** + * Provider settings require an authenticated user who can edit the target. + * + * @covers Two_Factor_Core::rest_get_user_provider_settings + */ + public function test_get_provider_settings_permissions() { + $response = rest_do_request( $this->get_request( 'GET', self::$subscriber_id ) ); + $this->assertErrorResponse( 'rest_forbidden', $response, 401 ); + + wp_set_current_user( self::$subscriber_id ); + $response = rest_do_request( $this->get_request( 'GET', self::$admin_id ) ); + $this->assertErrorResponse( 'rest_forbidden', $response, 403 ); + + wp_set_current_user( self::$admin_id ); + $response = rest_do_request( $this->get_request( 'GET', self::$subscriber_id ) ); + $this->assertSame( 200, $response->get_status() ); + } + + /** + * Status includes provider state without disclosing a configured TOTP secret. + * + * @covers Two_Factor_Core::rest_get_user_provider_settings + */ + public function test_get_provider_settings_redacts_totp_secret_and_counts_recovery_codes() { + wp_set_current_user( self::$subscriber_id ); + $totp = Two_Factor_Totp::get_instance(); + $backup = Two_Factor_Backup_Codes::get_instance(); + $secret = $totp->generate_key(); + + $totp->set_user_totp_key( self::$subscriber_id, $secret ); + $backup->generate_codes( get_user_by( 'id', self::$subscriber_id ), array( 'number' => 3 ) ); + Two_Factor_Core::enable_provider_for_user( self::$subscriber_id, 'Two_Factor_Totp' ); + + $response = rest_do_request( $this->get_request( 'GET', self::$subscriber_id ) ); + $data = $response->get_data(); + $totp = $this->get_provider_data( $data, 'Two_Factor_Totp' ); + $backup = $this->get_provider_data( $data, 'Two_Factor_Backup_Codes' ); + + $this->assertTrue( $totp['supported'] ); + $this->assertTrue( $totp['configured'] ); + $this->assertTrue( $totp['enabled'] ); + $this->assertTrue( $totp['primary'] ); + $this->assertSame( 3, $backup['remaining'] ); + $this->assertStringNotContainsString( $secret, wp_json_encode( $data ) ); + } + + /** + * Email and generated recovery codes can be enabled and selected generically. + * + * @covers Two_Factor_Core::rest_update_user_provider_settings + * @covers Two_Factor_Core::update_user_provider_settings + */ + public function test_update_email_and_recovery_code_settings() { + wp_set_current_user( self::$subscriber_id ); + Two_Factor_Backup_Codes::get_instance()->generate_codes( get_user_by( 'id', self::$subscriber_id ) ); + + $response = rest_do_request( + $this->get_request( + 'POST', + self::$subscriber_id, + array( + 'enabled_providers' => array( 'Two_Factor_Email', 'Two_Factor_Backup_Codes' ), + 'primary_provider' => 'Two_Factor_Email', + ) + ) + ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertTrue( $this->get_provider_data( $data, 'Two_Factor_Email' )['primary'] ); + $this->assertTrue( $this->get_provider_data( $data, 'Two_Factor_Backup_Codes' )['enabled'] ); + $this->assertSame( + array( 'Two_Factor_Email', 'Two_Factor_Backup_Codes' ), + Two_Factor_Core::get_enabled_providers_for_user( self::$subscriber_id ) + ); + } + + /** + * Admin edits apply profile-compatible session and primary-provider semantics. + * + * @covers Two_Factor_Core::rest_update_user_provider_settings + * @covers Two_Factor_Core::update_user_provider_settings + */ + public function test_admin_update_invalidates_target_sessions_and_can_disable_last_provider() { + wp_set_current_user( self::$admin_id ); + $user = get_user_by( 'id', self::$subscriber_id ); + $session_manager = WP_Session_Tokens::get_instance( self::$subscriber_id ); + + Two_Factor_Backup_Codes::get_instance()->generate_codes( $user ); + $session_manager->create( time() + HOUR_IN_SECONDS ); + $session_manager->create( time() + DAY_IN_SECONDS ); + + $response = rest_do_request( + $this->get_request( + 'POST', + self::$subscriber_id, + array( + 'enabled_providers' => array( 'Two_Factor_Email', 'Two_Factor_Backup_Codes' ), + 'primary_provider' => 'Two_Factor_Email', + ) + ) + ); + $this->assertSame( 200, $response->get_status() ); + $this->assertCount( 0, $session_manager->get_all() ); + + $session_manager->create( time() + HOUR_IN_SECONDS ); + $response = rest_do_request( + $this->get_request( + 'POST', + self::$subscriber_id, + array( + 'enabled_providers' => array( 'Two_Factor_Email' ), + 'primary_provider' => 'Two_Factor_Email', + ) + ) + ); + $this->assertSame( 200, $response->get_status() ); + $this->assertCount( 0, $session_manager->get_all() ); + + $response = rest_do_request( + $this->get_request( + 'POST', + self::$subscriber_id, + array( + 'enabled_providers' => array(), + 'primary_provider' => '', + ) + ) + ); + $this->assertSame( 200, $response->get_status() ); + $this->assertEmpty( Two_Factor_Core::get_enabled_providers_for_user( self::$subscriber_id ) ); + $this->assertNull( Two_Factor_Core::get_primary_provider_for_user( self::$subscriber_id ) ); + } + + /** + * Unconfigured providers and disabled primary providers are rejected atomically. + * + * @covers Two_Factor_Core::update_user_provider_settings + */ + public function test_update_rejects_invalid_provider_invariants() { + wp_set_current_user( self::$subscriber_id ); + + $response = rest_do_request( + $this->get_request( + 'POST', + self::$subscriber_id, + array( 'enabled_providers' => array() ) + ) + ); + $this->assertErrorResponse( 'rest_missing_callback_param', $response, 400 ); + + $response = rest_do_request( + $this->get_request( + 'POST', + self::$subscriber_id, + array( + 'enabled_providers' => array( 'Two_Factor_Totp' ), + 'primary_provider' => '', + ) + ) + ); + $this->assertErrorResponse( 'two_factor_provider_not_configured', $response, 400 ); + $this->assertEmpty( Two_Factor_Core::get_enabled_providers_for_user( self::$subscriber_id ) ); + + $response = rest_do_request( + $this->get_request( + 'POST', + self::$subscriber_id, + array( + 'enabled_providers' => array( 'Two_Factor_Email' ), + 'primary_provider' => 'Two_Factor_Totp', + ) + ) + ); + $this->assertErrorResponse( 'two_factor_primary_provider_not_enabled', $response, 400 ); + $this->assertEmpty( Two_Factor_Core::get_enabled_providers_for_user( self::$subscriber_id ) ); + } + + /** + * Sensitive mutations return a structured target when recent 2FA is required. + * + * @covers Two_Factor_Core::rest_api_can_edit_user_and_update_two_factor_options + */ + public function test_update_requires_recent_revalidation() { + wp_set_current_user( self::$subscriber_id ); + wp_set_auth_cookie( self::$subscriber_id ); + Two_Factor_Core::enable_provider_for_user( self::$subscriber_id, 'Two_Factor_Email' ); + + $response = rest_do_request( + $this->get_request( + 'POST', + self::$subscriber_id, + array( + 'enabled_providers' => array(), + 'primary_provider' => '', + ) + ) + ); + $data = $response->get_data(); + + $this->assertErrorResponse( 'revalidation_required', $response, 403 ); + $this->assertTrue( $data['data']['revalidation']['required'] ); + $this->assertSame( 'revalidate_2fa', $data['data']['revalidation']['action'] ); + $this->assertStringContainsString( 'action=revalidate_2fa', $data['data']['revalidation']['url'] ); + } + + /** + * Provider settings routes consistently reject nonexistent users. + * + * @ticket 937 + * @covers Two_Factor_Core::rest_api_can_edit_user + */ + public function test_provider_settings_routes_reject_nonexistent_users_consistently() { + wp_set_current_user( self::$admin_id ); + $before = get_user_meta( 0 ); + $requests = array( + $this->get_request( 'GET', 0 ), + $this->get_request( + 'POST', + 0, + array( + 'enabled_providers' => array(), + 'primary_provider' => '', + ) + ), + ); + + foreach ( $requests as $request ) { + $response = rest_do_request( $request ); + + $this->assertErrorResponse( 'rest_user_invalid_id', $response, 404 ); + $this->assertSame( $before, get_user_meta( 0 ) ); + } + } +} diff --git a/tests/providers/class-two-factor-backup-codes-rest-api.php b/tests/providers/class-two-factor-backup-codes-rest-api.php index 2cfef2b3..1a253493 100644 --- a/tests/providers/class-two-factor-backup-codes-rest-api.php +++ b/tests/providers/class-two-factor-backup-codes-rest-api.php @@ -138,4 +138,56 @@ public function test_generate_codes_for_other_users() { $this->assertFalse( self::$provider->validate_code( wp_get_current_user(), $data['codes'][0] ) ); $this->assertTrue( self::$provider->validate_code( get_user_by( 'id', self::$editor_id ), $data['codes'][0] ) ); } + + /** + * A nonexistent user cannot receive generated recovery codes. + * + * PHPUnit converts warnings to exceptions, so this also verifies that the + * invalid target never reaches provider generation logic. + * + * @ticket 937 + * @covers Two_Factor_Core::rest_api_can_edit_user + * @covers Two_Factor_Backup_Codes::rest_generate_codes + */ + public function test_generate_codes_rejects_nonexistent_user_without_codes_or_mutation() { + wp_set_current_user( self::$admin_id ); + $before = get_user_meta( 0 ); + + $request = new WP_REST_Request( 'POST', '/' . Two_Factor_Core::REST_NAMESPACE . '/generate-backup-codes' ); + $request->set_body_params( array( 'user_id' => 0 ) ); + $response = rest_do_request( $request ); + $data = $response->get_data(); + + $this->assertErrorResponse( 'rest_user_invalid_id', $response, 404 ); + $this->assertArrayNotHasKey( 'codes', $data ); + $this->assertArrayNotHasKey( 'download_link', $data ); + $this->assertSame( $before, get_user_meta( 0 ) ); + } + + /** + * A nonexistent user cannot enable recovery codes during generation. + * + * @ticket 937 + * @covers Two_Factor_Core::rest_api_can_edit_user + * @covers Two_Factor_Backup_Codes::rest_generate_codes + */ + public function test_generate_and_enable_codes_rejects_nonexistent_user_consistently() { + wp_set_current_user( self::$admin_id ); + $before = get_user_meta( 0 ); + + $request = new WP_REST_Request( 'POST', '/' . Two_Factor_Core::REST_NAMESPACE . '/generate-backup-codes' ); + $request->set_body_params( + array( + 'user_id' => 0, + 'enable_provider' => true, + ) + ); + $response = rest_do_request( $request ); + $data = $response->get_data(); + + $this->assertErrorResponse( 'rest_user_invalid_id', $response, 404 ); + $this->assertArrayNotHasKey( 'codes', $data ); + $this->assertArrayNotHasKey( 'download_link', $data ); + $this->assertSame( $before, get_user_meta( 0 ) ); + } } diff --git a/tests/providers/class-two-factor-totp-rest-api.php b/tests/providers/class-two-factor-totp-rest-api.php index c94db127..d15ed435 100644 --- a/tests/providers/class-two-factor-totp-rest-api.php +++ b/tests/providers/class-two-factor-totp-rest-api.php @@ -142,6 +142,30 @@ public function test_user_two_factor_rest_set_key_bad_auth_code() { $this->assertFalse( self::$provider->is_available_for_user( wp_get_current_user() ) ); } + /** + * Verify oversized auth codes are rejected before TOTP calculation. + * + * @ticket 936 + * @covers Two_Factor_Totp::rest_setup_totp + */ + public function test_user_two_factor_rest_rejects_oversized_auth_code() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'POST', '/' . Two_Factor_Core::REST_NAMESPACE . '/totp' ); + $request->set_body_params( + array( + 'user_id' => self::$admin_id, + 'key' => self::$provider->generate_key(), + 'code' => str_repeat( '1', 1000 ), + ) + ); + + $response = rest_do_request( $request ); + + $this->assertErrorResponse( 'invalid_key_code', $response, 400 ); + $this->assertFalse( self::$provider->is_available_for_user( wp_get_current_user() ) ); + } + /** * Verify setting up TOTP with an authcode. * @@ -282,4 +306,200 @@ public function test_user_cannot_delete_secret_for_others() { 'Secret has not been deleted' ); } + + /** + * A server-owned enrollment can be confirmed without resubmitting its secret. + * + * @covers Two_Factor_Totp::rest_begin_enrollment + * @covers Two_Factor_Totp::rest_setup_totp + */ + public function test_begin_and_confirm_server_owned_enrollment() { + wp_set_current_user( self::$admin_id ); + Two_Factor_Totp::set_time( time() ); + + $request = new WP_REST_Request( 'POST', '/' . Two_Factor_Core::REST_NAMESPACE . '/totp/enrollment' ); + $request->set_body_params( array( 'user_id' => self::$admin_id ) ); + $response = rest_do_request( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertTrue( self::$provider->is_valid_key( $data['secret'] ) ); + $this->assertStringStartsWith( 'otpauth://totp/', $data['otpauth_uri'] ); + $this->assertGreaterThan( time(), $data['expires_at'] ); + $this->assertSame( '', self::$provider->get_user_totp_key( self::$admin_id ) ); + + $request = new WP_REST_Request( 'POST', '/' . Two_Factor_Core::REST_NAMESPACE . '/totp' ); + $request->set_body_params( + array( + 'user_id' => self::$admin_id, + 'code' => self::$provider->calc_totp( $data['secret'] ), + 'enable_provider' => true, + ) + ); + $response = rest_do_request( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( $data['secret'], self::$provider->get_user_totp_key( self::$admin_id ) ); + $this->assertEmpty( get_user_meta( self::$admin_id, Two_Factor_Totp::PENDING_ENROLLMENT_META_KEY, true ) ); + + self::$provider->delete_user_totp_key( self::$admin_id ); + $response = rest_do_request( $request ); + $this->assertErrorResponse( 'totp_enrollment_not_found', $response, 400 ); + + Two_Factor_Totp::set_time( null ); + } + + /** + * An unchanged pending enrollment value is not a database error. + * + * @covers Two_Factor_Totp::rest_begin_enrollment + */ + public function test_begin_enrollment_accepts_unchanged_meta_update() { + wp_set_current_user( self::$admin_id ); + + $filter = function ( $check, $user_id, $meta_key, $meta_value ) use ( &$filter ) { + if ( Two_Factor_Totp::PENDING_ENROLLMENT_META_KEY !== $meta_key ) { + return $check; + } + + remove_filter( 'update_user_metadata', $filter ); + update_user_meta( $user_id, $meta_key, $meta_value ); + + return false; + }; + add_filter( 'update_user_metadata', $filter, 10, 4 ); + + $request = new WP_REST_Request( 'POST', '/' . Two_Factor_Core::REST_NAMESPACE . '/totp/enrollment' ); + $request->set_body_params( array( 'user_id' => self::$admin_id ) ); + $response = rest_do_request( $request ); + $data = $response->get_data(); + + remove_filter( 'update_user_metadata', $filter ); + + $this->assertSame( 200, $response->get_status() ); + $pending = get_user_meta( self::$admin_id, Two_Factor_Totp::PENDING_ENROLLMENT_META_KEY, true ); + $this->assertSame( $data['secret'], $pending['key'] ); + + delete_user_meta( self::$admin_id, Two_Factor_Totp::PENDING_ENROLLMENT_META_KEY ); + } + + /** + * A newer enrollment replaces the previous pending secret. + * + * @covers Two_Factor_Totp::rest_begin_enrollment + * @covers Two_Factor_Totp::rest_setup_totp + */ + public function test_begin_enrollment_replaces_pending_secret() { + wp_set_current_user( self::$admin_id ); + Two_Factor_Totp::set_time( time() ); + + $begin = new WP_REST_Request( 'POST', '/' . Two_Factor_Core::REST_NAMESPACE . '/totp/enrollment' ); + $begin->set_body_params( array( 'user_id' => self::$admin_id ) ); + $first = rest_do_request( $begin )->get_data(); + $second = rest_do_request( $begin )->get_data(); + + $this->assertNotSame( $first['secret'], $second['secret'] ); + + $confirm = new WP_REST_Request( 'POST', '/' . Two_Factor_Core::REST_NAMESPACE . '/totp' ); + $confirm->set_body_params( + array( + 'user_id' => self::$admin_id, + 'code' => self::$provider->calc_totp( $first['secret'] ), + ) + ); + $this->assertErrorResponse( 'invalid_key_code', rest_do_request( $confirm ), 400 ); + + $confirm->set_param( 'code', self::$provider->calc_totp( $second['secret'] ) ); + $this->assertSame( 200, rest_do_request( $confirm )->get_status() ); + + Two_Factor_Totp::set_time( null ); + } + + /** + * Expired pending enrollments are deleted and cannot be confirmed. + * + * @covers Two_Factor_Totp::rest_begin_enrollment + * @covers Two_Factor_Totp::rest_setup_totp + */ + public function test_pending_enrollment_expires() { + wp_set_current_user( self::$admin_id ); + $now = time(); + Two_Factor_Totp::set_time( $now ); + + $begin = new WP_REST_Request( 'POST', '/' . Two_Factor_Core::REST_NAMESPACE . '/totp/enrollment' ); + $begin->set_body_params( array( 'user_id' => self::$admin_id ) ); + $data = rest_do_request( $begin )->get_data(); + + Two_Factor_Totp::set_time( $now + Two_Factor_Totp::PENDING_ENROLLMENT_TTL ); + + $confirm = new WP_REST_Request( 'POST', '/' . Two_Factor_Core::REST_NAMESPACE . '/totp' ); + $confirm->set_body_params( + array( + 'user_id' => self::$admin_id, + 'code' => self::$provider->calc_totp( $data['secret'] ), + ) + ); + $response = rest_do_request( $confirm ); + + $this->assertErrorResponse( 'totp_enrollment_expired', $response, 400 ); + $this->assertEmpty( get_user_meta( self::$admin_id, Two_Factor_Totp::PENDING_ENROLLMENT_META_KEY, true ) ); + + Two_Factor_Totp::set_time( null ); + } + + /** + * Users cannot begin enrollment for accounts they cannot edit. + * + * @covers Two_Factor_Totp::rest_begin_enrollment + */ + public function test_user_cannot_begin_enrollment_for_another_user() { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'POST', '/' . Two_Factor_Core::REST_NAMESPACE . '/totp/enrollment' ); + $request->set_body_params( array( 'user_id' => self::$admin_id ) ); + $response = rest_do_request( $request ); + + $this->assertErrorResponse( 'rest_forbidden', $response, 403 ); + } + + /** + * All TOTP mutations reject nonexistent users before provider callbacks. + * + * @ticket 937 + * @covers Two_Factor_Core::rest_api_can_edit_user + */ + public function test_totp_routes_reject_nonexistent_users_consistently() { + wp_set_current_user( self::$admin_id ); + $before = get_user_meta( 0 ); + $routes = array( + array( + 'POST', + '/' . Two_Factor_Core::REST_NAMESPACE . '/totp/enrollment', + array( 'user_id' => 0 ), + ), + array( + 'POST', + '/' . Two_Factor_Core::REST_NAMESPACE . '/totp', + array( + 'user_id' => 0, + 'key' => self::$provider->generate_key(), + 'code' => '123456', + ), + ), + array( + 'DELETE', + '/' . Two_Factor_Core::REST_NAMESPACE . '/totp', + array( 'user_id' => 0 ), + ), + ); + + foreach ( $routes as $route ) { + $request = new WP_REST_Request( $route[0], $route[1] ); + $request->set_body_params( $route[2] ); + $response = rest_do_request( $request ); + + $this->assertErrorResponse( 'rest_user_invalid_id', $response, 404 ); + $this->assertSame( $before, get_user_meta( 0 ) ); + } + } }