From d018411f92622a88ba271a7f89422f3493a7c028 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rabbi=20Islam=20Rony=20=E2=9A=A1=EF=B8=8F?= <35329385+RabbiIslamRony@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:02:38 +0600 Subject: [PATCH] fix: defer user creation until email verification --- blocks/templates/registration.php | 2 +- includes/classes/class-ajax-handler.php | 16 +- includes/classes/class-email.php | 79 ++- .../classes/class-pending-registration.php | 634 ++++++++++++++++++ includes/classes/class-user.php | 151 ++++- includes/helper-functions.php | 5 + templates/account/login-registration-form.php | 25 +- 7 files changed, 880 insertions(+), 32 deletions(-) create mode 100644 includes/classes/class-pending-registration.php diff --git a/blocks/templates/registration.php b/blocks/templates/registration.php index 72549201cd..3701b1e9af 100644 --- a/blocks/templates/registration.php +++ b/blocks/templates/registration.php @@ -82,7 +82,7 @@
If you did not sign up for this account you can ignore this email.
' + ); + + $site_name = get_option( 'blogname' ); + $site_url = site_url(); + $button_label = ! empty( $registration['generated_password'] ) ? __( 'Set Password And Confirm Email Address', 'directorist' ) : __( 'Confirm Email Address', 'directorist' ); + $button = sprintf( + '', + esc_url( $verification_url ), + esc_html( $button_label ) + ); + + $find_replace = [ + '==NAME==' => $display_name, + '==USERNAME==' => $registration['user_login'], + '==USER_EMAIL==' => $registration['user_email'], + '==SITE_NAME==' => $site_name, + '==SITE_LINK==' => sprintf( '%s', esc_url( $site_url ), esc_html( $site_name ) ), + '==SITE_URL==' => sprintf( '%s', esc_url( $site_url ), esc_html( $site_url ) ), + '==DASHBOARD_LINK==' => sprintf( '%s', esc_url( ATBDP_Permalink::get_dashboard_page_link() ), esc_html( ATBDP_Permalink::get_dashboard_page_link() ) ), + '==CONFIRM_EMAIL_ADDRESS_URL==' => $button, + '==SET_PASSWORD_AND_CONFIRM_EMAIL_ADDRESS_URL==' => $button, + '==USER_PASSWORD==' => '', + ]; + + $find_replace = apply_filters( 'directorist_replace_in_content', $find_replace, 0, $pending_user ); + + $subject = strtr( $subject, $find_replace ); + $body = nl2br( strtr( $body, $find_replace ) ); + $body = apply_filters( 'directorist_pending_registration_email_body', atbdp_email_html( $title, $body ), $registration, $verification_url ); + $sent = $this->send_mail( $registration['user_email'], $subject, $body, $this->get_email_headers() ); + + if ( $sent ) { + do_action( 'directorist_after_pending_registration_verification_email_sent', $registration, $subject, $body ); + } + + return $sent; + } } // ends class -endif; \ No newline at end of file +endif; diff --git a/includes/classes/class-pending-registration.php b/includes/classes/class-pending-registration.php new file mode 100644 index 0000000000..029b6225cd --- /dev/null +++ b/includes/classes/class-pending-registration.php @@ -0,0 +1,634 @@ +table_name = $wpdb->prefix . 'directorist_pending_registrations'; + + add_action( 'init', [ $this, 'maybe_install_schema' ], 5 ); + add_action( 'init', [ $this, 'schedule_cleanup' ], 20 ); + add_action( self::CLEANUP_HOOK, [ $this, 'cleanup_expired' ] ); + add_action( 'template_redirect', [ $this, 'handle_verification_request' ], 5 ); + } + + /** + * Create or update the private pending-registration table when required. + * + * @param bool $verify_table Whether to confirm that the versioned table exists. + */ + public function maybe_install_schema( $verify_table = false ) { + global $wpdb; + + if ( self::SCHEMA_VERSION === get_option( self::SCHEMA_OPTION ) ) { + if ( ! $verify_table || $this->table_name === $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $this->table_name ) ) ) ) { + return; + } + } + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + + $charset_collate = $wpdb->get_charset_collate(); + $sql = "CREATE TABLE {$this->table_name} ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + user_login varchar(60) NOT NULL, + user_email varchar(100) NOT NULL, + password_hash varchar(255) NOT NULL, + profile_data longtext NOT NULL, + token_hmac char(64) NOT NULL, + ip_hash char(64) NOT NULL, + status varchar(20) NOT NULL DEFAULT 'pending', + created_at datetime NOT NULL, + updated_at datetime NOT NULL, + last_sent_at datetime NOT NULL, + expires_at datetime NOT NULL, + verified_at datetime DEFAULT NULL, + resend_count int(10) unsigned NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY user_login (user_login), + UNIQUE KEY user_email (user_email), + UNIQUE KEY token_hmac (token_hmac), + KEY status_expires (status, expires_at), + KEY ip_created (ip_hash, created_at) + ) {$charset_collate};"; + + dbDelta( $sql ); + + if ( $this->table_name === $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $this->table_name ) ) ) ) { + update_option( self::SCHEMA_OPTION, self::SCHEMA_VERSION, false ); + } + } + + /** + * Schedule daily expired-record cleanup. + */ + public function schedule_cleanup() { + if ( ! wp_next_scheduled( self::CLEANUP_HOOK ) ) { + wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::CLEANUP_HOOK ); + } + } + + /** + * Delete records after their verification window has expired. + */ + public function cleanup_expired() { + global $wpdb; + + $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$this->table_name} WHERE expires_at < %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + current_time( 'mysql', true ) + ) + ); + } + + /** + * Reserve a frontend registration and send its verification email. + * + * @param array $registration Sanitized registration fields, including password. + * @return array|WP_Error Pending record details or an error. + */ + public function create( array $registration ) { + global $wpdb; + + $this->maybe_install_schema( true ); + + $user_login = sanitize_user( $registration['user_login'], true ); + $user_email = sanitize_email( $registration['user_email'] ); + + $rate_limit = $this->check_rate_limits( $user_email ); + if ( is_wp_error( $rate_limit ) ) { + return $rate_limit; + } + + $this->record_request( $user_email ); + + if ( 4 > strlen( $user_login ) || 60 < strlen( $user_login ) || ! is_email( $user_email ) ) { + return new WP_Error( 'registration_invalid', __( 'The username or email is not valid.', 'directorist' ) ); + } + + if ( username_exists( $user_login ) || email_exists( $user_email ) ) { + return new WP_Error( 'registration_exists', __( 'This username or email is already registered.', 'directorist' ) ); + } + + $existing = $wpdb->get_row( + $wpdb->prepare( + "SELECT * FROM {$this->table_name} WHERE user_login = %s OR user_email = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $user_login, + $user_email + ), + ARRAY_A + ); + + if ( $existing && 'pending' === $existing['status'] && strtotime( $existing['expires_at'] . ' UTC' ) > time() ) { + return new WP_Error( 'registration_pending', __( 'A verification request is already pending. Please check your email or use the resend link.', 'directorist' ) ); + } + + if ( $existing ) { + $wpdb->delete( $this->table_name, [ 'id' => (int) $existing['id'] ], [ '%d' ] ); + } + + $raw_token = $this->generate_token(); + $now = current_time( 'mysql', true ); + $profile_data = $registration; + $password = $profile_data['password']; + $generated = ! empty( $profile_data['generated_password'] ); + $verification_url = $this->get_verification_url( $raw_token ); + + unset( $profile_data['password'], $profile_data['user_login'], $profile_data['user_email'] ); + + $inserted = $wpdb->insert( + $this->table_name, + [ + 'user_login' => $user_login, + 'user_email' => $user_email, + 'password_hash' => wp_hash_password( $password ), + 'profile_data' => wp_json_encode( $profile_data ), + 'token_hmac' => $this->hash_token( $raw_token ), + 'ip_hash' => $this->get_ip_hash(), + 'status' => 'pending', + 'created_at' => $now, + 'updated_at' => $now, + 'last_sent_at' => $now, + 'expires_at' => gmdate( 'Y-m-d H:i:s', time() + DAY_IN_SECONDS ), + 'verified_at' => null, + 'resend_count' => 0, + ], + [ '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d' ] + ); + + if ( ! $inserted ) { + return new WP_Error( 'registration_storage_failed', __( 'The registration could not be saved. Please try again.', 'directorist' ) ); + } + + $pending_id = (int) $wpdb->insert_id; + + $mail_data = [ + 'user_login' => $user_login, + 'user_email' => $user_email, + 'first_name' => isset( $registration['first_name'] ) ? $registration['first_name'] : '', + 'last_name' => isset( $registration['last_name'] ) ? $registration['last_name'] : '', + 'generated_password' => $generated, + ]; + + if ( ! ATBDP()->email->send_pending_user_confirmation_email( $mail_data, $verification_url ) ) { + $wpdb->update( + $this->table_name, + [ 'status' => 'failed', 'updated_at' => current_time( 'mysql', true ) ], + [ 'id' => $pending_id, 'status' => 'pending' ], + [ '%s', '%s' ], + [ '%d', '%s' ] + ); + + return new WP_Error( 'registration_email_failed', __( 'The verification email could not be sent. Please try again later.', 'directorist' ) ); + } + + return [ + 'id' => $pending_id, + 'user_email' => $user_email, + ]; + } + + /** + * Resend a pending verification email with a rotated token. + * + * @param string $email Pending email address. + * @return true|WP_Error True on success or an error. + */ + public function resend( $email ) { + global $wpdb; + + $this->maybe_install_schema( true ); + + $email = sanitize_email( $email ); + + $rate_limit = $this->check_rate_limits( $email ); + if ( is_wp_error( $rate_limit ) ) { + return $rate_limit; + } + + $this->record_request( $email ); + + $row = $wpdb->get_row( + $wpdb->prepare( + "SELECT * FROM {$this->table_name} WHERE user_email = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $email + ), + ARRAY_A + ); + + if ( ! $row || 'pending' !== $row['status'] || strtotime( $row['expires_at'] . ' UTC' ) <= time() ) { + return new WP_Error( 'registration_not_pending', __( 'No active verification request was found.', 'directorist' ) ); + } + + if ( strtotime( $row['last_sent_at'] . ' UTC' ) > time() - ( 5 * MINUTE_IN_SECONDS ) ) { + return new WP_Error( 'registration_resend_cooldown', __( 'Please wait five minutes before requesting another verification email.', 'directorist' ) ); + } + + $profile_data = json_decode( $row['profile_data'], true ); + if ( ! is_array( $profile_data ) ) { + $profile_data = []; + } + + $raw_token = $this->generate_token(); + $token_hmac = $this->hash_token( $raw_token ); + $previous_hmac = $row['token_hmac']; + $previous_sent_at = $row['last_sent_at']; + $now = current_time( 'mysql', true ); + + $updated = $wpdb->update( + $this->table_name, + [ + 'token_hmac' => $token_hmac, + 'updated_at' => $now, + 'last_sent_at' => $now, + 'expires_at' => gmdate( 'Y-m-d H:i:s', time() + DAY_IN_SECONDS ), + 'resend_count' => (int) $row['resend_count'] + 1, + ], + [ 'id' => (int) $row['id'], 'token_hmac' => $previous_hmac, 'status' => 'pending' ], + [ '%s', '%s', '%s', '%s', '%d' ], + [ '%d', '%s', '%s' ] + ); + + if ( 1 !== $updated ) { + return new WP_Error( 'registration_resend_failed', __( 'The verification email could not be resent. Please try again.', 'directorist' ) ); + } + + $mail_data = [ + 'user_login' => $row['user_login'], + 'user_email' => $row['user_email'], + 'first_name' => isset( $profile_data['first_name'] ) ? $profile_data['first_name'] : '', + 'last_name' => isset( $profile_data['last_name'] ) ? $profile_data['last_name'] : '', + 'generated_password' => ! empty( $profile_data['generated_password'] ), + ]; + + if ( ! ATBDP()->email->send_pending_user_confirmation_email( $mail_data, $this->get_verification_url( $raw_token ) ) ) { + $wpdb->update( + $this->table_name, + [ 'token_hmac' => $previous_hmac, 'last_sent_at' => $previous_sent_at ], + [ 'id' => (int) $row['id'], 'token_hmac' => $token_hmac, 'status' => 'pending' ], + [ '%s', '%s' ], + [ '%d', '%s', '%s' ] + ); + + return new WP_Error( 'registration_email_failed', __( 'The verification email could not be sent. Please try again later.', 'directorist' ) ); + } + + return true; + } + + /** + * Exchange a raw pending token for one WordPress user. + * + * @param string $raw_token Raw token from the email URL. + * @return array|WP_Error Verification result or an error. + */ + public function verify( $raw_token ) { + global $wpdb; + + if ( ! is_string( $raw_token ) || ! preg_match( '/\A[a-f0-9]{64}\z/', $raw_token ) ) { + return new WP_Error( 'invalid_token', __( 'This verification link is invalid.', 'directorist' ) ); + } + + $this->maybe_install_schema( true ); + $token_hmac = $this->hash_token( $raw_token ); + + $wpdb->query( 'START TRANSACTION' ); + + $row = $wpdb->get_row( + $wpdb->prepare( + "SELECT * FROM {$this->table_name} WHERE token_hmac = %s LIMIT 1 FOR UPDATE", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $token_hmac + ), + ARRAY_A + ); + + if ( ! $row ) { + $wpdb->query( 'ROLLBACK' ); + return new WP_Error( 'invalid_token', __( 'This verification link is invalid or has already been used.', 'directorist' ) ); + } + + if ( 'pending' !== $row['status'] ) { + $wpdb->query( 'ROLLBACK' ); + return new WP_Error( 'used_token', __( 'This verification link has already been used.', 'directorist' ) ); + } + + if ( strtotime( $row['expires_at'] . ' UTC' ) <= time() ) { + $wpdb->update( $this->table_name, [ 'status' => 'expired' ], [ 'id' => (int) $row['id'] ], [ '%s' ], [ '%d' ] ); + $wpdb->query( 'COMMIT' ); + return new WP_Error( 'expired_token', __( 'This verification link has expired.', 'directorist' ) ); + } + + $claimed = $wpdb->update( + $this->table_name, + [ 'status' => 'processing', 'updated_at' => current_time( 'mysql', true ) ], + [ 'id' => (int) $row['id'], 'status' => 'pending' ], + [ '%s', '%s' ], + [ '%d', '%s' ] + ); + + if ( 1 !== $claimed ) { + $wpdb->query( 'ROLLBACK' ); + return new WP_Error( 'used_token', __( 'This verification link has already been used.', 'directorist' ) ); + } + + if ( username_exists( $row['user_login'] ) || email_exists( $row['user_email'] ) ) { + $wpdb->update( $this->table_name, [ 'status' => 'failed' ], [ 'id' => (int) $row['id'] ], [ '%s' ], [ '%d' ] ); + $wpdb->query( 'COMMIT' ); + return new WP_Error( 'registration_conflict', __( 'That username or email is no longer available.', 'directorist' ) ); + } + + $profile_data = json_decode( $row['profile_data'], true ); + if ( ! is_array( $profile_data ) ) { + $profile_data = []; + } + + $password_filter = static function ( $data, $update, $user_id, $userdata ) use ( $row ) { + if ( ! $update && isset( $userdata['user_login'], $userdata['user_email'] ) && $row['user_login'] === $userdata['user_login'] && $row['user_email'] === $userdata['user_email'] ) { + $data['user_pass'] = $row['password_hash']; + } + + return $data; + }; + + $user_hook_priority = has_action( 'user_register', [ ATBDP()->user, 'action_user_register' ] ); + if ( false !== $user_hook_priority ) { + remove_action( 'user_register', [ ATBDP()->user, 'action_user_register' ], $user_hook_priority ); + } + + $creation_error = null; + + try { + add_filter( 'wp_pre_insert_user_data', $password_filter, 10, 4 ); + + $user_id = wp_insert_user( + [ + 'user_login' => $row['user_login'], + 'user_email' => $row['user_email'], + 'user_pass' => wp_generate_password( 32, true, true ), + 'user_url' => isset( $profile_data['website'] ) ? $profile_data['website'] : '', + 'first_name' => isset( $profile_data['first_name'] ) ? $profile_data['first_name'] : '', + 'last_name' => isset( $profile_data['last_name'] ) ? $profile_data['last_name'] : '', + 'description' => isset( $profile_data['bio'] ) ? $profile_data['bio'] : '', + 'role' => 'subscriber', + ] + ); + } catch ( \Throwable $throwable ) { + $creation_error = new WP_Error( 'registration_creation_failed', __( 'The account could not be created. Please try again.', 'directorist' ) ); + } finally { + remove_filter( 'wp_pre_insert_user_data', $password_filter, 10 ); + + if ( false !== $user_hook_priority ) { + add_action( 'user_register', [ ATBDP()->user, 'action_user_register' ], $user_hook_priority ); + } + } + + if ( $creation_error ) { + $wpdb->query( 'ROLLBACK' ); + $wpdb->update( $this->table_name, [ 'status' => 'failed' ], [ 'id' => (int) $row['id'] ], [ '%s' ], [ '%d' ] ); + return $creation_error; + } + + if ( is_wp_error( $user_id ) || ! $user_id ) { + $wpdb->update( $this->table_name, [ 'status' => 'failed' ], [ 'id' => (int) $row['id'] ], [ '%s' ], [ '%d' ] ); + $wpdb->query( 'COMMIT' ); + return $user_id; + } + + $verified_at = current_time( 'mysql', true ); + $completed = $wpdb->update( + $this->table_name, + [ 'status' => 'verified', 'updated_at' => $verified_at, 'verified_at' => $verified_at ], + [ 'id' => (int) $row['id'], 'status' => 'processing' ], + [ '%s', '%s', '%s' ], + [ '%d', '%s' ] + ); + + if ( 1 !== $completed ) { + $wpdb->query( 'ROLLBACK' ); + clean_user_cache( $user_id ); + return new WP_Error( 'registration_creation_failed', __( 'The account could not be created. Please try again.', 'directorist' ) ); + } + + $wpdb->query( 'COMMIT' ); + + do_action( 'atbdp_user_registration_completed', $user_id ); + update_user_meta( $user_id, '_atbdp_privacy', isset( $profile_data['privacy_policy'] ) ? $profile_data['privacy_policy'] : '' ); + update_user_meta( $user_id, '_user_type', isset( $profile_data['user_type'] ) ? $profile_data['user_type'] : '' ); + update_user_meta( $user_id, '_atbdp_terms_and_conditions', isset( $profile_data['terms_and_conditions'] ) ? $profile_data['terms_and_conditions'] : '' ); + + wp_new_user_notification( $user_id, null, 'admin' ); + ATBDP()->email->custom_wp_new_user_notification_email( $user_id ); + + return [ + 'user_id' => (int) $user_id, + 'user_email' => $row['user_email'], + 'generated_password' => ! empty( $profile_data['generated_password'] ), + ]; + } + + /** + * Process pending verification links before rendering the account page. + */ + public function handle_verification_request() { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- The one-time HMAC token authenticates this request. + if ( empty( $_GET[ self::TOKEN_QUERY_ARG ] ) ) { + return; + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- The one-time HMAC token authenticates this request. + $raw_token = sanitize_text_field( wp_unslash( $_GET[ self::TOKEN_QUERY_ARG ] ) ); + $result = $this->verify( $raw_token ); + + if ( is_wp_error( $result ) ) { + wp_safe_redirect( + ATBDP_Permalink::get_signin_signup_page_link( + [ 'pending_registration_error' => $result->get_error_code() ] + ) + ); + exit; + } + + $query_args = [ 'pending_registration_verified' => 1 ]; + + if ( $result['generated_password'] ) { + $user = get_user_by( 'id', $result['user_id'] ); + $key = $user instanceof WP_User ? get_password_reset_key( $user ) : new WP_Error( 'invalid_user' ); + + if ( is_wp_error( $key ) ) { + $query_args = [ 'pending_registration_error' => 'set_password_failed' ]; + } else { + $query_args = [ + 'user' => base64_encode( $result['user_email'] ), + 'key' => $key, + 'password_reset' => 1, + 'pending_registration_verified' => 1, + ]; + } + } + + wp_safe_redirect( ATBDP_Permalink::get_signin_signup_page_link( $query_args ) ); + exit; + } + + /** + * Get a safe message for a verification result code. + * + * @param string $code Result code. + * @return string + */ + public function get_verification_error_message( $code ) { + $messages = [ + 'invalid_token' => __( 'This verification link is invalid or has already been used.', 'directorist' ), + 'used_token' => __( 'This verification link has already been used.', 'directorist' ), + 'expired_token' => __( 'This verification link has expired. Please register again.', 'directorist' ), + 'registration_conflict' => __( 'That username or email is no longer available. Please register again.', 'directorist' ), + 'registration_creation_failed' => __( 'The account could not be created. Please register again.', 'directorist' ), + 'set_password_failed' => __( 'Your account was verified, but the password form could not be opened. Please use password recovery.', 'directorist' ), + 'registration_not_pending' => __( 'No active verification request was found.', 'directorist' ), + 'registration_resend_cooldown' => __( 'Please wait five minutes before requesting another verification email.', 'directorist' ), + 'registration_rate_limited' => __( 'Too many registration requests. Please try again in one hour.', 'directorist' ), + 'registration_resend_failed' => __( 'The verification email could not be resent. Please try again.', 'directorist' ), + 'registration_email_failed' => __( 'The verification email could not be sent. Please try again later.', 'directorist' ), + ]; + + return isset( $messages[ $code ] ) ? $messages[ $code ] : $messages['invalid_token']; + } + + /** + * Convert a pending-registration error to a legacy form error number. + * + * @param WP_Error $error Registration error. + * @return int + */ + public function get_legacy_error_code( WP_Error $error ) { + $map = [ + 'registration_pending' => 10, + 'registration_rate_limited' => 11, + 'registration_email_failed' => 12, + 'registration_storage_failed' => 12, + 'registration_exists' => 4, + ]; + + return isset( $map[ $error->get_error_code() ] ) ? $map[ $error->get_error_code() ] : 12; + } + + /** + * Generate a cryptographically random URL token. + * + * @return string + */ + private function generate_token() { + try { + return bin2hex( random_bytes( 32 ) ); + } catch ( \Exception $exception ) { + return hash( 'sha256', wp_generate_password( 64, true, true ) . microtime( true ) ); + } + } + + /** + * HMAC a raw token before persistence. + * + * @param string $token Raw token. + * @return string + */ + private function hash_token( $token ) { + return hash_hmac( 'sha256', $token, wp_salt( 'auth' ) ); + } + + /** + * Create a salted, non-reversible client IP identifier. + * + * @return string + */ + private function get_ip_hash() { + $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; + $ip = apply_filters( 'directorist_registration_ip_address', $ip ); + + return hash_hmac( 'sha256', (string) $ip, wp_salt( 'nonce' ) ); + } + + /** + * Check one-hour registration request limits. + * + * @param string $email Registration email. + * @return true|WP_Error + */ + private function check_rate_limits( $email ) { + $keys = $this->get_rate_limit_keys( $email ); + + if ( (int) get_transient( $keys['ip'] ) >= 5 || (int) get_transient( $keys['email'] ) >= 3 ) { + return new WP_Error( 'registration_rate_limited', __( 'Too many registration requests. Please try again in one hour.', 'directorist' ) ); + } + + return true; + } + + /** + * Record a registration or resend request for one hour. + * + * @param string $email Registration email. + */ + private function record_request( $email ) { + foreach ( $this->get_rate_limit_keys( $email ) as $key ) { + set_transient( $key, (int) get_transient( $key ) + 1, HOUR_IN_SECONDS ); + } + } + + /** + * Build privacy-safe transient keys for rate limiting. + * + * @param string $email Registration email. + * @return array + */ + private function get_rate_limit_keys( $email ) { + return [ + 'ip' => 'directorist_reg_ip_' . substr( $this->get_ip_hash(), 0, 32 ), + 'email' => 'directorist_reg_email_' . substr( hash_hmac( 'sha256', strtolower( $email ), wp_salt( 'nonce' ) ), 0, 32 ), + ]; + } + + /** + * Build the pending verification URL. + * + * @param string $raw_token Raw token. + * @return string + */ + private function get_verification_url( $raw_token ) { + return ATBDP_Permalink::get_signin_signup_page_link( + [ self::TOKEN_QUERY_ARG => $raw_token ] + ); + } +} diff --git a/includes/classes/class-user.php b/includes/classes/class-user.php index a1dc60af60..a05b8e1e7f 100644 --- a/includes/classes/class-user.php +++ b/includes/classes/class-user.php @@ -19,7 +19,16 @@ * User class. */ class ATBDP_User { + /** + * Pending frontend registration manager. + * + * @var Directorist\Pending_Registration + */ + public $pending_registration; + public function __construct() { + $this->pending_registration = new Directorist\Pending_Registration(); + add_action( 'wp_loaded', [ $this, 'handle_user_registration' ] ); //add_action('init', array($this, 'activate_user')); add_filter( 'pre_get_posts', [ $this,'restrict_listing_to_the_author' ] ); @@ -87,6 +96,7 @@ public function directorist_register_form() { $terms_condition = ! empty( $params['enable_registration_terms'] ) && 'yes' === $params['enable_registration_terms'] ? 1 : 0; $username = ! empty( $_POST['username'] ) ? directorist_clean( wp_unslash( $_POST['username'] ) ) : ''; + $clean_username = sanitize_user( $username, true ); $password = ! empty( $_POST['password'] ) ? $_POST['password'] : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash $email = ! empty( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : ''; $website = ! empty( $_POST['website'] ) ? directorist_clean( wp_unslash( $_POST['website'] ) ) : ''; @@ -98,6 +108,8 @@ public function directorist_register_form() { $t_c_check = ! empty( $_POST['t_c_check'] ) ? directorist_clean( wp_unslash( $_POST['t_c_check'] ) ) : ''; $previous_page = ! empty( $_POST['previous_page'] ) ? directorist_clean( wp_unslash( $_POST['previous_page'] ) ) : ''; + $generated_password = empty( $display_password ) || empty( $password ); + //password validation if ( ! empty( $display_password ) && empty( $password ) ) { $password_validation = 'yes'; @@ -142,15 +154,19 @@ public function directorist_register_form() { if ( 'passed' !== $validation ) { $error_msg = ''; - if ( empty( $username ) || ! empty( $password_validation ) || empty( $email ) || ! empty( $website_validation ) || ! empty( $fname_validation ) || ! empty( $lname_validation ) || ! empty( $bio_validation ) || ! empty( $privacy_validation ) || ! empty( $t_c_validation ) ) { + if ( empty( $username ) || empty( $clean_username ) || ! empty( $password_validation ) || empty( $email ) || ! empty( $website_validation ) || ! empty( $fname_validation ) || ! empty( $lname_validation ) || ! empty( $bio_validation ) || ! empty( $privacy_validation ) || ! empty( $t_c_validation ) ) { $error_msg = directorist_get_registration_error_message( 1 ); } elseif ( email_exists( $email ) ) { $error_msg = directorist_get_registration_error_message( 2 ); - } elseif ( ! empty( $username ) && 4 > strlen( $username ) ) { + } elseif ( ! empty( $username ) && 4 > strlen( $clean_username ) ) { $error_msg = directorist_get_registration_error_message( 3 ); + } elseif ( 60 < strlen( $clean_username ) ) { + $error_msg = directorist_get_registration_error_message( 9 ); } elseif ( ! empty( $username ) && preg_match( '/\s/',$username ) ) { $error_msg = directorist_get_registration_error_message( 7 ); - } elseif ( username_exists( $username ) ) { + } elseif ( ! validate_username( $username ) ) { + $error_msg = directorist_get_registration_error_message( 13 ); + } elseif ( username_exists( $clean_username ) ) { $error_msg = directorist_get_registration_error_message( 4 ); } elseif ( ! empty( $password ) && 5 > strlen( $password ) ) { $error_msg = directorist_get_registration_error_message( 5 ); @@ -171,7 +187,7 @@ public function directorist_register_form() { // sanitize user form input global $username, $password, $email, $website, $first_name, $last_name, $bio; - $username = directorist_clean( wp_unslash( $_POST['username'] ) ); + $username = sanitize_user( directorist_clean( wp_unslash( $_POST['username'] ) ), true ); if ( empty( $display_password ) || empty( $_POST['password'] ) ) { $password = wp_generate_password( 12, false ); @@ -194,6 +210,45 @@ public function directorist_register_form() { * */ do_action( 'atbdp_before_processing_submitted_user_registration', $_POST ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash + if ( directorist_is_email_verification_enabled() ) { + $pending = $this->pending_registration->create( + [ + 'user_login' => $username, + 'user_email' => $email, + 'password' => $password, + 'website' => $website, + 'first_name' => $first_name, + 'last_name' => $last_name, + 'bio' => $bio, + 'user_type' => $user_type, + 'privacy_policy' => $privacy_policy, + 'terms_and_conditions' => $t_c_check, + 'previous_page' => $previous_page, + 'generated_password' => $generated_password, + ] + ); + + if ( is_wp_error( $pending ) ) { + wp_send_json_error( + [ 'error' => $pending->get_error_message() ], + 'registration_rate_limited' === $pending->get_error_code() ? 429 : 400 + ); + } + + wp_send_json_success( + [ + 'redirect_url' => esc_url_raw( + ATBDP_Permalink::get_signin_signup_page_link( + [ + 'user' => base64_encode( $email ), + 'verification' => 1, + ] + ) + ), + ] + ); + } + $user_id = $this->complete_registration( $username, $password, $email, $website, $first_name, $last_name, $bio ); if ( is_wp_error( $user_id ) || ! $user_id ) { @@ -754,6 +809,8 @@ private function complete_registration( $username, $password, $email, $website, public function registration_validation( $username, $password, $email, $website, $first_name, $last_name, $bio, $user_type, $privacy_policy, $t_c_check ) { global $reg_errors; + $clean_username = sanitize_user( $username, true ); + $require_website = get_directorist_option( 'require_website_reg',0 ); $display_website = get_directorist_option( 'display_website_reg',1 ); $display_fname = get_directorist_option( 'display_fname_reg',1 ); @@ -800,15 +857,19 @@ public function registration_validation( $username, $password, $email, $website, $t_c_validation = 'yes'; } $reg_errors = new WP_Error; - if ( empty( $username ) || ! empty( $password_validation ) || empty( $email ) || ! empty( $website_validation ) || ! empty( $fname_validation ) || ! empty( $lname_validation ) || ! empty( $bio_validation ) || ! empty( $privacy_validation ) || ! empty( $t_c_validation ) || ! empty( $user_type_validation ) ) { + if ( empty( $username ) || empty( $clean_username ) || ! empty( $password_validation ) || empty( $email ) || ! empty( $website_validation ) || ! empty( $fname_validation ) || ! empty( $lname_validation ) || ! empty( $bio_validation ) || ! empty( $privacy_validation ) || ! empty( $t_c_validation ) || ! empty( $user_type_validation ) ) { $reg_errors->add( 'field', esc_html__( 'Required form field is missing. Please fill all required fields.', 'directorist' ) ); } - if ( ! empty( $username ) && 4 > strlen( $username ) ) { + if ( ! empty( $username ) && 4 > strlen( $clean_username ) ) { $reg_errors->add( 'username_length', esc_html__( 'Username too short. At least 4 characters is required', 'directorist' ) ); } - if ( username_exists( $username ) ) + if ( 60 < strlen( $clean_username ) ) { + $reg_errors->add( 'username_length', esc_html__( 'Username too long. A maximum of 60 characters is allowed.', 'directorist' ) ); + } + + if ( username_exists( $clean_username ) ) $reg_errors->add( 'user_name', esc_html__( 'Sorry, that username already exists!', 'directorist' ) ); if ( preg_match( '/\s/',$username ) ) @@ -901,6 +962,7 @@ public function handle_user_registration() { do_action( 'atbdp_before_processing_submitted_user_registration', $_POST ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash $username = ! empty( $_POST['username'] ) ? directorist_clean( wp_unslash( $_POST['username'] ) ) : ''; + $clean_username = sanitize_user( $username, true ); $password = ! empty( $_POST['password'] ) ? $_POST['password'] : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash $email = ! empty( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : ''; $website = ! empty( $_POST['website'] ) ? directorist_clean( wp_unslash( $_POST['website'] ) ) : ''; @@ -911,6 +973,8 @@ public function handle_user_registration() { $privacy_policy = ! empty( $_POST['privacy_policy'] ) ? directorist_clean( wp_unslash( $_POST['privacy_policy'] ) ) : ''; $t_c_check = ! empty( $_POST['t_c_check'] ) ? directorist_clean( wp_unslash( $_POST['t_c_check'] ) ) : ''; + $generated_password = empty( $display_password ) || empty( $password ); + //password validation if ( ! empty( $require_password ) && ! empty( $display_password ) && empty( $password ) ) { $password_validation = 'yes'; @@ -949,19 +1013,25 @@ public function handle_user_registration() { // validate all the inputs $validation = $this->registration_validation( $username, $password, $email, $website, $first_name, $last_name, $bio, $user_type, $privacy_policy, $t_c_check ); if ( 'passed' !== $validation ) { - if ( empty( $username ) || ! empty( $password_validation ) || empty( $email ) || ! empty( $website_validation ) || ! empty( $fname_validation ) || ! empty( $lname_validation ) || ! empty( $bio_validation ) || ! empty( $privacy_validation ) || ! empty( $t_c_validation ) ) { + if ( empty( $username ) || empty( $clean_username ) || ! empty( $password_validation ) || empty( $email ) || ! empty( $website_validation ) || ! empty( $fname_validation ) || ! empty( $lname_validation ) || ! empty( $bio_validation ) || ! empty( $privacy_validation ) || ! empty( $t_c_validation ) ) { wp_safe_redirect( ATBDP_Permalink::get_registration_page_link( ['errors' => 1] ) ); exit(); } elseif ( email_exists( $email ) ) { wp_safe_redirect( ATBDP_Permalink::get_registration_page_link( ['errors' => 2] ) ); exit(); - } elseif ( ! empty( $username ) && 4 > strlen( $username ) ) { + } elseif ( ! empty( $username ) && 4 > strlen( $clean_username ) ) { wp_safe_redirect( ATBDP_Permalink::get_registration_page_link( ['errors' => 3] ) ); exit(); + } elseif ( 60 < strlen( $clean_username ) ) { + wp_safe_redirect( ATBDP_Permalink::get_registration_page_link( ['errors' => 9] ) ); + exit(); } elseif ( ! empty( $username ) && preg_match( '/\s/',$username ) ) { wp_safe_redirect( ATBDP_Permalink::get_registration_page_link( ['errors' => 7] ) ); exit(); - } elseif ( username_exists( $username ) ) { + } elseif ( ! validate_username( $username ) ) { + wp_safe_redirect( ATBDP_Permalink::get_registration_page_link( ['errors' => 13] ) ); + exit(); + } elseif ( username_exists( $clean_username ) ) { wp_safe_redirect( ATBDP_Permalink::get_registration_page_link( ['errors' => 4] ) ); exit(); } elseif ( ! empty( $password ) && 5 > strlen( $password ) ) { @@ -978,21 +1048,60 @@ public function handle_user_registration() { // sanitize user form input global $username, $password, $email, $website, $first_name, $last_name, $bio; - $username = directorist_clean( wp_unslash( $_POST['username'] ) ); + $username = sanitize_user( directorist_clean( wp_unslash( $_POST['username'] ) ), true ); if ( empty( $display_password ) || empty( $_POST['password'] ) ) { - $password = wp_generate_password( 12, false ); + $password = wp_generate_password( 12, false ); } else { - $password = $_POST['password']; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash + $password = $_POST['password']; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash + } + + $email = ! empty( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : ''; + $website = ! empty( $_POST['website'] ) ? directorist_clean( $_POST['website'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash + $first_name = ! empty( $_POST['fname'] ) ? directorist_clean( wp_unslash( $_POST['fname'] ) ) : ''; + $last_name = ! empty( $_POST['lname'] ) ? directorist_clean( wp_unslash( $_POST['lname'] ) ) : ''; + $user_type = ! empty( $_POST['user_type'] ) ? directorist_clean( wp_unslash( $_POST['user_type'] ) ) : ''; + $bio = ! empty( $_POST['bio'] ) ? sanitize_textarea_field( wp_unslash( $_POST['bio'] ) ) : ''; + $previous_page = ! empty( $_POST['previous_page'] ) ? directorist_clean( $_POST['previous_page'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash + + if ( directorist_is_email_verification_enabled() ) { + $pending = $this->pending_registration->create( + [ + 'user_login' => $username, + 'user_email' => $email, + 'password' => $password, + 'website' => $website, + 'first_name' => $first_name, + 'last_name' => $last_name, + 'bio' => $bio, + 'user_type' => $user_type, + 'privacy_policy' => $privacy_policy, + 'terms_and_conditions' => $t_c_check, + 'previous_page' => $previous_page, + 'generated_password' => $generated_password, + ] + ); + + if ( is_wp_error( $pending ) ) { + wp_safe_redirect( + ATBDP_Permalink::get_registration_page_link( + [ 'errors' => $this->pending_registration->get_legacy_error_code( $pending ) ] + ) + ); + exit(); + } + + wp_safe_redirect( + ATBDP_Permalink::get_login_page_link( + [ + 'user' => base64_encode( $email ), + 'verification' => 1, + ] + ) + ); + exit(); } - $email = ! empty( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : ''; - $website = ! empty( $_POST['website'] ) ? directorist_clean( $_POST['website'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash - $first_name = ! empty( $_POST['fname'] ) ? directorist_clean( wp_unslash( $_POST['fname'] ) ) : ''; - $last_name = ! empty( $_POST['lname'] ) ? directorist_clean( wp_unslash( $_POST['lname'] ) ) : ''; - $user_type = ! empty( $_POST['user_type'] ) ? directorist_clean( wp_unslash( $_POST['user_type'] ) ) : ''; - $bio = ! empty( $_POST['bio'] ) ? sanitize_textarea_field( wp_unslash( $_POST['bio'] ) ) : ''; - $previous_page = ! empty( $_POST['previous_page'] ) ? directorist_clean( $_POST['previous_page'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash // call @function complete_registration to create the user // only when no WP_error is found $user_id = $this->complete_registration( $username, $password, $email, $website, $first_name, $last_name, $bio ); @@ -1169,4 +1278,4 @@ public function restrict_listing_to_the_author( $query ) { return $query; } } // ends ATBDP_User -endif; \ No newline at end of file +endif; diff --git a/includes/helper-functions.php b/includes/helper-functions.php index 57b3da23c1..2dc245a595 100644 --- a/includes/helper-functions.php +++ b/includes/helper-functions.php @@ -3268,6 +3268,11 @@ function directorist_get_registration_error_message( $error_code ) { '6' => __( 'Email is not valid.', 'directorist' ), '7' => __( 'Spaces are not allowed in usernames.', 'directorist' ), '8' => __( 'Please make sure you selected the user type.', 'directorist' ), + '9' => __( 'Username too long. A maximum of 60 characters is allowed.', 'directorist' ), + '10' => __( 'A verification request is already pending. Please check your email or use the resend link.', 'directorist' ), + '11' => __( 'Too many registration requests. Please try again in one hour.', 'directorist' ), + '12' => __( 'The verification email could not be sent. Please try again later.', 'directorist' ), + '13' => __( 'The username you entered is not valid.', 'directorist' ), ]; $messages = apply_filters( 'directorist_registration_error_messages', $messages, $error_code ); diff --git a/templates/account/login-registration-form.php b/templates/account/login-registration-form.php index 7ba0f6ba9b..a4779e976f 100644 --- a/templates/account/login-registration-form.php +++ b/templates/account/login-registration-form.php @@ -10,6 +10,7 @@ $user_email = isset( $_GET['user'] ) ? sanitize_email( wp_unslash( base64_decode( $_GET['user'] ) ) ) : ''; $key = isset( $_GET['key'] ) ? sanitize_text_field( wp_unslash( $_GET['key'] ) ) : ''; $registration_success = false; +$pending_error = isset( $_GET['pending_registration_error'] ) ? sanitize_key( wp_unslash( $_GET['pending_registration_error'] ) ) : ''; if ( ! empty( $_GET['registration_status'] ) ) { $active_form = 'signin'; @@ -28,6 +29,26 @@ + ++ click here to login.', 'directorist' ), + esc_url( ATBDP_Permalink::get_signin_signup_page_link() ) + ), + [ 'a' => [ 'href' => [] ] ] + ); + ?> +
+ + + ++ user->pending_registration->get_verification_error_message( $pending_error ) ); ?> +
+ +