diff --git a/includes/class-newspack.php b/includes/class-newspack.php index 3bde777a1f..576f3d079f 100644 --- a/includes/class-newspack.php +++ b/includes/class-newspack.php @@ -97,6 +97,7 @@ private function includes() { include_once NEWSPACK_ABSPATH . 'includes/class-theme-manager.php'; include_once NEWSPACK_ABSPATH . 'includes/class-admin-plugins-screen.php'; include_once NEWSPACK_ABSPATH . 'includes/reader-activation/class-reader-activation.php'; + include_once NEWSPACK_ABSPATH . 'includes/reader-activation/class-reader-registration.php'; include_once NEWSPACK_ABSPATH . 'includes/reader-activation/class-reader-activation-emails.php'; include_once NEWSPACK_ABSPATH . 'includes/reader-activation/class-reader-data.php'; include_once NEWSPACK_ABSPATH . 'includes/reader-activation/sync/class-sync.php'; diff --git a/includes/reader-activation/class-integrations.php b/includes/reader-activation/class-integrations.php index a65e4315dc..cfd0e3b8a3 100644 --- a/includes/reader-activation/class-integrations.php +++ b/includes/reader-activation/class-integrations.php @@ -90,6 +90,7 @@ public static function init() { add_action( 'init', [ __CLASS__, 'register_my_account_endpoints' ], 6 ); add_filter( 'woocommerce_account_menu_items', [ __CLASS__, 'filter_my_account_menu_items' ] ); add_filter( 'query_vars', [ __CLASS__, 'filter_my_account_query_vars' ] ); + add_action( 'newspack_frontend_registration_existing_user', [ __CLASS__, 'handle_existing_user_registration' ], 10, 3 ); add_action( 'init', [ __CLASS__, 'schedule_health_check' ] ); add_action( self::HEALTH_CHECK_CRON_HOOK, [ __CLASS__, 'run_health_checks' ] ); add_filter( 'newspack_data_events_handler_action_group', [ __CLASS__, 'filter_handler_action_group' ], 10, 3 ); @@ -656,6 +657,24 @@ public static function filter_my_account_query_vars( $vars ) { return $vars; } + /** + * Handle an existing user attempting to register via a frontend integration. + * + * Delegates to the integration's handle_logged_in_user_registration() method if it exists, + * allowing integrations to update user data on repeated registration attempts + * (e.g. recording a new donation for a returning donor). + * + * @param \WP_User $user The logged-in user. + * @param \WP_REST_Request $request The registration request. + * @param \Newspack\Reader_Activation\Integration|null $integration_instance The integration instance, or null for filter-only registrations. + */ + public static function handle_existing_user_registration( $user, $request, $integration_instance ) { + if ( ! $integration_instance instanceof Integration ) { + return; + } + $integration_instance->handle_logged_in_user_registration( $user, $request ); + } + /** * Schedule the hourly health check cron event. * diff --git a/includes/reader-activation/class-reader-activation.php b/includes/reader-activation/class-reader-activation.php index e053b60533..6d92ae95d0 100644 --- a/includes/reader-activation/class-reader-activation.php +++ b/includes/reader-activation/class-reader-activation.php @@ -150,12 +150,13 @@ public static function enqueue_scripts() { 'is_ras_enabled' => self::is_enabled(), ]; + $script_data = array_merge( $script_data, Reader_Registration::get_script_data() ); + if ( Recaptcha::can_use_captcha() ) { $recaptcha_version = Recaptcha::get_setting( 'version' ); $script_dependencies[] = Recaptcha::SCRIPT_HANDLE; - if ( 'v3' === $recaptcha_version ) { - $script_data['captcha_site_key'] = Recaptcha::get_site_key(); - } + $script_data['captcha_site_key'] = Recaptcha::get_site_key(); + $script_data['captcha_version'] = $recaptcha_version; } Newspack::load_common_assets(); diff --git a/includes/reader-activation/class-reader-registration.php b/includes/reader-activation/class-reader-registration.php new file mode 100644 index 0000000000..e2e1e16ed8 --- /dev/null +++ b/includes/reader-activation/class-reader-registration.php @@ -0,0 +1,443 @@ + \WP_REST_Server::CREATABLE, + 'callback' => [ __CLASS__, 'api_frontend_register_reader' ], + 'permission_callback' => '__return_true', + 'args' => [ + 'npe' => [ + 'type' => 'string', + 'sanitize_callback' => 'sanitize_email', + 'default' => '', + ], + 'email' => [ + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'default' => '', + ], + 'integration_id' => [ + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'default' => '', + ], + 'integration_key' => [ + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'default' => '', + ], + 'first_name' => [ + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'default' => '', + ], + 'last_name' => [ + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'default' => '', + ], + 'metadata' => [ + 'type' => 'object', + 'default' => [], + 'sanitize_callback' => [ __CLASS__, 'sanitize_metadata' ], + ], + 'g-recaptcha-response' => [ + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'default' => '', + ], + ], + ] + ); + } + + /** + * Sanitize the metadata parameter. + * + * Ensures all keys and values are sanitized strings. + * + * @param array $metadata Raw metadata from the request. + * @return array Sanitized metadata. + */ + public static function sanitize_metadata( $metadata ) { + if ( ! is_array( $metadata ) ) { + return []; + } + $sanitized = []; + foreach ( $metadata as $key => $value ) { + $key = sanitize_key( $key ); + if ( ! empty( $key ) ) { + $sanitized[ $key ] = \sanitize_text_field( $value ); + } + } + return $sanitized; + } + + /** + * Get registered frontend registration integrations. + * + * @return array Map of integration ID => label. + */ + public static function get_frontend_registration_integrations(): array { + /** + * Filters the list of integrations that can trigger frontend reader registration. + * + * @param array $integrations Map of integration ID => display label. + */ + $integrations = \apply_filters( 'newspack_frontend_registration_integrations', [] ); + + // Also include Integration subclasses that opt in. + foreach ( Integrations::get_available_integrations() as $integration ) { + if ( $integration->supports_frontend_registration() && ! isset( $integrations[ $integration->get_id() ] ) ) { + $integrations[ $integration->get_id() ] = $integration->get_name(); + } + } + + return $integrations; + } + + /** + * Generate an HMAC key for a frontend registration integration. + * + * The key is deterministic (safe for page caching) and unique per + * integration ID and site. It is not a secret — it is output to the + * page source — but it binds registration requests to a PHP-registered + * integration, preventing arbitrary callers. + * + * @param string $integration_id Integration identifier. + * @return string HMAC-SHA256 hex string. + */ + public static function get_frontend_registration_key( string $integration_id ): string { + $integration = Integrations::get_integration( $integration_id ); + if ( $integration && $integration->supports_frontend_registration() ) { + return $integration->get_registration_key(); + } + // Fallback for filter-only registrations. + return hash_hmac( 'sha256', $integration_id, \wp_salt( 'auth' ) ); + } + + /** + * Get script data for frontend localization. + * + * Called by Reader_Activation::enqueue_scripts() to merge integration + * config into the newspack_ras_config object. + * + * @return array Script data to merge, or empty array if no integrations. + */ + public static function get_script_data(): array { + if ( ! Reader_Activation::is_enabled() ) { + return []; + } + + $frontend_integrations = self::get_frontend_registration_integrations(); + if ( empty( $frontend_integrations ) ) { + return []; + } + + $integrations_config = []; + foreach ( $frontend_integrations as $id => $label ) { + $integrations_config[ $id ] = [ + 'key' => self::get_frontend_registration_key( $id ), + 'label' => $label, + ]; + } + + return [ + 'frontend_registration_integrations' => $integrations_config, + 'frontend_registration_url' => \rest_url( NEWSPACK_API_NAMESPACE . '/reader-activation/register' ), + ]; + } + + /** + * Check and increment the per-IP rate limit for frontend registration. + * + * @return bool|\WP_Error True if under limit, WP_Error if exceeded. + */ + private static function check_registration_rate_limit(): bool|\WP_Error { + // @todo REMOTE_ADDR may be a proxy/load-balancer IP in some environments. + // On WordPress VIP/Atomic this is the real client IP. For other hosts, + // consider parsing forwarded headers or providing a filter to override IP resolution. + // See WooCommerce_Connection::get_client_ip() for a forwarded-header approach. + $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '127.0.0.1'; // phpcs:ignore WordPressVIPMinimum.Variables.ServerVariables.UserControlledHeaders,WordPressVIPMinimum.Variables.RestrictedVariables.cache_constraints___SERVER__REMOTE_ADDR__ + $cache_key = 'newspack_reg_ip_' . md5( $ip ); + + /** + * Filters the maximum number of frontend registration attempts per IP per hour. + * + * @param int $limit Maximum attempts. Default 10. + * @param string $ip The client IP address. + */ + $limit = \apply_filters( 'newspack_frontend_registration_rate_limit', 10, $ip ); + + if ( \wp_using_ext_object_cache() ) { + $cache_group = 'newspack_rate_limit'; + \wp_cache_add( $cache_key, 0, $cache_group, HOUR_IN_SECONDS ); + $attempts = \wp_cache_incr( $cache_key, 1, $cache_group ); + } else { + $attempts = (int) \get_transient( $cache_key ); + \set_transient( $cache_key, $attempts + 1, HOUR_IN_SECONDS ); + $attempts++; + } + + if ( $attempts > $limit ) { + Logger::log( 'Frontend registration rate limit exceeded for IP ' . $ip ); + return new \WP_Error( + 'rate_limit_exceeded', + __( 'Too many registration attempts. Please try again later.', 'newspack-plugin' ), + [ 'status' => 429 ] + ); + } + + return true; + } + + /** + * REST API handler for frontend integration reader registration. + * + * Validation sequence: + * 1. Already logged in — return current reader data + * 2. Reader Activation is enabled + * 3. Integration ID is registered + * 4. Integration key matches HMAC + * 5. Honeypot field is empty + * 6. Per-IP rate limit + * 7. reCAPTCHA (when configured) + * 8. Email is valid + * + * @param \WP_REST_Request $request Request object. + * @return \WP_REST_Response|\WP_Error + */ + public static function api_frontend_register_reader( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { + + // Step 1: Validate integration ID is registered. + $integration_id = $request->get_param( 'integration_id' ); + $integrations = self::get_frontend_registration_integrations(); + $integration_instance = Integrations::get_integration( $integration_id ); + + if ( empty( $integration_id ) || ! isset( $integrations[ $integration_id ] ) ) { + Logger::log( 'Frontend registration rejected: invalid integration ID "' . $integration_id . '"' ); + return new \WP_Error( + 'invalid_integration', + __( 'Invalid integration.', 'newspack-plugin' ), + [ 'status' => 400 ] + ); + } + + // Step 2: If caller is already logged in, return current reader data. + // This makes the API idempotent — integrations don't need to check + // authentication state before calling register(). + if ( \is_user_logged_in() ) { + $current_user = \wp_get_current_user(); + + /** + * Action triggered when a logged-in user attempts to register via the frontend registration endpoint. + * + * Integrations can hook into this action to handle cases where an existing user attempts to register again via the frontend registration flow. For example, an integration might want to link the existing user account to the integration or log this event for analytics purposes. + * + * @param \WP_User $current_user The currently logged-in user. + * @param \WP_REST_Request $request The original registration request. + * @param Integration|null $integration_instance The integration instance associated with the registration attempt, or null if the integration was registered via filter only. + */ + do_action( 'newspack_frontend_registration_existing_user', $current_user, $request, $integration_instance ); + + return new \WP_REST_Response( + [ + 'success' => true, + 'status' => 'existing', + 'email' => $current_user->user_email, + ], + 200 + ); + } + + // Step 3: Check RAS is enabled. + if ( ! Reader_Activation::is_enabled() ) { + return new \WP_Error( + 'reader_activation_disabled', + __( 'Reader Activation is not enabled.', 'newspack-plugin' ), + [ 'status' => 403 ] + ); + } + + // Step 4: Validate integration key. + $integration_key = $request->get_param( 'integration_key' ); + if ( $integration_instance && $integration_instance->supports_frontend_registration() ) { + $key_valid = $integration_instance->validate_registration_request( $integration_key, $request ); + } else { + // Fallback for filter-only registrations. + $expected_key = self::get_frontend_registration_key( $integration_id ); + $key_valid = hash_equals( $expected_key, $integration_key ); + } + if ( ! $key_valid ) { + Logger::log( 'Frontend registration rejected: invalid key for integration "' . $integration_id . '"' ); + return new \WP_Error( + 'invalid_integration_key', + __( 'Invalid integration key.', 'newspack-plugin' ), + [ 'status' => 403 ] + ); + } + + // Step 5: Honeypot — the `email` field must be empty. Real email is in `npe`. + $honeypot = $request->get_param( 'email' ); + if ( ! empty( $honeypot ) ) { + // Return fake success to avoid revealing the honeypot to bots. + // @todo Consider returning the npe value instead of the honeypot value to make + // the fake response indistinguishable from a real one. + return new \WP_REST_Response( + [ + 'success' => true, + 'status' => 'created', + 'email' => $honeypot, + ], + 200 + ); + } + + // Step 6: Per-IP rate limit. Checked before reCAPTCHA to avoid + // triggering external verification calls for rate-limited IPs. + $rate_check = self::check_registration_rate_limit(); + if ( \is_wp_error( $rate_check ) ) { + return $rate_check; + } + + // Step 7: reCAPTCHA (when configured). + $recaptcha_token = $request->get_param( 'g-recaptcha-response' ); + $should_verify = \apply_filters( 'newspack_recaptcha_verify_captcha', Recaptcha::can_use_captcha(), '', 'integration_registration' ); + if ( $should_verify ) { + // Bridge: verify_captcha() reads from $_POST. + // @todo Refactor Recaptcha::verify_captcha() to accept an optional $token parameter, eliminating this $_POST mutation. + $_POST['g-recaptcha-response'] = $recaptcha_token; // phpcs:ignore WordPress.Security.NonceVerification.Missing + $captcha_result = Recaptcha::verify_captcha(); + unset( $_POST['g-recaptcha-response'] ); + if ( \is_wp_error( $captcha_result ) ) { + return new \WP_Error( + 'recaptcha_failed', + $captcha_result->get_error_message(), + [ 'status' => 403 ] + ); + } + } + + // Step 8: Validate email. + $email = $request->get_param( 'npe' ); + if ( empty( $email ) ) { + return new \WP_Error( + 'invalid_email', + __( 'A valid email address is required.', 'newspack-plugin' ), + [ 'status' => 400 ] + ); + } + + // Build display name from profile fields. + $first_name = $request->get_param( 'first_name' ); + $last_name = $request->get_param( 'last_name' ); + $display_name = trim( $first_name . ' ' . $last_name ); + + // Build metadata. Normalize referer to a local path, matching process_auth_form(). + $referer = \wp_parse_url( \wp_get_referer() ); + $referer = is_array( $referer ) ? $referer : []; + $current_page_url = ! empty( $referer['path'] ) ? \esc_url( \home_url( $referer['path'] ) ) : ''; + $metadata = [ + 'registration_method' => 'integration-registration-' . $integration_id, + 'current_page_url' => $current_page_url, + ]; + + $result = Reader_Activation::register_reader( $email, $display_name, true, $metadata ); + + if ( \is_wp_error( $result ) ) { + // Race condition: concurrent requests for the same email can cause + // wp_insert_user() or wc_create_new_customer() to return an "existing + // user" error instead of register_reader() returning false. + $existing_user_codes = [ 'existing_user_email', 'existing_user_login', 'registration-error-email-exists' ]; + if ( array_intersect( $result->get_error_codes(), $existing_user_codes ) ) { + return new \WP_Error( + 'reader_already_exists', + __( 'A reader with this email address is already registered.', 'newspack-plugin' ), + [ 'status' => 409 ] + ); + } + + return new \WP_Error( + 'registration_failed', + $result->get_error_message(), + [ 'status' => 500 ] + ); + } + + // @todo register_reader() returns false for both existing readers (sends magic link) + // and existing non-reader accounts (sends login reminder). This 409 treats both + // identically. Consider distinguishing these cases to avoid disclosing account type. + if ( false === $result ) { + return new \WP_Error( + 'reader_already_exists', + __( 'A reader with this email address is already registered.', 'newspack-plugin' ), + [ 'status' => 409 ] + ); + } + + // Apply profile fields after creation. + if ( ! empty( $first_name ) || ! empty( $last_name ) ) { + \wp_update_user( + [ + 'ID' => $result, + 'first_name' => $first_name, + 'last_name' => $last_name, + ] + ); + } + + // Save arbitrary user metadata. + $user_metadata = $request->get_param( 'metadata' ); + if ( ! empty( $user_metadata ) ) { + foreach ( $user_metadata as $meta_key => $meta_value ) { + \update_user_meta( $result, $meta_key, $meta_value ); + } + } + + return new \WP_REST_Response( + [ + 'success' => true, + 'status' => 'created', + 'email' => $email, + ], + 201 + ); + } +} +Reader_Registration::init(); diff --git a/includes/reader-activation/integrations/class-integration.php b/includes/reader-activation/integrations/class-integration.php index 7a486b76d9..a5da448d45 100644 --- a/includes/reader-activation/integrations/class-integration.php +++ b/includes/reader-activation/integrations/class-integration.php @@ -126,6 +126,64 @@ public function get_description() { return $this->description; } + /** + * Whether this integration supports frontend reader registration. + * + * Integrations that return true will have their key output to the page + * and will be accepted by the frontend registration endpoint. + * + * @return bool + */ + public function supports_frontend_registration(): bool { + return false; + } + + /** + * Generate the registration key for this integration. + * + * The default implementation uses HMAC-SHA256 with the site's auth salt. + * Subclasses can override this to implement custom key schemes + * (e.g., asymmetric key pairs, time-bounded tokens). + * + * @return string The registration key. + */ + public function get_registration_key(): string { + return hash_hmac( 'sha256', $this->id, \wp_salt( 'auth' ) ); + } + + /** + * Validate a submitted registration key for this integration. + * + * The default implementation uses timing-safe comparison against + * the HMAC key. Subclasses can override this to implement custom + * validation (e.g., signature verification, token decryption). + * + * Note: The built-in JS client (newspackReaderActivation.register()) + * always sends the value from get_registration_key(). Integrations + * that override this method to accept a different value must provide + * their own client-side code to compute and submit the correct key. + * + * The default implementation validates the HMAC key. Subclasses can override + * this method to perform additional checks on the request (e.g. verifying + * custom headers, validating metadata, or enforcing integration-specific rules). + * + * @param string $key The submitted key to validate. + * @param \WP_REST_Request $request The full registration request. + * @return bool Whether the registration request is valid. + */ + public function validate_registration_request( string $key, $request ): bool { + return hash_equals( $this->get_registration_key(), $key ); + } + + /** + * Initialize the integration, performing any necessary setup or validation. + * + * Currently only initializes settings fields, but can be extended by child classes for additional setup. + */ + public function init() { + $this->settings_fields = $this->register_settings_fields(); + } + /** * Register settings fields for this integration. * @@ -160,6 +218,20 @@ abstract public function can_sync( $return_errors = false ); */ abstract public function push_contact_data( $contact, $context = '', $existing_contact = null ); + /** + * Handle a logged-in user attempting to register again via the frontend registration flow. + * + * Integrations can override this method to update user data or perform other actions when an existing user attempts to register again via the frontend registration flow. For example, an integration might want to link the existing user account to the integration, record a new donation for a returning donor, or log this event for analytics purposes. + * + * The default implementation is a no-op. + * + * @param \WP_User $user The currently logged-in user attempting to register again. + * @param \WP_REST_Request $request The original registration request. + */ + public function handle_logged_in_user_registration( $user, $request ) { + // By default, do nothing. Integrations can override this to handle cases where a logged-in user attempts to register again via the frontend registration flow. + } + /** * Register data event handlers for this integration. * @@ -741,7 +813,7 @@ private function get_settings_field_by_key( $key ) { * @param mixed $value The value to sanitize. * @return mixed The sanitized value. */ - private function sanitize_settings_field_value( $field, $value ) { + protected function sanitize_settings_field_value( $field, $value ) { $type = $field['type'] ?? 'text'; switch ( $type ) { case 'checkbox': diff --git a/src/reader-activation/index.js b/src/reader-activation/index.js index 2997332504..358e526087 100644 --- a/src/reader-activation/index.js +++ b/src/reader-activation/index.js @@ -13,7 +13,7 @@ import setupArticleViewsAggregates from './article-view.js'; import setupEngagement from './engagement.js'; import initSubscriptionTiersForm from './subscription-tiers-form.js'; import { openAuthModal as _openAuthModal } from '../reader-activation-auth/auth-modal.js'; -import { hydrateSession } from './session.js'; +import { getApiNonce, hydrateSession } from './session.js'; /** * Reader Activation Library. @@ -438,6 +438,186 @@ function attachNewsletterFormListener() { } ); } +/** + * Acquire a reCAPTCHA v2 invisible token. + * + * Renders a temporary invisible widget, executes it, and resolves + * with the token. Cleans up the widget container after completion. + * + * @todo Consider adding an in-flight guard to coalesce concurrent calls, + * since each invocation renders a separate widget and the reCAPTCHA API + * may not handle multiple simultaneous invisible widgets well. + * + * @param {string} siteKey reCAPTCHA site key. + * @return {Promise} Resolves with the reCAPTCHA token. + */ +function acquireV2InvisibleToken( siteKey ) { + return new Promise( function ( resolve, reject ) { + const container = document.createElement( 'div' ); + container.style.display = 'none'; + document.body.appendChild( container ); + + let settled = false; + const timeout = setTimeout( function () { + if ( ! settled ) { + settled = true; + container.remove(); + reject( new Error( 'reCAPTCHA timed out.' ) ); + } + }, 30000 ); + + function settle( fn, value ) { + if ( settled ) { + return; + } + settled = true; + clearTimeout( timeout ); + container.remove(); + fn( value ); + } + + try { + const widgetId = window.grecaptcha.render( container, { + sitekey: siteKey, + size: 'invisible', + isolated: true, + callback( token ) { + settle( resolve, token ); + }, + 'error-callback'() { + settle( reject, new Error( 'reCAPTCHA challenge failed.' ) ); + }, + 'expired-callback'() { + settle( reject, new Error( 'reCAPTCHA token expired.' ) ); + }, + } ); + window.grecaptcha.execute( widgetId ); + } catch ( err ) { + settle( reject, err ); + } + } ); +} + +/** + * Register a reader via a frontend integration. + * + * @param {string} email Reader email address. + * @param {string} integrationId Registered integration ID. + * @param {Object} profileFields Optional profile fields: { first_name, last_name, metadata }. + * @param {Object} profileFields.metadata Optional arbitrary key-value pairs to store as user meta. + * @return {Promise} Resolves with reader data on success, rejects with error on failure. + */ +function register( email, integrationId, profileFields = {} ) { + const config = newspack_ras_config?.frontend_registration_integrations || {}; + const integration = config[ integrationId ]; + + if ( ! integration ) { + return Promise.reject( new Error( 'Unknown integration: ' + integrationId ) ); + } + + if ( ! email || ! /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test( email ) ) { + return Promise.reject( new Error( 'Invalid email address.' ) ); + } + + if ( ! newspack_ras_config?.frontend_registration_url ) { + return Promise.reject( new Error( 'Registration is not available.' ) ); + } + + const body = { + npe: email, + integration_id: integrationId, + integration_key: integration.key, + first_name: profileFields.first_name || '', + last_name: profileFields.last_name || '', + metadata: profileFields.metadata || {}, + }; + + // Acquire reCAPTCHA token if configured, using the appropriate version flow. + const captchaSiteKey = newspack_ras_config?.captcha_site_key; + const captchaVersion = newspack_ras_config?.captcha_version; + let captchaPromise; + + if ( captchaSiteKey ) { + if ( ! window.grecaptcha ) { + return Promise.reject( new Error( 'reCAPTCHA is configured but not loaded.' ) ); + } + if ( captchaVersion === 'v3' ) { + captchaPromise = new Promise( function ( resolve, reject ) { + window.grecaptcha.ready( function () { + window.grecaptcha + .execute( captchaSiteKey, { + action: 'integration_registration', + } ) + .then( resolve ) + .catch( reject ); + } ); + } ); + } else if ( captchaVersion && captchaVersion.substring( 0, 2 ) === 'v2' ) { + captchaPromise = new Promise( function ( resolve, reject ) { + window.grecaptcha.ready( function () { + acquireV2InvisibleToken( captchaSiteKey ).then( resolve ).catch( reject ); + } ); + } ); + } else { + captchaPromise = Promise.resolve( '' ); + } + } else { + captchaPromise = Promise.resolve( '' ); + } + + return captchaPromise + .then( function ( token ) { + if ( token ) { + body[ 'g-recaptcha-response' ] = token; + } + const headers = { 'Content-Type': 'application/json' }; + const nonce = getApiNonce(); + if ( nonce ) { + headers[ 'X-WP-Nonce' ] = nonce; + } + return fetch( newspack_ras_config.frontend_registration_url, { + method: 'POST', + headers, + credentials: 'same-origin', + body: JSON.stringify( body ), + } ); + } ) + .then( function ( response ) { + return response.json().then( function ( data ) { + if ( ! response.ok ) { + const error = new Error( data.message || 'Registration failed.' ); + error.code = data.code; + throw error; + } + return data; + } ); + } ) + .then( function ( data ) { + const readerEmail = data.email || email; + const reader = { + ...( store.get( 'reader' ) || {} ), + email: readerEmail, + authenticated: true, + }; + store.set( 'reader', reader, false ); + emit( EVENTS.reader, reader ); + dispatchActivity( 'reader_registered', { + email: readerEmail, + integration_id: integrationId, + status: data.status || 'created', + } ); + return data; + } ) + .catch( function ( error ) { + dispatchActivity( 'reader_registration_failed', { + email, + integration_id: integrationId, + error: error.code || 'network_error', + } ); + throw error; + } ); +} + const readerActivation = { store, overlays, @@ -463,6 +643,7 @@ const readerActivation = { setPendingCheckout, getPendingCheckout, debugLog, + register, ...( newspack_ras_config.is_ras_enabled && { openAuthModal } ), }; diff --git a/src/wizards/audience/views/integrations/settings-field.js b/src/wizards/audience/views/integrations/settings-field.js index ba2514d063..89fe188d1b 100644 --- a/src/wizards/audience/views/integrations/settings-field.js +++ b/src/wizards/audience/views/integrations/settings-field.js @@ -2,7 +2,7 @@ * WordPress dependencies. */ import { __ } from '@wordpress/i18n'; -import { CheckboxControl, ExternalLink } from '@wordpress/components'; +import { CheckboxControl, ExternalLink, TextareaControl } from '@wordpress/components'; /** * Internal dependencies. @@ -72,15 +72,7 @@ export const SettingsField = ( { field, value, onChange } ) => { ); case 'textarea': return ( - + ); case 'number': return ( diff --git a/tests/unit-tests/reader-registration-endpoint.php b/tests/unit-tests/reader-registration-endpoint.php new file mode 100644 index 0000000000..195334983f --- /dev/null +++ b/tests/unit-tests/reader-registration-endpoint.php @@ -0,0 +1,761 @@ +server = $wp_rest_server; + add_filter( 'newspack_frontend_registration_integrations', [ __CLASS__, 'register_test_integration' ] ); + // Ensure routes are registered — Reader_Activation::init() may have run + // before IS_TEST_ENV was defined, skipping the rest_api_init hook. + add_action( 'rest_api_init', [ Reader_Registration::class, 'register_routes' ] ); + do_action( 'rest_api_init' ); + wp_set_current_user( 0 ); + } + + /** + * Clean up after each test. + */ + public function tear_down() { + global $wp_rest_server; + $wp_rest_server = null; + remove_filter( 'newspack_frontend_registration_integrations', [ __CLASS__, 'register_test_integration' ] ); + remove_action( 'rest_api_init', [ Reader_Registration::class, 'register_routes' ] ); + $user = get_user_by( 'email', self::$reader_email ); + if ( $user ) { + wp_delete_user( $user->ID ); + } + // Reset rate limit state. + delete_transient( 'newspack_reg_ip_' . md5( '127.0.0.1' ) ); + wp_cache_delete( 'newspack_reg_ip_' . md5( '127.0.0.1' ), 'newspack_rate_limit' ); + // Clean up any $_POST pollution. + unset( $_POST['g-recaptcha-response'] ); + parent::tear_down(); + } + + /** + * Helper to make a registration request. + * + * @param array $body Request body. + * @return WP_REST_Response + */ + private function do_register_request( $body = [] ) { + $request = new WP_REST_Request( 'POST', self::$route ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( $body ) ); + return $this->server->dispatch( $request ); + } + + /** + * Test successful reader registration. + */ + public function test_register_new_reader() { + $response = $this->do_register_request( + [ + 'npe' => self::$reader_email, + 'integration_id' => self::$integration_id, + 'integration_key' => self::generate_key( self::$integration_id ), + ] + ); + $this->assertEquals( 201, $response->get_status() ); + $data = $response->get_data(); + $this->assertTrue( $data['success'] ); + $this->assertEquals( 'created', $data['status'] ); + $this->assertEquals( self::$reader_email, $data['email'] ); + $this->assertInstanceOf( 'WP_User', get_user_by( 'email', self::$reader_email ) ); + } + + /** + * Test duplicate email returns 409. + */ + public function test_register_duplicate_email() { + // Create the user directly to avoid register_reader()'s RAS-enabled check. + self::factory()->user->create( + [ + 'user_email' => self::$reader_email, + 'role' => 'subscriber', + ] + ); + wp_set_current_user( 0 ); + + $response = $this->do_register_request( + [ + 'npe' => self::$reader_email, + 'integration_id' => self::$integration_id, + 'integration_key' => self::generate_key( self::$integration_id ), + ] + ); + $this->assertEquals( 409, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'reader_already_exists', $data['code'] ); + } + + /** + * Test missing email returns 400. + */ + public function test_register_missing_email() { + $response = $this->do_register_request( + [ + 'integration_id' => self::$integration_id, + 'integration_key' => self::generate_key( self::$integration_id ), + ] + ); + $this->assertEquals( 400, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'invalid_email', $data['code'] ); + } + + /** + * Test invalid email returns 400. + */ + public function test_register_invalid_email() { + $response = $this->do_register_request( + [ + 'npe' => 'not-an-email', + 'integration_id' => self::$integration_id, + 'integration_key' => self::generate_key( self::$integration_id ), + ] + ); + $this->assertEquals( 400, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'invalid_email', $data['code'] ); + } + + /** + * Test missing integration ID returns 400. + */ + public function test_register_missing_integration_id() { + $response = $this->do_register_request( + [ + 'npe' => self::$reader_email, + 'integration_key' => 'anything', + ] + ); + $this->assertEquals( 400, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'invalid_integration', $data['code'] ); + } + + /** + * Test unregistered integration ID returns 400. + */ + public function test_register_unknown_integration_id() { + $response = $this->do_register_request( + [ + 'npe' => self::$reader_email, + 'integration_id' => 'unknown-tool', + 'integration_key' => self::generate_key( 'unknown-tool' ), + ] + ); + $this->assertEquals( 400, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'invalid_integration', $data['code'] ); + } + + /** + * Test wrong integration key returns 403. + */ + public function test_register_wrong_integration_key() { + $response = $this->do_register_request( + [ + 'npe' => self::$reader_email, + 'integration_id' => self::$integration_id, + 'integration_key' => 'wrong-key', + ] + ); + $this->assertEquals( 403, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'invalid_integration_key', $data['code'] ); + } + + /** + * Test honeypot field triggers fake success. + */ + public function test_honeypot_returns_fake_success() { + $response = $this->do_register_request( + [ + 'npe' => self::$reader_email, + 'email' => 'bot-filled@spam.com', + 'integration_id' => self::$integration_id, + 'integration_key' => self::generate_key( self::$integration_id ), + ] + ); + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertTrue( $data['success'] ); + // Verify user was NOT actually created. + $this->assertFalse( get_user_by( 'email', self::$reader_email ) ); + } + + /** + * Test logged-in user returns current reader data. + */ + public function test_register_while_logged_in() { + $admin_id = self::factory()->user->create( + [ + 'role' => 'administrator', + 'user_email' => 'admin@test.com', + ] + ); + wp_set_current_user( $admin_id ); + + $response = $this->do_register_request( + [ + 'npe' => self::$reader_email, + 'integration_id' => self::$integration_id, + 'integration_key' => self::generate_key( self::$integration_id ), + ] + ); + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertTrue( $data['success'] ); + $this->assertEquals( 'existing', $data['status'] ); + $this->assertEquals( 'admin@test.com', $data['email'] ); + + wp_delete_user( $admin_id ); + } + + /** + * Test registration with profile fields. + */ + public function test_register_with_profile_fields() { + $response = $this->do_register_request( + [ + 'npe' => self::$reader_email, + 'integration_id' => self::$integration_id, + 'integration_key' => self::generate_key( self::$integration_id ), + 'first_name' => 'Jane', + 'last_name' => 'Doe', + ] + ); + $this->assertEquals( 201, $response->get_status() ); + $user = get_user_by( 'email', self::$reader_email ); + $this->assertInstanceOf( 'WP_User', $user ); + $this->assertEquals( 'Jane', $user->first_name ); + $this->assertEquals( 'Doe', $user->last_name ); + $this->assertStringContainsString( 'Jane', $user->display_name ); + } + + /** + * Test registration stores the integration-based registration method. + */ + public function test_register_stores_registration_method() { + $response = $this->do_register_request( + [ + 'npe' => self::$reader_email, + 'integration_id' => self::$integration_id, + 'integration_key' => self::generate_key( self::$integration_id ), + ] + ); + $this->assertEquals( 201, $response->get_status() ); + $user = get_user_by( 'email', self::$reader_email ); + $this->assertEquals( + 'integration-registration-' . self::$integration_id, + get_user_meta( $user->ID, Reader_Activation::REGISTRATION_METHOD, true ) + ); + } + + /** + * Test RAS disabled returns 403. + * + * Skipped in the test environment because Reader_Activation::is_enabled() + * short-circuits to true when IS_TEST_ENV is defined, bypassing the filter. + */ + public function test_register_when_ras_disabled() { + if ( defined( 'IS_TEST_ENV' ) && IS_TEST_ENV ) { + $this->markTestSkipped( 'is_enabled() always returns true when IS_TEST_ENV is defined.' ); + } + + add_filter( 'newspack_reader_activation_enabled', '__return_false' ); + + $response = $this->do_register_request( + [ + 'npe' => self::$reader_email, + 'integration_id' => self::$integration_id, + 'integration_key' => self::generate_key( self::$integration_id ), + ] + ); + $this->assertEquals( 403, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'reader_activation_disabled', $data['code'] ); + + remove_filter( 'newspack_reader_activation_enabled', '__return_false' ); + } + + /** + * Test per-IP rate limiting returns 429. + */ + public function test_rate_limit_exceeded() { + // Lower limit to 2 for testing. + $set_limit = function() { + return 2; + }; + add_filter( 'newspack_frontend_registration_rate_limit', $set_limit ); + + $base_body = [ + 'integration_id' => self::$integration_id, + 'integration_key' => self::generate_key( self::$integration_id ), + ]; + + // First two requests should succeed or return non-429 errors. + // Reset current user between requests since successful registration authenticates the reader. + $this->do_register_request( array_merge( $base_body, [ 'npe' => 'rate1@test.com' ] ) ); + wp_set_current_user( 0 ); + $this->do_register_request( array_merge( $base_body, [ 'npe' => 'rate2@test.com' ] ) ); + wp_set_current_user( 0 ); + + // Third request should be rate-limited. + $response = $this->do_register_request( array_merge( $base_body, [ 'npe' => 'rate3@test.com' ] ) ); + $this->assertEquals( 429, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'rate_limit_exceeded', $data['code'] ); + + remove_filter( 'newspack_frontend_registration_rate_limit', $set_limit ); + + // Clean up created users. + foreach ( [ 'rate1@test.com', 'rate2@test.com' ] as $email ) { + $user = get_user_by( 'email', $email ); + if ( $user ) { + wp_delete_user( $user->ID ); + } + } + } + + /** + * Test that a race condition WP_Error with "existing user" code maps to 409. + * + * Simulates a race where another request creates the user between + * register_reader()'s exists check and its wp_insert_user() call. + */ + public function test_race_condition_existing_user_returns_409() { + $race_email = 'race-test@test.com'; + + // This filter fires inside canonize_user_data(), after the exists check + // but before wp_insert_user(). Creating the user here simulates a race. + $create_user_during_insert = function( $user_data ) use ( $race_email ) { + if ( ! empty( $user_data['user_email'] ) && $user_data['user_email'] === $race_email ) { + wp_insert_user( + [ + 'user_login' => 'race-user', + 'user_email' => $race_email, + 'user_pass' => wp_generate_password(), + 'role' => 'subscriber', + ] + ); + } + return $user_data; + }; + add_filter( 'newspack_register_reader_user_data', $create_user_during_insert ); + + $response = $this->do_register_request( + [ + 'npe' => $race_email, + 'integration_id' => self::$integration_id, + 'integration_key' => self::generate_key( self::$integration_id ), + ] + ); + $this->assertEquals( 409, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'reader_already_exists', $data['code'] ); + + remove_filter( 'newspack_register_reader_user_data', $create_user_during_insert ); + + $user = get_user_by( 'email', $race_email ); + if ( $user ) { + wp_delete_user( $user->ID ); + } + } + + /** + * Test that current_page_url is normalized from the HTTP referer. + * + * The endpoint should parse the referer, extract the path, and rebuild + * it with home_url() — matching the process_auth_form() convention. + */ + public function test_current_page_url_normalization() { + $test_email = 'referer-test@test.com'; + + // Set a referer with query params and fragment that should be stripped. + $_SERVER['HTTP_REFERER'] = home_url( '/sample-page/?foo=bar&baz=1#section' ); + + $response = $this->do_register_request( + [ + 'npe' => $test_email, + 'integration_id' => self::$integration_id, + 'integration_key' => self::generate_key( self::$integration_id ), + ] + ); + $this->assertEquals( 201, $response->get_status() ); + + $user = get_user_by( 'email', $test_email ); + $this->assertInstanceOf( 'WP_User', $user ); + + $registration_page = get_user_meta( $user->ID, Reader_Activation::REGISTRATION_PAGE, true ); + // Should be normalized to just the path on the home URL, no query params. + $this->assertEquals( home_url( '/sample-page/' ), $registration_page ); + + unset( $_SERVER['HTTP_REFERER'] ); + wp_delete_user( $user->ID ); + } + + /** + * Test that the reCAPTCHA verify filter controls the verification attempt. + * + * When the filter returns true, the endpoint enters the verification block + * and calls verify_captcha(). In the test environment reCAPTCHA is not + * configured, so verify_captcha() short-circuits to true (passes). + * This test confirms the filter is respected and the $_POST bridge + * sets and cleans up the token correctly. + */ + public function test_recaptcha_filter_forces_verification() { + $captcha_email = 'captcha-test@test.com'; + $token_value = 'test-recaptcha-token'; + + // Force reCAPTCHA verification on, regardless of configuration. + $force_verify = function() { + return true; + }; + add_filter( 'newspack_recaptcha_verify_captcha', $force_verify ); + + $response = $this->do_register_request( + [ + 'npe' => $captcha_email, + 'integration_id' => self::$integration_id, + 'integration_key' => self::generate_key( self::$integration_id ), + 'g-recaptcha-response' => $token_value, + ] + ); + // verify_captcha() returns true when not configured, so registration succeeds. + $this->assertEquals( 201, $response->get_status() ); + // Verify $_POST was cleaned up after the bridge. + $this->assertArrayNotHasKey( 'g-recaptcha-response', $_POST ); // phpcs:ignore WordPress.Security.NonceVerification.Missing + + remove_filter( 'newspack_recaptcha_verify_captcha', $force_verify ); + + $user = get_user_by( 'email', $captcha_email ); + if ( $user ) { + wp_delete_user( $user->ID ); + } + } + + /** + * Test that the reCAPTCHA verify filter can disable verification. + * + * When forced off, registration should succeed even if reCAPTCHA + * would otherwise be required. + */ + public function test_recaptcha_filter_disables_verification() { + $disable_verify = function() { + return false; + }; + add_filter( 'newspack_recaptcha_verify_captcha', $disable_verify ); + + $recaptcha_email = 'captcha-disabled@test.com'; + $response = $this->do_register_request( + [ + 'npe' => $recaptcha_email, + 'integration_id' => self::$integration_id, + 'integration_key' => self::generate_key( self::$integration_id ), + ] + ); + $this->assertEquals( 201, $response->get_status() ); + + remove_filter( 'newspack_recaptcha_verify_captcha', $disable_verify ); + + $user = get_user_by( 'email', $recaptcha_email ); + if ( $user ) { + wp_delete_user( $user->ID ); + } + } + + /** + * Test that the integration registry returns filtered integrations. + */ + public function test_get_frontend_registration_integrations() { + // The test integration is registered via the filter in set_up(). + $integrations = Reader_Registration::get_frontend_registration_integrations(); + $this->assertArrayHasKey( self::$integration_id, $integrations ); + $this->assertEquals( 'Test Integration', $integrations[ self::$integration_id ] ); + } + + /** + * Test that the integration registry is empty without the filter. + */ + public function test_get_frontend_registration_integrations_empty_without_filter() { + remove_filter( 'newspack_frontend_registration_integrations', [ __CLASS__, 'register_test_integration' ] ); + + $integrations = Reader_Registration::get_frontend_registration_integrations(); + $this->assertEmpty( $integrations ); + + // Re-add for tear_down consistency. + add_filter( 'newspack_frontend_registration_integrations', [ __CLASS__, 'register_test_integration' ] ); + } + + /** + * Test that integration key generation is deterministic and unique per ID. + */ + public function test_integration_key_determinism_and_uniqueness() { + $key_a_first = Reader_Registration::get_frontend_registration_key( 'integration-a' ); + $key_a_second = Reader_Registration::get_frontend_registration_key( 'integration-a' ); + $key_b = Reader_Registration::get_frontend_registration_key( 'integration-b' ); + + // Same ID produces the same key. + $this->assertEquals( $key_a_first, $key_a_second ); + // Different IDs produce different keys. + $this->assertNotEquals( $key_a_first, $key_b ); + // Keys are 64-character hex strings (SHA-256). + $this->assertMatchesRegularExpression( '/^[a-f0-9]{64}$/', $key_a_first ); + $this->assertMatchesRegularExpression( '/^[a-f0-9]{64}$/', $key_b ); + } + + /** + * Test registration via an Integration subclass with default HMAC key. + */ + public function test_register_via_integration_subclass() { + $integration = new Test_Frontend_Integration( 'subclass-test', 'Subclass Test' ); + Integrations::register( $integration ); + + $response = $this->do_register_request( + [ + 'npe' => 'subclass@test.com', + 'integration_id' => 'subclass-test', + 'integration_key' => $integration->get_registration_key(), + ] + ); + $this->assertEquals( 201, $response->get_status() ); + $data = $response->get_data(); + $this->assertTrue( $data['success'] ); + + $user = get_user_by( 'email', 'subclass@test.com' ); + if ( $user ) { + wp_delete_user( $user->ID ); + } + } + + /** + * Test that a custom key validation override is used. + */ + public function test_custom_key_validation() { + $integration = new Test_Custom_Key_Integration( 'custom-key-test', 'Custom Key Test' ); + Integrations::register( $integration ); + + // The public key (from get_registration_key) should NOT validate. + $response = $this->do_register_request( + [ + 'npe' => 'custom@test.com', + 'integration_id' => 'custom-key-test', + 'integration_key' => 'custom-public-key', + ] + ); + $this->assertEquals( 403, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'invalid_integration_key', $data['code'] ); + + // The secret key should validate via the custom validate_registration_request(). + $response = $this->do_register_request( + [ + 'npe' => 'custom@test.com', + 'integration_id' => 'custom-key-test', + 'integration_key' => 'custom-secret-key', + ] + ); + $this->assertEquals( 201, $response->get_status() ); + + $user = get_user_by( 'email', 'custom@test.com' ); + if ( $user ) { + wp_delete_user( $user->ID ); + } + } + + /** + * Test that Integration subclass is included in get_frontend_registration_integrations(). + */ + public function test_integration_subclass_in_registry() { + $integration = new Test_Frontend_Integration( 'registry-test', 'Registry Test' ); + Integrations::register( $integration ); + + $integrations = Reader_Registration::get_frontend_registration_integrations(); + $this->assertArrayHasKey( 'registry-test', $integrations ); + $this->assertEquals( 'Registry Test', $integrations['registry-test'] ); + } +}