Security follow-up to v1.5.1. Closes four items from the v1.5.1 review — two ship-blockers and two mediums — all within the ADR-0001 envelope. PSEC-007 — peptide_starter_get_client_ip() now trusts CF-Connecting-IP only when REMOTE_ADDR falls inside a published Cloudflare edge range. Previously, any direct-to-origin request (which Hostinger permits) could forge its rate-limit key by setting the header to a different IP per attempt, bypassing every abuse control in the theme. New module inc/cloudflare-ips.php embeds a dated snapshot of CF v4/v6 ranges and a CIDR matcher covering both families; list is overridable via the peptide_starter_cloudflare_ip_ranges filter for out-of-band updates. HTTP_X_FORWARDED_FOR stays ignored unless operators opt in via the peptide_starter_trust_xff filter. PSEC-008 — Removed the v1.5.1 user-verification migration (Option A as confirmed by peptiderepo). The sync-wp_mail-per-subscriber loop on admin_init would have blocked wp-admin and hammered deliverability. Existing accounts are now grandfathered: no ps_pending_verification meta = verified. Verification applies only to registrations from v1.5.2 forward. The ps_verify_migration_version option (written by v1.5.1) is cleared on theme activation. PSEC-009 — Registration validation no longer short-circuits. All five checks (username pattern, email validity, password length, username_exists, email_exists) evaluate unconditionally before the combined rejection. Closes the response-time oracle that let an attacker distinguish microsecond-fast regex rejection from DB-round-trip rejection and infer which field triggered the failure. PSEC-010 — Added the happy-path verify test that v1.5.1 was missing. Covers token match, user meta cleanup, auth cookie set, and the peptide_starter_user_verified action firing with the correct user ID. Seven new rate-limiter tests confirm CF-peer validation works end-to-end: trusted CF peer honours the header, untrusted peer ignores spoofed headers, invalid values fall through, XFF default-off, XFF filter opt-in, CF range filter override, IPv6 CIDR match, family mismatch rejection. Confirmed with peptiderepo before coding: (1) Option A for PSEC-008 because zero users; (2) Cloudflare IS in front of the origin (dig confirms 104.21.x / 172.67.x + CF-Ray response header), so PSEC-007 is a real fix not dead code; (3) no staging — treat prod as staging. ARCHITECTURE.md gains a client-IP trust model section; CONVENTIONS.md documents the header-trust rules for future modules. Version bumped to 1.5.2 in style.css and functions.php. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
242 lines
8.4 KiB
PHP
242 lines
8.4 KiB
PHP
<?php
|
|
/**
|
|
* Email Verification Flow
|
|
*
|
|
* Generates signed tokens, sends verification email, handles the /verify
|
|
* click-through, and exposes a resend endpoint + gate helper for downstream
|
|
* handlers that should refuse writes from unverified accounts.
|
|
*
|
|
* @see inc/auth-handlers.php — calls send_verification_email after register
|
|
* @see inc/config.php — supplies verify_token_ttl and verify_resend limit
|
|
* @see inc/contact-handler.php — gates on peptide_starter_user_is_verified()
|
|
* @see page-profile.php — renders the "verify your email" notice
|
|
* @see functions.php — includes this file
|
|
*
|
|
* What: Token-gated email verification for new registrations.
|
|
* Who calls it: Auth handlers, /verify rewrite, AJAX resend endpoint.
|
|
* Dependencies: wp_mail, user meta (ps_verify_token, ps_verify_expires,
|
|
* ps_pending_verification), Peptide_Starter_Rate_Limiter.
|
|
*
|
|
* @package peptide-starter
|
|
*/
|
|
|
|
// Exit if accessed directly.
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Generate a verification token, persist it, and email the user.
|
|
*
|
|
* Side effects:
|
|
* - Sets user meta ps_pending_verification, ps_verify_token, ps_verify_expires.
|
|
* - Calls wp_mail (network I/O).
|
|
* - Logs wp_mail return value via error_log at debug level for triage.
|
|
*
|
|
* Event: fires 'peptide_starter_verification_sent' with ( $user_id, $sent_ok ).
|
|
* No listeners registered in-theme; plugins may attach for auditing.
|
|
*
|
|
* @param int $user_id Target user.
|
|
* @return bool wp_mail() return value; true on dispatch, false on failure.
|
|
*/
|
|
function peptide_starter_send_verification_email( $user_id ) {
|
|
$user = get_user_by( 'id', (int) $user_id );
|
|
if ( ! $user ) {
|
|
return false;
|
|
}
|
|
|
|
$token = wp_generate_password( 43, false, false );
|
|
$ttl = (int) peptide_starter_config_get( 'verify_token_ttl', DAY_IN_SECONDS );
|
|
$expires = time() + $ttl;
|
|
|
|
update_user_meta( $user_id, 'ps_pending_verification', 1 );
|
|
update_user_meta( $user_id, 'ps_verify_token', $token );
|
|
update_user_meta( $user_id, 'ps_verify_expires', $expires );
|
|
|
|
$verify_url = add_query_arg(
|
|
array(
|
|
'uid' => $user_id,
|
|
'token' => $token,
|
|
),
|
|
home_url( '/verify' )
|
|
);
|
|
|
|
$subject = sprintf(
|
|
/* translators: %s: site name */
|
|
__( '[%s] Verify your email address', 'peptide-starter' ),
|
|
wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES )
|
|
);
|
|
|
|
$body = sprintf(
|
|
/* translators: %s: user display name */
|
|
__( 'Hi %s,', 'peptide-starter' ),
|
|
$user->display_name
|
|
) . "\n\n";
|
|
$body .= __( 'Thanks for registering with Peptide Repo. To activate your account, click the link below within 24 hours:', 'peptide-starter' ) . "\n\n";
|
|
$body .= $verify_url . "\n\n";
|
|
$body .= __( "If you didn't create this account, you can safely ignore this email — it will expire on its own.", 'peptide-starter' ) . "\n\n";
|
|
$body .= __( '— Peptide Repo', 'peptide-starter' );
|
|
|
|
$headers = array( 'Content-Type: text/plain; charset=UTF-8' );
|
|
|
|
$sent = wp_mail( $user->user_email, $subject, $body, $headers );
|
|
|
|
// Log for deliverability triage; never log the token or body.
|
|
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
|
|
error_log( sprintf( 'peptide_starter_verify_mail user=%d sent=%s', $user_id, $sent ? '1' : '0' ) );
|
|
|
|
/**
|
|
* Fires after the verification email dispatch attempt.
|
|
*
|
|
* @param int $user_id Recipient user ID.
|
|
* @param bool $sent Whether wp_mail returned true.
|
|
*/
|
|
do_action( 'peptide_starter_verification_sent', $user_id, (bool) $sent );
|
|
|
|
return (bool) $sent;
|
|
}
|
|
|
|
/**
|
|
* Whether a user has completed email verification.
|
|
*
|
|
* Users without the ps_pending_verification meta are treated as verified.
|
|
* This includes all accounts created before v1.5.2 — the v1.5.1 re-verify
|
|
* migration was removed in v1.5.2 (see CHANGELOG § [1.5.2] PSEC-008);
|
|
* existing accounts are grandfathered. Verification is enforced on new
|
|
* registrations from v1.5.2 forward.
|
|
*
|
|
* @param int $user_id User ID.
|
|
* @return bool True if verified or exempt; false if pending.
|
|
*/
|
|
function peptide_starter_user_is_verified( $user_id ) {
|
|
$pending = get_user_meta( (int) $user_id, 'ps_pending_verification', true );
|
|
return '1' !== (string) $pending;
|
|
}
|
|
|
|
/**
|
|
* Add the /verify rewrite rule and the query var it emits.
|
|
*
|
|
* @return void
|
|
*/
|
|
function peptide_starter_register_verify_rewrite() {
|
|
add_rewrite_rule( '^verify/?$', 'index.php?ps_verify=1', 'top' );
|
|
}
|
|
add_action( 'init', 'peptide_starter_register_verify_rewrite' );
|
|
|
|
/**
|
|
* Register the ps_verify query var.
|
|
*
|
|
* @param array $vars Existing query vars.
|
|
* @return array
|
|
*/
|
|
function peptide_starter_register_verify_query_var( $vars ) {
|
|
$vars[] = 'ps_verify';
|
|
return $vars;
|
|
}
|
|
add_filter( 'query_vars', 'peptide_starter_register_verify_query_var' );
|
|
|
|
/**
|
|
* Handle a /verify click-through.
|
|
*
|
|
* Timing-safe token comparison. On success: clears verify meta, logs the
|
|
* user in, redirects to /profile?verified=1. On failure: redirects to
|
|
* /auth?verify_error=1.
|
|
*
|
|
* Side effects: deletes user meta, sets auth cookie, emits 302.
|
|
*
|
|
* @return void
|
|
*/
|
|
function peptide_starter_handle_verify_request() {
|
|
if ( ! (int) get_query_var( 'ps_verify' ) ) {
|
|
return;
|
|
}
|
|
|
|
$uid = isset( $_GET['uid'] ) ? (int) $_GET['uid'] : 0;
|
|
$token = isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( $_GET['token'] ) ) : '';
|
|
|
|
if ( $uid <= 0 || '' === $token ) {
|
|
wp_safe_redirect( home_url( '/auth?verify_error=1' ) );
|
|
exit;
|
|
}
|
|
|
|
$stored_token = (string) get_user_meta( $uid, 'ps_verify_token', true );
|
|
$stored_expires = (int) get_user_meta( $uid, 'ps_verify_expires', true );
|
|
|
|
if ( '' === $stored_token || $stored_expires < time() || ! hash_equals( $stored_token, $token ) ) {
|
|
wp_safe_redirect( home_url( '/auth?verify_error=1' ) );
|
|
exit;
|
|
}
|
|
|
|
delete_user_meta( $uid, 'ps_pending_verification' );
|
|
delete_user_meta( $uid, 'ps_verify_token' );
|
|
delete_user_meta( $uid, 'ps_verify_expires' );
|
|
|
|
wp_set_current_user( $uid );
|
|
wp_set_auth_cookie( $uid, true, is_ssl() );
|
|
|
|
/**
|
|
* Fires when a user successfully verifies their email.
|
|
*
|
|
* @param int $uid Verified user ID.
|
|
*/
|
|
do_action( 'peptide_starter_user_verified', $uid );
|
|
|
|
wp_safe_redirect( home_url( '/profile?verified=1' ) );
|
|
exit;
|
|
}
|
|
add_action( 'template_redirect', 'peptide_starter_handle_verify_request' );
|
|
|
|
/**
|
|
* AJAX endpoint: resend a verification email.
|
|
*
|
|
* Rate-limited via the 'verify_resend' action. Always responds with a
|
|
* generic success to avoid leaking whether the address is registered.
|
|
*
|
|
* Side effects: may call peptide_starter_send_verification_email().
|
|
*
|
|
* @return void Sends JSON and exits.
|
|
*/
|
|
function peptide_starter_ajax_resend_verify() {
|
|
if ( ! isset( $_POST['ps_resend_nonce'] ) ||
|
|
! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['ps_resend_nonce'] ) ), 'ps_resend_verify' ) ) {
|
|
wp_send_json_error( array( 'message' => __( 'Security check failed. Please refresh and try again.', 'peptide-starter' ) ) );
|
|
}
|
|
|
|
$email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '';
|
|
if ( ! is_email( $email ) ) {
|
|
wp_send_json_success( array( 'message' => __( 'If that address needs a verification link, one has been sent.', 'peptide-starter' ) ) );
|
|
}
|
|
|
|
$identifier = peptide_starter_hash_identifier( strtolower( $email ) );
|
|
if ( ! Peptide_Starter_Rate_Limiter::check( 'verify_resend', $identifier ) ) {
|
|
wp_send_json_success( array( 'message' => __( 'If that address needs a verification link, one has been sent.', 'peptide-starter' ) ) );
|
|
}
|
|
Peptide_Starter_Rate_Limiter::record( 'verify_resend', $identifier );
|
|
|
|
$user = get_user_by( 'email', $email );
|
|
if ( $user && ! peptide_starter_user_is_verified( $user->ID ) ) {
|
|
peptide_starter_send_verification_email( $user->ID );
|
|
}
|
|
|
|
wp_send_json_success( array( 'message' => __( 'If that address needs a verification link, one has been sent.', 'peptide-starter' ) ) );
|
|
}
|
|
add_action( 'wp_ajax_nopriv_ps_resend_verify', 'peptide_starter_ajax_resend_verify' );
|
|
add_action( 'wp_ajax_ps_resend_verify', 'peptide_starter_ajax_resend_verify' );
|
|
|
|
/**
|
|
* Flush rewrite rules when the /verify rule is freshly registered.
|
|
*
|
|
* Idempotent — guarded by an option flag so we only flush once per release.
|
|
*
|
|
* @return void
|
|
*/
|
|
function peptide_starter_maybe_flush_verify_rewrite() {
|
|
$stamp = get_option( 'peptide_starter_verify_rewrite_version' );
|
|
if ( PEPTIDE_STARTER_VERSION === $stamp ) {
|
|
return;
|
|
}
|
|
flush_rewrite_rules( false );
|
|
update_option( 'peptide_starter_verify_rewrite_version', PEPTIDE_STARTER_VERSION, false );
|
|
}
|
|
add_action( 'init', 'peptide_starter_maybe_flush_verify_rewrite', 20 );
|