WooCommerce Code Reference

QRLoginRateLimits.php

Source code

<?php
/**
 * Rate limiter for mobile app QR login endpoints.
 *
 * @package WooCommerce\Admin\API
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Admin\API\RateLimits;

use Automattic\WooCommerce\Admin\API\MobileAppQRLogin;
use WC_Rate_Limiter;

defined( 'ABSPATH' ) || exit;

/**
 * Counter-based rate limiter for QR login endpoints.
 *
 * Uses WooCommerce's `wc_rate_limits` table and an atomic SQL upsert so
 * concurrent requests cannot bypass a bucket by racing a transient get/set
 * sequence.
 *
 * @internal
 */
class QRLoginRateLimits extends WC_Rate_Limiter {

	/**
	 * Generation bucket.
	 */
	const BUCKET_GENERATION = 'gen';

	/**
	 * Broad exchange-IP bucket.
	 */
	const BUCKET_EXCHANGE_IP = 'exc_ip';

	/**
	 * Invalid-token exchange bucket.
	 */
	const BUCKET_INVALID_EXCHANGE = 'exc_invalid';

	/**
	 * Invalid-token scan bucket.
	 */
	const BUCKET_INVALID_SCAN = 'scn_invalid';

	/**
	 * Valid-token exchange bucket.
	 */
	const BUCKET_VALID_EXCHANGE = 'exc_token';

	/**
	 * Status polling bucket.
	 */
	const BUCKET_STATUS = 'sta';

	/**
	 * Revoke endpoint bucket.
	 */
	const BUCKET_REVOKE = 'rev';

	/**
	 * Scan endpoint bucket.
	 */
	const BUCKET_SCAN = 'scn';

	/**
	 * Approval endpoint bucket.
	 */
	const BUCKET_APPROVE = 'apr';

	/**
	 * Session-status polling bucket.
	 */
	const BUCKET_SESSION_STATUS = 'ss';

	/**
	 * Prefix for QR login rate-limit rows.
	 */
	const KEY_PREFIX = 'qr_login_';

	/**
	 * Cache group.
	 */
	const CACHE_GROUP = 'wc_qr_login_rate_limit';

	/**
	 * Build the persisted rate-limit action ID.
	 *
	 * @param string $bucket Bucket name.
	 * @param string $identifier Bucket identifier.
	 * @return string
	 */
	public static function get_action_id( string $bucket, string $identifier ): string {
		$normalized_identifier = preg_replace( '/[^A-Za-z0-9:._-]/', '_', trim( $identifier ) );
		$normalized_identifier = is_string( $normalized_identifier ) && '' !== $normalized_identifier
			? $normalized_identifier
			: 'unknown';

		return substr( self::KEY_PREFIX . $bucket . '_' . $normalized_identifier, 0, 190 );
	}

	/**
	 * Consume one request from a bucket.
	 *
	 * @param string $bucket Bucket name.
	 * @param string $identifier Bucket identifier.
	 * @return bool True if the request is within the bucket limit.
	 */
	public static function consume( string $bucket, string $identifier ): bool {
		global $wpdb;

		$options = self::get_bucket_options( $bucket );
		if ( null === $options ) {
			return false;
		}

		$time              = time();
		$limit             = max( 1, (int) $options['limit'] );
		$rate_limit_expiry = $time + (int) $options['seconds'];
		$action_id         = self::get_action_id( $bucket, $identifier );

		$result = $wpdb->query(
			$wpdb->prepare(
				"INSERT INTO {$wpdb->prefix}wc_rate_limits
					(`rate_limit_key`, `rate_limit_expiry`, `rate_limit_remaining`)
				VALUES
					(%s, %d, %d)
				ON DUPLICATE KEY UPDATE
					`rate_limit_id` = IF(
						`rate_limit_expiry` < %d OR `rate_limit_remaining` > 0,
						LAST_INSERT_ID(`rate_limit_id`),
						LAST_INSERT_ID(0) + `rate_limit_id`
					),
					`rate_limit_remaining` = IF(
						`rate_limit_expiry` < %d,
						VALUES(`rate_limit_remaining`),
						IF(`rate_limit_remaining` > 0, `rate_limit_remaining` - 1, 0)
					),
					`rate_limit_expiry` = IF(`rate_limit_expiry` < %d, VALUES(`rate_limit_expiry`), `rate_limit_expiry`);
				",
				$action_id,
				$rate_limit_expiry,
				$limit - 1,
				$time,
				$time,
				$time
			)
		);

		if ( false === $result ) {
			return false;
		}

		return (int) $wpdb->get_var( 'SELECT LAST_INSERT_ID()' ) > 0;
	}

	/**
	 * Get bucket options.
	 *
	 * @param string $bucket Bucket name.
	 * @return array{limit:int, seconds:int}|null
	 */
	private static function get_bucket_options( string $bucket ): ?array {
		switch ( $bucket ) {
			case self::BUCKET_GENERATION:
				return array(
					'limit'   => MobileAppQRLogin::MAX_TOKENS_PER_WINDOW,
					'seconds' => 15 * MINUTE_IN_SECONDS,
				);
			case self::BUCKET_EXCHANGE_IP:
				return array(
					'limit'   => MobileAppQRLogin::MAX_EXCHANGE_IP_ATTEMPTS,
					'seconds' => 15 * MINUTE_IN_SECONDS,
				);
			case self::BUCKET_INVALID_EXCHANGE:
				return array(
					'limit'   => MobileAppQRLogin::MAX_INVALID_EXCHANGE_ATTEMPTS,
					'seconds' => 15 * MINUTE_IN_SECONDS,
				);
			case self::BUCKET_INVALID_SCAN:
				return array(
					'limit'   => MobileAppQRLogin::MAX_INVALID_SCAN_ATTEMPTS,
					'seconds' => 15 * MINUTE_IN_SECONDS,
				);
			case self::BUCKET_VALID_EXCHANGE:
				return array(
					'limit'   => MobileAppQRLogin::MAX_EXCHANGE_ATTEMPTS,
					'seconds' => 15 * MINUTE_IN_SECONDS,
				);
			case self::BUCKET_STATUS:
				return array(
					'limit'   => MobileAppQRLogin::MAX_STATUS_CHECKS_PER_WINDOW,
					'seconds' => 15 * MINUTE_IN_SECONDS,
				);
			case self::BUCKET_REVOKE:
				return array(
					'limit'   => MobileAppQRLogin::MAX_REVOKE_ATTEMPTS,
					'seconds' => 15 * MINUTE_IN_SECONDS,
				);
			case self::BUCKET_SCAN:
				return array(
					'limit'   => MobileAppQRLogin::MAX_SCAN_PER_WINDOW,
					'seconds' => 15 * MINUTE_IN_SECONDS,
				);
			case self::BUCKET_APPROVE:
				return array(
					'limit'   => MobileAppQRLogin::MAX_APPROVE_PER_WINDOW,
					'seconds' => 15 * MINUTE_IN_SECONDS,
				);
			case self::BUCKET_SESSION_STATUS:
				return array(
					'limit'   => MobileAppQRLogin::MAX_SESSION_STATUS_PER_WINDOW,
					'seconds' => 15 * MINUTE_IN_SECONDS,
				);
		}

		return null;
	}
}