issueAndDeliver( false ); } /** * Step one again, by phone call: issue a fresh code and have it read out. * * A new code rather than the one already texted: codes are stored only as a * hash, so the one in the SMS cannot be read back — and a call is a resend in * every way that matters, so it goes through the same limits and waits. * * @internal Hooked callback. */ public function requestVoice(): void { if ( ! Settings::get( OtpAuthModule::SLUG . '.voice_enabled', false ) || ! OtpDispatcher::canCall() ) { wp_send_json_error( [ 'message' => __( 'دریافت کد با تماس تلفنی فعال نیست.', 'rahkar-toolkit' ) ] ); } $this->issueAndDeliver( true ); } /** * Validate, throttle, issue and deliver — shared by the SMS and voice paths. * * @param bool $voice Whether to deliver by phone call. */ private function issueAndDeliver( bool $voice ): void { check_ajax_referer( self::NONCE, 'nonce' ); $raw = isset( $_POST['identifier'] ) ? sanitize_text_field( wp_unslash( $_POST['identifier'] ) ) : ''; $phone = PhoneNumber::parse( $raw ); if ( null === $phone ) { wp_send_json_error( [ 'message' => __( 'شماره موبایل معتبر نیست. نمونه درست: ۰۹۱۲۳۴۵۶۷۸۹', 'rahkar-toolkit' ) ] ); } $canonical = $phone->canonical(); $blocked = $this->throttle( $canonical ); if ( null !== $blocked ) { OtpLog::record( OtpLog::EVENT_THROTTLED, OtpLog::RESULT_BLOCKED, [ 'phone' => $canonical, 'via' => $voice ? 'voice' : 'sms', 'message' => (string) $blocked['message'], ] ); wp_send_json_error( $blocked ); } $wait = Plugin::make( OtpService::class )->resendWaitFor( $canonical ); if ( $wait > 0 ) { wp_send_json_error( [ 'message' => sprintf( /* translators: %d: seconds to wait */ __( 'کد قبلی هنوز معتبر است. %d ثانیه دیگر می‌توانید کد جدید بگیرید.', 'rahkar-toolkit' ), $wait ), 'wait' => $wait, ] ); } $issued = Plugin::make( OtpService::class )->issue( $canonical, $voice ? 'voice' : 'sms' ); if ( null === $issued ) { wp_send_json_error( [ 'message' => __( 'خطای داخلی. لطفاً دوباره تلاش کنید.', 'rahkar-toolkit' ) ] ); } Housekeeping::schedule(); $dispatcher = Plugin::make( OtpDispatcher::class ); $outcome = $voice ? $dispatcher->sendVoice( $phone, $issued['code'] ) : $dispatcher->send( $phone, $issued['code'] ); if ( ! $outcome->isDelivered() ) { // The store failed, not the customer: the code that never left is // thrown away so no resend wait starts, and the request is not counted // against them. Plugin::make( OtpService::class )->discard( $issued['token'] ); RateLimiter::refund( OtpService::BUCKET_PHONE, $canonical ); RateLimiter::refund( OtpService::BUCKET_PHONE_DAY, $canonical ); wp_send_json_error( [ 'message' => $voice ? __( 'تماس برقرار نشد. کمی بعد دوباره تلاش کنید یا کد پیامکی بگیرید.', 'rahkar-toolkit' ) : __( 'ارسال پیامک موقتاً ممکن نیست. چند لحظه دیگر دوباره تلاش کنید.', 'rahkar-toolkit' ), ] ); } wp_send_json_success( [ // The token is the whole of the client's state. The code itself is // never returned — it only exists in the SMS or the call. 'token' => $issued['token'], 'expiresIn' => max( 0, $issued['expires'] - time() ), 'resendIn' => (int) Settings::get( OtpAuthModule::SLUG . '.resend_after', 60 ), 'masked' => $phone->masked(), 'voice' => $voice, // Only ever set for an allow-listed tester: the dispatcher decides. 'testCode' => $outcome->isTestMode() ? $outcome->testCode() : '', ] ); } /** * Step two: check the code and sign the customer in. * * @internal Hooked callback. */ public function verifyCode(): void { check_ajax_referer( self::NONCE, 'nonce' ); $token = isset( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : ''; $code = isset( $_POST['code'] ) ? sanitize_text_field( wp_unslash( $_POST['code'] ) ) : ''; if ( '' === $token || '' === $code ) { wp_send_json_error( [ 'message' => __( 'کد را وارد کنید.', 'rahkar-toolkit' ) ] ); } $check = Plugin::make( OtpService::class )->verify( $token, $code ); if ( ! $check['ok'] ) { OtpLog::record( OtpLog::EVENT_VERIFY, $check['reason'], [ 'phone' => $check['subject'], 'message' => $check['error'], ] ); wp_send_json_error( [ 'message' => $check['error'] ] ); } $canonical = $check['identifier']; $known = Plugin::make( IdentityRepository::class )->findUserByPhone( $canonical ); $user = $known ?? Plugin::make( IdentityRepository::class )->findOrCreate( $canonical ); if ( $user instanceof WP_Error ) { OtpLog::record( OtpLog::EVENT_SIGNUP, OtpLog::RESULT_FAILED, [ 'phone' => $canonical, 'message' => $user->get_error_message(), ] ); wp_send_json_error( [ 'message' => $user->get_error_message() ] ); } // The number is proven at this point, so clear the throttle: the attempts // it took to get here should not count against the person who succeeded. RateLimiter::clear( OtpService::BUCKET_PHONE, $canonical ); $user = $this->signOn( $user->user_login, '', $user ); if ( ! $user instanceof WP_User ) { // Another plugin's `authenticate` filter turned the sign-in down: a // lockout, a blocked account, a spam flag. Its word stands. wp_send_json_error( [ 'message' => wp_strip_all_tags( $user->get_error_message() ) ] ); } Logger::info( 'otp', 'Signed in by one-time code.', [ 'user' => $user->ID ] ); OtpLog::record( null === $known ? OtpLog::EVENT_SIGNUP : OtpLog::EVENT_LOGIN, OtpLog::RESULT_OK, [ 'phone' => $canonical, 'user_id' => $user->ID, ] ); /** * Fires once a phone number has been proven and the customer is signed in. * * This is the moment a store knows both who the visitor is and what is in * their cart, which makes it the natural hook for abandoned-cart capture. * It is published here so that feature can live in its own module and * never need to modify this one. * * Handlers must be cheap or queue their work: a customer is waiting on * this response. * * @param string $canonical Verified phone number, canonical form. * @param WP_User $user The signed-in user. */ do_action( 'rahkar_wp_phone_verified', $canonical, $user ); wp_send_json_success( [ 'redirect' => $this->redirectFor( $user ), // Asked after the code, never before: a returning customer does not // see it, and someone who closes the page here already has an account. 'needsName' => $this->needsName( $user, null === $known ), // The session changed from guest to this user, so the nonce printed in // the page no longer validates. The name step uses this one instead. 'nonce' => wp_create_nonce( self::NAME_NONCE ), ] ); } /** * Step three, for a new account only: the customer's name. * * @internal Hooked callback. */ public function saveName(): void { check_ajax_referer( self::NAME_NONCE, 'nonce' ); $userId = get_current_user_id(); if ( $userId <= 0 ) { wp_send_json_error( [ 'message' => __( 'نشست شما منقضی شده است. لطفاً دوباره وارد شوید.', 'rahkar-toolkit' ) ] ); } $first = isset( $_POST['firstName'] ) ? sanitize_text_field( wp_unslash( $_POST['firstName'] ) ) : ''; $last = isset( $_POST['lastName'] ) ? sanitize_text_field( wp_unslash( $_POST['lastName'] ) ) : ''; if ( '' === trim( $first ) || '' === trim( $last ) ) { wp_send_json_error( [ 'message' => __( 'نام و نام خانوادگی را وارد کنید.', 'rahkar-toolkit' ) ] ); } $first = mb_substr( trim( $first ), 0, 60 ); $last = mb_substr( trim( $last ), 0, 60 ); wp_update_user( [ 'ID' => $userId, 'first_name' => $first, 'last_name' => $last, 'display_name' => trim( $first . ' ' . $last ), ] ); // WooCommerce reads the billing name for the checkout and every invoice, so // filling it here means the customer is not asked a second time at checkout. if ( '' === (string) get_user_meta( $userId, 'billing_first_name', true ) ) { update_user_meta( $userId, 'billing_first_name', $first ); } if ( '' === (string) get_user_meta( $userId, 'billing_last_name', true ) ) { update_user_meta( $userId, 'billing_last_name', $last ); } wp_send_json_success( [ 'saved' => true ] ); } /** * Email and password sign-in, for stores that offer "mobile or email". * * The same form field takes either: a number goes to the code step, an * address comes here. Rate limited per IP, because unlike the code path there * is no send to throttle and nothing else stands between a script and a * password guessing run. * * @internal Hooked callback. */ public function passwordLogin(): void { check_ajax_referer( self::NONCE, 'nonce' ); if ( OtpAuthModule::IDENTIFIER_MOBILE_EMAIL !== OtpAuthModule::identifier() ) { wp_send_json_error( [ 'message' => __( 'ورود با ایمیل فعال نیست.', 'rahkar-toolkit' ) ] ); } $ip = RateLimiter::clientIp(); if ( ! RateLimiter::hit( self::BUCKET_PASSWORD, $ip, 10, 900 ) ) { OtpLog::record( OtpLog::EVENT_THROTTLED, OtpLog::RESULT_BLOCKED, [ 'via' => 'password' ] ); wp_send_json_error( [ 'message' => __( 'تعداد تلاش‌ها بیش از حد مجاز است. چند دقیقه دیگر دوباره امتحان کنید.', 'rahkar-toolkit' ) ] ); } $login = isset( $_POST['email'] ) ? sanitize_text_field( wp_unslash( $_POST['email'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- A password is compared as typed; sanitising would change it. $password = isset( $_POST['password'] ) ? (string) wp_unslash( $_POST['password'] ) : ''; if ( '' === $login || '' === $password ) { wp_send_json_error( [ 'message' => __( 'ایمیل و رمز عبور را وارد کنید.', 'rahkar-toolkit' ) ] ); } $user = $this->signOn( $login, $password ); if ( ! $user instanceof WP_User ) { OtpLog::record( OtpLog::EVENT_PASSWORD, OtpLog::RESULT_FAILED, [ 'via' => 'password' ] ); // One message for every failure. Saying "no such email" separately would // turn this form into a way to check who has an account here. wp_send_json_error( [ 'message' => __( 'ایمیل یا رمز عبور درست نیست.', 'rahkar-toolkit' ) ] ); } RateLimiter::clear( self::BUCKET_PASSWORD, $ip ); OtpLog::record( OtpLog::EVENT_PASSWORD, OtpLog::RESULT_OK, [ 'user_id' => $user->ID, 'via' => 'password', ] ); wp_send_json_success( [ 'redirect' => $this->redirectFor( $user ) ] ); } /** * Should the name step be shown to this user? * * Only when the store asks for names and the account has none. A returning * customer who once skipped it is asked again, which is the point: orders and * invoices otherwise carry a phone number where the name should be. * * @param WP_User $user The user who just signed in. * @param bool $created Whether the account was created by this sign-in. */ private function needsName( WP_User $user, bool $created ): bool { if ( ! Settings::get( OtpAuthModule::SLUG . '.ask_name', true ) ) { return false; } $hasName = '' !== trim( (string) get_user_meta( $user->ID, 'first_name', true ) ); return $created || ! $hasName; } /** * Sign in through core's own `wp_signon()`. * * Both paths go through it, so everything that guards a WordPress login * sees this one too: the `authenticate` filter, where lockout and spam * checks live, `wp_login_failed` when it is refused, and `wp_login` when it * is not — the action two-factor plugins hook to step in. * * A password is checked by core as usual. A one-time code has no password: * the number was proven by the code a moment ago, so `$verified` carries * that user into the `authenticate` filter, ahead of core's password check, * for this one call only. Any filter that runs after it can still refuse. * * @param string $login Username or email. * @param string $password Password, or empty after a verified code. * @param WP_User|null $verified The user a one-time code was just verified for. * * @return WP_User|WP_Error */ private function signOn( string $login, string $password, ?WP_User $verified = null ) { // A nonce created later in this response is tied to the session token in // the logged-in cookie. WordPress sets that cookie for the browser but not // in `$_COOKIE`, so without this the nonce would be built against no // session and rejected by the very next request. $keepCookie = static function ( $cookie ): void { $_COOKIE[ LOGGED_IN_COOKIE ] = $cookie; }; $verifiedUser = static function ( $user, $username ) use ( $verified ) { if ( null === $user && null !== $verified && $username === $verified->user_login ) { return $verified; } return $user; }; add_action( 'set_logged_in_cookie', $keepCookie ); if ( null !== $verified ) { // Priority 5: before core's username and password check at 20, which // passes an already resolved user through untouched. add_filter( 'authenticate', $verifiedUser, 5, 2 ); } wp_clear_auth_cookie(); $user = wp_signon( [ 'user_login' => $login, 'user_password' => $password, 'remember' => true, ] ); remove_filter( 'authenticate', $verifiedUser, 5 ); if ( $user instanceof WP_User ) { wp_set_current_user( $user->ID ); } else { remove_action( 'set_logged_in_cookie', $keepCookie ); } return $user; } /** * Where to send the customer after signing in. * * @param WP_User $user Authenticated user. */ private function redirectFor( WP_User $user ): string { // Where the customer actually was outranks any configured destination. // Someone who verified their number at the checkout gate wants the // checkout back, not the account page — sending them elsewhere with a // full cart is how a store loses the order it had already won. $origin = $this->originUrl(); if ( '' !== $origin ) { return wp_validate_redirect( $origin, home_url( '/' ) ); } // The page the customer was sent here from — the standalone login page // carries it as `redirect_to` — as long as it is on this site. // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce already verified by the caller. $returnTo = isset( $_POST['returnTo'] ) ? esc_url_raw( wp_unslash( $_POST['returnTo'] ) ) : ''; $returnTo = '' !== $returnTo ? wp_validate_redirect( $returnTo, '' ) : ''; if ( '' !== $returnTo ) { return $returnTo; } $configured = trim( (string) Settings::get( OtpAuthModule::SLUG . '.redirect_to', '' ) ); if ( '' === $configured && function_exists( 'wc_get_page_permalink' ) ) { $configured = (string) wc_get_page_permalink( 'myaccount' ); } $target = '' !== $configured ? $configured : home_url( '/' ); /** * Filters where a customer lands after signing in by one-time code. * * @param string $target Destination URL. * @param WP_User $user The user who just signed in. */ $target = (string) apply_filters( 'rahkar_wp_otp_redirect', $target, $user ); // `wp_safe_redirect` semantics, applied to a URL the browser will follow: // an unchecked value here would turn the login form into an open redirect // that phishing pages could point at their own site. return wp_validate_redirect( $target, home_url( '/' ) ); } /** * The checkout URL the customer was on, when they were on one. * * The browser sends its own location with the verification request. Only the * checkout is honoured — accepting any submitted URL would let a crafted * request bounce a freshly signed-in user anywhere, and `wp_validate_redirect` * alone would still allow any page on this site. */ private function originUrl(): string { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce already verified by the caller. $origin = isset( $_POST['origin'] ) ? esc_url_raw( wp_unslash( $_POST['origin'] ) ) : ''; if ( '' === $origin || ! function_exists( 'wc_get_checkout_url' ) ) { return ''; } $checkout = (string) wc_get_checkout_url(); $originHost = strtolower( (string) wp_parse_url( $origin, PHP_URL_HOST ) ); $siteHost = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); // The host is checked as well as the path. Matching on path alone would // treat `https://evil.example/checkout/` as "the customer was on our // checkout". The value returned is this site's own URL either way, so // that mismatch is not exploitable today — but it is wrong, and the next // person to touch this method should not have to notice that the safety // comes from the return value rather than from the check. if ( '' !== $originHost && $originHost !== $siteHost ) { return ''; } $originPath = (string) wp_parse_url( $origin, PHP_URL_PATH ); $checkoutPath = (string) wp_parse_url( $checkout, PHP_URL_PATH ); return '' !== $checkoutPath && untrailingslashit( $originPath ) === untrailingslashit( $checkoutPath ) ? $checkout : ''; } /** * Apply the two rate limits. * * Both are needed and they catch different attacks. The per-number limit stops * someone using the form to spam one person with texts — which costs the store * real money per message. The per-IP limit stops one client walking through * many numbers. * * @param string $canonical Canonical phone number. * * @return array|null Error payload, or null when allowed. */ private function throttle( string $canonical ): ?array { $ipMax = (int) Settings::get( OtpAuthModule::SLUG . '.ip_limit_count', 10 ); $ipWindow = (int) Settings::get( OtpAuthModule::SLUG . '.ip_limit_window', 300 ); if ( ! RateLimiter::hit( OtpService::BUCKET_IP, RateLimiter::clientIp(), $ipMax, $ipWindow ) ) { return [ 'message' => __( 'تعداد درخواست‌ها بیش از حد مجاز است. لطفاً بعداً تلاش کنید.', 'rahkar-toolkit' ), 'wait' => RateLimiter::secondsRemaining( OtpService::BUCKET_IP, RateLimiter::clientIp() ), ]; } $phoneMax = (int) Settings::get( OtpAuthModule::SLUG . '.phone_limit_count', 3 ); $phoneWindow = (int) Settings::get( OtpAuthModule::SLUG . '.phone_limit_window', 600 ); if ( ! RateLimiter::hit( OtpService::BUCKET_PHONE, $canonical, $phoneMax, $phoneWindow ) ) { return [ 'message' => __( 'برای این شماره درخواست‌های زیادی ثبت شده. چند دقیقه دیگر تلاش کنید.', 'rahkar-toolkit' ), 'wait' => RateLimiter::secondsRemaining( OtpService::BUCKET_PHONE, $canonical ), ]; } // A cap per number per day, whatever the IP. Without it someone rotating // addresses can have hundreds of messages sent to one person's phone — // harassment for them, and a bill for the store. $daily = (int) Settings::get( OtpAuthModule::SLUG . '.phone_daily_limit', 10 ); if ( $daily > 0 && ! RateLimiter::hit( OtpService::BUCKET_PHONE_DAY, $canonical, $daily, DAY_IN_SECONDS ) ) { RateLimiter::refund( OtpService::BUCKET_PHONE, $canonical ); return [ 'message' => __( 'سقف درخواست کد برای این شماره در امروز پر شده است. فردا دوباره تلاش کنید.', 'rahkar-toolkit' ), 'wait' => RateLimiter::secondsRemaining( OtpService::BUCKET_PHONE_DAY, $canonical ), ]; } return null; } /** * Nonce for the login form. */ public static function nonce(): string { return wp_create_nonce( self::NONCE ); } }