generateCode( $length ); $token = bin2hex( random_bytes( 32 ) ); // Any earlier codes for this identifier are void the moment a new one is // issued. Leaving them live would mean a code the customer already gave // up on still works, widening the window for anyone who intercepted it. $this->invalidateFor( $identifier ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery $inserted = $wpdb->insert( Schema::table( 'otp' ), [ 'identifier' => substr( $identifier, 0, 191 ), 'channel' => $channel, 'code_hash' => $this->hash( $code ), 'token' => $token, 'attempts' => 0, 'verified' => 0, 'ip' => RateLimiter::clientIp(), 'expires_at' => gmdate( 'Y-m-d H:i:s', time() + max( 30, $ttl ) ), 'created_at' => gmdate( 'Y-m-d H:i:s' ), ], [ '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s', '%s' ] ); if ( ! $inserted ) { return null; } return [ 'token' => $token, 'code' => $code, 'expires' => time() + max( 30, $ttl ), ]; } /** * Check a submitted code against a token. * * @param string $token Opaque handle returned by {@see self::issue()}. * @param string $code Code as the customer typed it. * * @return array{ok:bool, identifier:string, error:string, reason:string, subject:string} */ public function verify( string $token, string $code ): array { global $wpdb; $table = Schema::table( 'otp' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE token = %s LIMIT 1', $table, $token ), ARRAY_A ); if ( null === $row ) { return $this->failure( __( 'درخواست معتبر نیست. لطفاً دوباره کد بگیرید.', 'rahkar-toolkit' ), self::FAIL_EXPIRED ); } if ( 1 === (int) $row['verified'] ) { // A code is good for exactly one login. Without this, anyone who // obtained the token could replay a already-used verification. return $this->failure( __( 'این کد قبلاً استفاده شده است.', 'rahkar-toolkit' ), self::FAIL_EXPIRED, (string) $row['identifier'] ); } if ( strtotime( $row['expires_at'] . ' UTC' ) < time() ) { return $this->failure( __( 'کد منقضی شده است. لطفاً کد جدید بگیرید.', 'rahkar-toolkit' ), self::FAIL_EXPIRED, (string) $row['identifier'] ); } $maxAttempts = (int) Settings::get( OtpAuthModule::SLUG . '.max_attempts', 5 ); if ( (int) $row['attempts'] >= $maxAttempts ) { return $this->failure( __( 'تعداد تلاش‌ها بیش از حد مجاز است. لطفاً کد جدید بگیرید.', 'rahkar-toolkit' ), self::FAIL_BLOCKED, (string) $row['identifier'] ); } // Counted before the comparison, so a request that dies mid-way — a // timeout, a fatal — still costs the attacker an attempt. $this->countAttempt( (int) $row['id'] ); $submitted = $this->normaliseCode( $code ); if ( ! hash_equals( (string) $row['code_hash'], $this->hash( $submitted ) ) ) { return $this->failure( __( 'کد وارد‌شده درست نیست.', 'rahkar-toolkit' ), self::FAIL_WRONG, (string) $row['identifier'] ); } $this->markVerified( (int) $row['id'] ); return [ 'ok' => true, 'identifier' => (string) $row['identifier'], 'error' => '', 'reason' => '', 'subject' => (string) $row['identifier'], ]; } /** * Seconds the customer must wait before another code can be requested. * * @param string $identifier Canonical phone number or email address. */ public function resendWaitFor( string $identifier ): int { $after = (int) Settings::get( OtpAuthModule::SLUG . '.resend_after', 60 ); global $wpdb; $table = Schema::table( 'otp' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery $last = $wpdb->get_var( $wpdb->prepare( "SELECT created_at FROM %i WHERE identifier = %s ORDER BY id DESC LIMIT 1", $table, substr( $identifier, 0, 191 ) ) ); if ( ! is_string( $last ) ) { return 0; } $elapsed = time() - (int) strtotime( $last . ' UTC' ); return max( 0, $after - $elapsed ); } /** * Throw away a code that was issued but never delivered. * * Without this, a send the store failed to make would still start the resend * wait, and the customer would be told to wait for a code that is not coming. * * @param string $token Handle returned by {@see self::issue()}. */ public function discard( string $token ): void { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->delete( Schema::table( 'otp' ), [ 'token' => $token ], [ '%s' ] ); } /** * Delete lapsed rows. * * Driven by the queue rather than a request, so nobody waits for it. * * @param int $graceHours How long a used or expired row is kept. * * @return int Rows removed. */ public function purgeExpired( int $graceHours = 24 ): int { global $wpdb; $table = Schema::table( 'otp' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery return (int) $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE expires_at < %s", $table, gmdate( 'Y-m-d H:i:s', time() - ( $graceHours * HOUR_IN_SECONDS ) ) ) ); } /** * Keyed hash of a code. * * HMAC-SHA256 rather than a password hash: this runs on every verification * attempt and must stay fast, and the input is a short numeric code whose * security comes from expiry and attempt limits, not from hashing cost. * `wp_salt` keys it to the site, so a hash lifted from one install is useless * against another. * * @param string $code Plain code. */ private function hash( string $code ): string { return hash_hmac( 'sha256', $code, wp_salt( 'auth' ) ); } /** * Generate a numeric code. * * `random_int` is the cryptographically secure generator; `rand` and `mt_rand` * are predictable from a handful of observed outputs. * * @param int $length Digits. */ private function generateCode( int $length ): string { $length = max( 4, min( 8, $length ) ); $code = ''; for ( $i = 0; $i < $length; $i++ ) { $code .= (string) random_int( 0, 9 ); } return $code; } /** * Fold a typed code to comparable digits. * * Customers paste codes with spaces, and a Persian keyboard produces Persian * digits. Both are the right code and must not be rejected as wrong. * * @param string $code Code as typed. */ private function normaliseCode( string $code ): string { $ascii = \Rahkar\WP\Modules\IranLocations\Normaliser::digits( trim( $code ) ); return (string) preg_replace( '/\D+/', '', $ascii ); } /** * Void every outstanding code for an identifier. * * @param string $identifier Canonical identifier. */ private function invalidateFor( string $identifier ): void { global $wpdb; $table = Schema::table( 'otp' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->query( $wpdb->prepare( "UPDATE %i SET expires_at = %s WHERE identifier = %s AND verified = 0", $table, gmdate( 'Y-m-d H:i:s', time() - 1 ), substr( $identifier, 0, 191 ) ) ); } /** * Increment the attempt counter. * * @param int $id Row id. */ private function countAttempt( int $id ): void { global $wpdb; $table = Schema::table( 'otp' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->query( $wpdb->prepare( 'UPDATE %i SET attempts = attempts + 1 WHERE id = %d', $table, $id ) ); } /** * Mark a row as spent. * * @param int $id Row id. */ private function markVerified( int $id ): void { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->update( Schema::table( 'otp' ), [ 'verified' => 1 ], [ 'id' => $id ], [ '%d' ], [ '%d' ] ); } /** * Build a failure result. * * `identifier` stays empty on every failure, so a caller that forgets to * check `ok` still cannot sign anyone in; the number the code was for, when * known, is carried separately as `subject`, for the log. * * @param string $message Reason to show the customer. * @param string $reason One of the FAIL_* constants. * @param string $subject Identifier the code was issued to, when known. * * @return array{ok:bool, identifier:string, error:string, reason:string, subject:string} */ private function failure( string $message, string $reason, string $subject = '' ): array { return [ 'ok' => false, 'identifier' => '', 'error' => $message, 'reason' => $reason, 'subject' => $subject, ]; } }