get_var( $wpdb->prepare( "SELECT user_id FROM %i WHERE type = %s AND value = %s LIMIT 1", $table, self::TYPE_PHONE, $canonical ) ); if ( $userId <= 0 ) { return null; } $user = get_user_by( 'id', $userId ); if ( ! $user instanceof WP_User ) { // The row outlived the account it pointed at. Clear it so the number // can be claimed again instead of becoming permanently unusable. $this->forget( $canonical ); return null; } return $user; } /** * Get the user for a verified number, creating an account when there is none. * * @param string $canonical Canonical phone number. * * @return WP_User|WP_Error */ public function findOrCreate( string $canonical ) { $existing = $this->findUserByPhone( $canonical ); if ( null !== $existing ) { return $existing; } if ( ! Settings::get( OtpAuthModule::SLUG . '.allow_register', true ) ) { return new WP_Error( 'registration_closed', __( 'حسابی با این شماره وجود ندارد و ثبت‌نام جدید غیرفعال است.', 'rahkar-toolkit' ) ); } return $this->createUser( $canonical ); } /** * Attach a phone number to a user. * * @param int $userId User id. * @param string $canonical Canonical phone number. */ public function link( int $userId, string $canonical ): bool { global $wpdb; $now = gmdate( 'Y-m-d H:i:s' ); // The unique key on (type, value) makes this the point where a race is // settled: whichever request lands first owns the number, and the other // updates rather than creating a duplicate. // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared $table = Schema::table( 'identity' ); $written = $wpdb->query( $wpdb->prepare( "INSERT INTO %i (user_id, type, value, country, verified, created_at, updated_at) VALUES (%d, %s, %s, 'IR', 1, %s, %s) ON DUPLICATE KEY UPDATE user_id = VALUES(user_id), verified = 1, updated_at = VALUES(updated_at)", $table, $userId, self::TYPE_PHONE, $canonical, $now, $now ) ); // phpcs:enable if ( false === $written ) { return false; } // Mirrored into user meta so WooCommerce, themes, and other plugins that // only know about `billing_phone` see the number too. The table stays the // source of truth; this is a convenience copy. $phone = PhoneNumber::parse( $canonical ); if ( null !== $phone ) { update_user_meta( $userId, 'rahkar_phone', $canonical ); if ( '' === (string) get_user_meta( $userId, 'billing_phone', true ) ) { update_user_meta( $userId, 'billing_phone', $phone->national() ); } } return true; } /** * Drop a number's mapping. * * @param string $canonical Canonical phone number. */ public function forget( string $canonical ): void { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->delete( Schema::table( 'identity' ), [ 'type' => self::TYPE_PHONE, 'value' => $canonical, ], [ '%s', '%s' ] ); } /** * Create an account for a verified number. * * @param string $canonical Canonical phone number. * * @return WP_User|WP_Error */ private function createUser( string $canonical ) { $phone = PhoneNumber::parse( $canonical ); if ( null === $phone ) { return new WP_Error( 'invalid_phone', __( 'شماره موبایل معتبر نیست.', 'rahkar-toolkit' ) ); } $username = $this->uniqueUsername( $phone->national() ); // A long random password nobody knows. The account is reachable only by // one-time code unless the owner sets a password later, so there is no // weak credential to guess — and no password to leak. $userId = wp_insert_user( [ 'user_login' => $username, 'user_pass' => wp_generate_password( 32, true, true ), 'display_name' => $phone->national(), // WooCommerce's customer role, or the site's own default for new // users where there is none. Never a stored choice of ours: a role // read from an option is one bad write away from handing out admin. 'role' => null !== get_role( 'customer' ) ? 'customer' : (string) get_option( 'default_role', 'subscriber' ), ] ); if ( is_wp_error( $userId ) ) { Logger::error( 'otp', 'User creation failed.', [ 'error' => $userId->get_error_message() ] ); return $userId; } $this->link( (int) $userId, $canonical ); Logger::info( 'otp', 'Account created from phone verification.', [ 'user' => (int) $userId ] ); /** * Fires after an account is created through phone verification. * * @param int $userId New user id. * @param string $canonical Canonical phone number. */ do_action( 'rahkar_wp_user_registered', (int) $userId, $canonical ); $user = get_user_by( 'id', $userId ); return $user instanceof WP_User ? $user : new WP_Error( 'user_lookup_failed', __( 'ساخت حساب کاربری ناموفق بود.', 'rahkar-toolkit' ) ); } /** * Derive a username that is not already taken. * * The phone number itself is the natural choice — it is unique and the owner * recognises it. A numeric suffix covers the case where the login was freed up * by a deleted account but the name is still reserved. * * @param string $national National-format number, e.g. `09123456789`. */ private function uniqueUsername( string $national ): string { $base = sanitize_user( $national, true ); $base = '' !== $base ? $base : 'user' . wp_rand( 1000, 9999 ); if ( ! username_exists( $base ) ) { return $base; } for ( $i = 2; $i < 100; $i++ ) { $candidate = $base . '_' . $i; if ( ! username_exists( $candidate ) ) { return $candidate; } } return $base . '_' . wp_generate_password( 6, false, false ); } }