Skip to content
Draft
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
0250b2a
feat: initial reader registration API rollup from working branch
jason10lee Apr 2, 2026
96516b2
docs: annotated new functions with type hints
jason10lee Apr 2, 2026
e38a94f
fix: centralize, normalize definition of `$referer`
jason10lee Apr 2, 2026
ea9af30
fix: use returned status rather than hardcoded value
jason10lee Apr 2, 2026
e0b2de8
test: annotate with group like we do elsewhere for isolated testing
jason10lee Apr 2, 2026
d206c54
docs: note to self about parameterizing `verify_captcha()`
jason10lee Apr 2, 2026
f143df2
docs: note about better mitigating attacks
jason10lee Apr 2, 2026
4d2fcd4
docs: note potential disclosure and mitigation path
jason10lee Apr 2, 2026
3200cc0
fix: merge into existing `reader` now that we are out of POC
jason10lee Apr 3, 2026
a70d5ff
fix: condition reCAPTCHA v3 actions on their `ready()`
jason10lee Apr 3, 2026
ea549c1
style: explicit fallback for `wp_parse_url()` as suggested by Copilot
jason10lee Apr 3, 2026
18de923
fix: make endpoint available only when RAS is enabled, per Copilot
jason10lee Apr 3, 2026
689107b
test: properly tear down our new routes
jason10lee Apr 3, 2026
56ba6ea
feat: localize reCAPTCHA site key and version for both v2 and v3
jason10lee Apr 3, 2026
60b0e77
feat: add reCAPTCHA v2 invisible support to register()
jason10lee Apr 3, 2026
f224df8
fix: move grecaptcha.execute inside try block to prevent Promise leak
jason10lee Apr 3, 2026
9cfcc30
fix: add isolated flag to v2 invisible widget to prevent interference
jason10lee Apr 3, 2026
8fd1a17
fix: add 30s timeout to v2 invisible token acquisition to prevent hang
jason10lee Apr 3, 2026
334c6d0
docs: note potential concurrent-call guard for v2 invisible token
jason10lee Apr 3, 2026
dd4c46b
fix: gracefully reject calls if essential config is missing
jason10lee Apr 3, 2026
9941580
docs: potential future directions for rate limiting
jason10lee Apr 3, 2026
4fbb990
fix: address potential race condition on multiple registrations with …
jason10lee Apr 3, 2026
be59918
fix: ensure idempotency by making sure callers get current reader dat…
jason10lee Apr 3, 2026
22c87af
fix: condition config output on RAS
jason10lee Apr 3, 2026
56e699f
fix: use server-side email, not submitted email, for logged-in users
jason10lee Apr 3, 2026
099a109
fix: reject Promise and provide helpful error if reCAPTCHA not happy
jason10lee Apr 3, 2026
0ec69a2
fix: move reCAPTCHA behind rate limiting to protect metered service f…
jason10lee Apr 3, 2026
4d88837
test: regression test for race condition
jason10lee Apr 4, 2026
e6d6216
test: test our referrer normalization
jason10lee Apr 4, 2026
59a2242
test: regression test reCAPTCHA at the filter level, as mocking the A…
jason10lee Apr 4, 2026
7fb7989
test: verify registry gating on RAS
jason10lee Apr 4, 2026
4fbf3e0
test: verify stability of our integration keys
jason10lee Apr 4, 2026
e6826ff
refactor: extract Reader_Registration class from Reader_Activation
jason10lee Apr 9, 2026
4341739
refactor: delegate frontend registration to Reader_Registration class
jason10lee Apr 9, 2026
b224401
test: update references to Reader_Registration class
jason10lee Apr 9, 2026
b0074c0
fix: add `use` statements for clarity
jason10lee Apr 9, 2026
2b4d04f
feat: add overridable registration key methods to Integration base class
jason10lee Apr 9, 2026
6aba746
feat: delegate key generation and validation to Integration instances
jason10lee Apr 9, 2026
c3ad735
test: add tests for Integration-based key generation and validation
jason10lee Apr 9, 2026
9e48c6e
test: register integration within test because apparently PHPUnit doe…
jason10lee Apr 9, 2026
09236d8
docs: make it clear that overriding the validation step means writing…
jason10lee Apr 9, 2026
5a28ab9
Merge branch 'trunk' into feat/integrations-reader-registration
jason10lee Apr 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
300 changes: 297 additions & 3 deletions includes/reader-activation/class-reader-activation.php
Original file line number Diff line number Diff line change
Expand Up @@ -147,12 +147,26 @@ public static function enqueue_scripts() {
'is_ras_enabled' => self::is_enabled(),
];

$frontend_integrations = self::get_frontend_registration_integrations();
if ( ! empty( $frontend_integrations ) ) {
$integrations_config = [];
foreach ( $frontend_integrations as $id => $label ) {
$integrations_config[ $id ] = [
'key' => self::get_frontend_registration_key( $id ),
'label' => $label,
];
}
$script_data['frontend_registration_integrations'] = $integrations_config;
if ( self::is_enabled() ) {
$script_data['frontend_registration_url'] = \rest_url( NEWSPACK_API_NAMESPACE . '/reader-activation/register' );
}
}

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();
Expand Down Expand Up @@ -260,6 +274,286 @@ public static function register_routes() {
],
]
);

\register_rest_route(
NEWSPACK_API_NAMESPACE,
'/reader-activation/register',
[
'methods' => \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' => '',
],
'g-recaptcha-response' => [
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'default' => '',
],
],
]
);
}

/**
* Get registered frontend registration integrations.
*
* @return array<string, string> 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<string, string> $integrations Map of integration ID => display label.
*/
return \apply_filters( 'newspack_frontend_registration_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 {
return hash_hmac( 'sha256', $integration_id, \wp_salt( 'auth' ) );
}

/**
* 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 {
$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. User is not logged in
* 2. Reader Activation is enabled
* 3. Integration ID is registered
* 4. Integration key matches HMAC
* 5. Honeypot field is empty
* 6. reCAPTCHA (when configured)
* 7. Per-IP rate limit
* 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: Reject if caller is already logged in.
if ( \is_user_logged_in() ) {
return new \WP_Error(
'already_logged_in',
__( 'Registration is not available for logged-in users.', 'newspack-plugin' ),
[ 'status' => 403 ]
);
}

// Step 2: Check RAS is enabled.
if ( ! self::is_enabled() ) {
return new \WP_Error(
'reader_activation_disabled',
__( 'Reader Activation is not enabled.', 'newspack-plugin' ),
[ 'status' => 403 ]
);
}

// Step 3: Validate integration ID is registered.
$integration_id = $request->get_param( 'integration_id' );
$integrations = self::get_frontend_registration_integrations();
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 4: Validate integration key.
$integration_key = $request->get_param( 'integration_key' );
$expected_key = self::get_frontend_registration_key( $integration_id );
if ( ! hash_equals( $expected_key, $integration_key ) ) {
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: 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 7: Per-IP rate limit.
$rate_check = self::check_registration_rate_limit();
if ( \is_wp_error( $rate_check ) ) {
return $rate_check;
}

// 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 = self::register_reader( $email, $display_name, true, $metadata );

if ( \is_wp_error( $result ) ) {
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,
]
);
}

return new \WP_REST_Response(
[
'success' => true,
'status' => 'created',
'email' => $email,
],
201
);
}

/**
Expand Down
Loading
Loading