$phone->masked() ] ); $this->recordTest( $phone, 'sms' ); return DispatchOutcome::testMode( $code ); } $gateways = Gateways::enabled(); if ( [] === $gateways ) { Logger::error( 'otp', 'No SMS gateway is enabled and configured.' ); return $this->noGateway( $phone, 'sms' ); } $outcome = DispatchOutcome::failed( '' ); $attempt = 0; foreach ( $gateways as $driver ) { $result = $this->attempt( $driver, $phone, $code, ++$attempt ); $this->record( $phone, $driver->id(), 'sms', $result ); if ( $result->ok ) { $outcome = DispatchOutcome::sent( $driver->id() ); break; } $outcome = DispatchOutcome::failed( $result->error ); if ( ! Gateways::failsOver() ) { break; } } $this->afterSend(); return $outcome; } /** * Have the code read out in a phone call, through the first voice-capable * gateway that accepts it. * * @param PhoneNumber $phone Recipient. * @param string $code The code. */ public function sendVoice( PhoneNumber $phone, string $code ): DispatchOutcome { if ( TestMode::appliesTo( $phone, RateLimiter::clientIp() ) ) { Logger::info( 'otp', 'Test mode: voice code shown on screen, no call placed.', [ 'to' => $phone->masked() ] ); $this->recordTest( $phone, 'voice' ); return DispatchOutcome::testMode( $code ); } $gateways = Gateways::voiceCapable(); if ( [] === $gateways ) { Logger::error( 'otp', 'No enabled gateway can place voice calls.' ); return $this->noGateway( $phone, 'voice' ); } $outcome = DispatchOutcome::failed( '' ); foreach ( $gateways as $driver ) { try { $result = $driver->sendVoiceOtp( $phone, $code ); } catch ( \Throwable $e ) { $result = Result::retryable( 'exception', $e->getMessage() ); } $this->record( $phone, $driver->id(), 'voice', $result ); if ( $result->ok ) { $outcome = DispatchOutcome::sent( $driver->id() ); break; } $outcome = DispatchOutcome::failed( $result->error ); if ( ! Gateways::failsOver() ) { break; } } $this->afterSend(); return $outcome; } /** * Whether any enabled gateway can place a voice call. No network access. */ public static function canCall(): bool { return [] !== Gateways::voiceCapable(); } /** * The approved pattern this module sends codes with on one gateway. * * @param string $driverId Gateway id. */ public static function patternFor( string $driverId ): string { return trim( (string) Settings::get( OtpAuthModule::SLUG . '.' . self::patternKey( $driverId ), '' ) ); } /** * The pattern's variable name for the code on one gateway. * * @param string $driverId Gateway id. */ public static function variableFor( string $driverId ): string { $name = trim( (string) Settings::get( OtpAuthModule::SLUG . '.' . self::variableKey( $driverId ), 'otp' ) ); return '' !== $name ? $name : 'otp'; } /** * Settings key of a gateway's pattern code. * * @param string $driverId Gateway id. */ public static function patternKey( string $driverId ): string { return 'pattern_' . sanitize_key( $driverId ); } /** * Settings key of a gateway's code variable name. * * @param string $driverId Gateway id. */ public static function variableKey( string $driverId ): string { return 'pattern_var_' . sanitize_key( $driverId ); } /** * One gateway's attempt. * * A gateway that throws — a provider library choking on a malformed response — * must not surface as a PHP error on the customer's form, and must not stop * the next gateway being tried. * * @param SmsDriver $driver Gateway. * @param PhoneNumber $phone Recipient. * @param string $code The code. * @param int $attempt 1 for the first gateway tried for this code. */ private function attempt( SmsDriver $driver, PhoneNumber $phone, string $code, int $attempt ): Result { $pattern = self::patternFor( $driver->id() ); $message = new Message( self::EVENT, $phone->canonical(), // Only used when no pattern is configured for this gateway, which is // free text over whatever line the account has — see the settings help. sprintf( /* translators: 1: verification code, 2: site name */ __( 'کد ورود شما: %1$s — %2$s', 'rahkar-toolkit' ), $code, get_bloginfo( 'name' ) ), '' !== $pattern ? [ self::variableFor( $driver->id() ) => $code ] : [ 'code' => $code ], '', true, $pattern, module: 'otp_auth' ); try { $result = $driver->send( $message ); } catch ( \Throwable $e ) { $result = Result::retryable( 'exception', $e->getMessage() ); } // Into the dashboard's SMS bill like every other message: metadata only, never // the code. A row in the outbox; the upload happens later, off this request. Notifier::report( DriverRegistry::CHANNEL_SMS, $driver, $message, $result, $attempt ); return $result; } /** * Log one attempt. The code itself is never written anywhere. * * @param PhoneNumber $phone Recipient. * @param string $driverId Gateway id. * @param string $via `sms` or `voice`. * @param Result $result Outcome. */ private function record( PhoneNumber $phone, string $driverId, string $via, Result $result ): void { $context = array_merge( $result->toLog(), [ 'driver' => $driverId, 'via' => $via ] ); OtpLog::record( OtpLog::EVENT_SEND, AlertMonitor::resultCode( $result ), [ 'phone' => $phone->canonical(), 'gateway' => $driverId, 'via' => $via, 'message' => $result->ok ? $result->messageId : $result->error, ] ); if ( $result->ok ) { Logger::info( 'otp', 'Login code accepted by gateway.', $context ); return; } Logger::warning( 'otp', 'Gateway refused the login code.', $context ); } /** * Log a test-mode send: nothing left the server. * * @param PhoneNumber $phone Recipient. * @param string $via `sms` or `voice`. */ private function recordTest( PhoneNumber $phone, string $via ): void { OtpLog::record( OtpLog::EVENT_SEND, OtpLog::RESULT_TEST, [ 'phone' => $phone->canonical(), 'gateway' => 'test_mode', 'via' => $via, ] ); } /** * Log and report that nothing was configured to send with. * * Counted as a failed send: a store whose gateways were all switched off * cannot deliver a code, and the failsafe and the alerts should know. * * @param PhoneNumber $phone Recipient. * @param string $via `sms` or `voice`. */ private function noGateway( PhoneNumber $phone, string $via ): DispatchOutcome { OtpLog::record( OtpLog::EVENT_SEND, OtpLog::RESULT_NO_GATEWAY, [ 'phone' => $phone->canonical(), 'via' => $via, ] ); $this->afterSend(); return DispatchOutcome::noGateway(); } /** * Let the failsafe and the alert rules see the latest outcome. * * One indexed query when all is well; the alert rules add a few more only * when an administrator number is set. */ private function afterSend(): void { $failures = OtpLog::consecutiveFailures(); Failsafe::evaluate( $failures ); AlertMonitor::afterSend( $failures ); } }