Automattic\WooCommerce\Admin\API

MobileAppQRLogin::approve_tokenpublicWC 1.0

Merchant taps a number on wc-admin. Server validates against the stored real number with hash_equals() (constant-time). Correct → approved + mints exchange_grant. Wrong → rejected (terminal, security event logged). One-strike: no retry.

Method of the class: MobileAppQRLogin{}

No Hooks.

Returns

\WP_REST_Response|\WP_Error.

Usage

$MobileAppQRLogin = new MobileAppQRLogin();
$MobileAppQRLogin->approve_token( $request );
$request(required)
.

MobileAppQRLogin::approve_token() code WC 11.0.1

public function approve_token( $request ) {
	$user_id = get_current_user_id();

	if ( ! $this->check_approve_rate_limit( $user_id ) ) {
		return new \WP_Error(
			'rate_limit_exceeded',
			__( 'Too many QR login approval attempts. Please try again later.', 'woocommerce' ),
			array( 'status' => 429 )
		);
	}

	$token      = (string) $request->get_param( 'token' );
	$token_hash = hash( 'sha256', $token );
	$key        = self::TOKEN_TRANSIENT_PREFIX . $token_hash;

	$record = get_transient( $key );
	if ( ! is_array( $record ) ) {
		return new \WP_Error(
			'invalid_token',
			__( 'Invalid or expired QR login token.', 'woocommerce' ),
			array( 'status' => 401 )
		);
	}

	$approval_claim_expires_at = ! empty( $record['challenge']['expires_at'] )
		? (int) $record['challenge']['expires_at']
		: ( isset( $record['expires_at'] ) ? (int) $record['expires_at'] : time() + self::TOKEN_TTL );
	if ( ! $this->claim_token_for_approval( $token_hash, $approval_claim_expires_at ) ) {
		return new \WP_Error(
			'qr_login_approval_in_progress',
			__( 'This QR login session is already being approved.', 'woocommerce' ),
			array( 'status' => 409 )
		);
	}

	// Re-read after acquiring the database claim in case another request
	// approved, rejected, or expired the challenge while this one was waiting.
	$record = get_transient( $key );
	if ( ! is_array( $record ) ) {
		$this->release_token_approval_claim( $token_hash );
		return new \WP_Error(
			'invalid_token',
			__( 'Invalid or expired QR login token.', 'woocommerce' ),
			array( 'status' => 401 )
		);
	}

	// Same cross-user defense as get_status — only the user that minted
	// the token can approve it.
	if ( ! isset( $record['user_id'] ) || (int) $record['user_id'] !== (int) $user_id ) {
		$this->release_token_approval_claim( $token_hash );
		return new \WP_Error(
			'invalid_token',
			__( 'Invalid or expired QR login token.', 'woocommerce' ),
			array( 'status' => 401 )
		);
	}

	if ( ! empty( $record['expires_at'] ) && time() >= (int) $record['expires_at'] ) {
		$record['state']    = self::STATE_EXPIRED;
		$record['state_at'] = time();
		set_transient( $key, $record, 60 );
		$this->release_token_approval_claim( $token_hash );
		return new \WP_Error(
			'qr_login_expired',
			__( 'The QR login challenge has expired. Please generate a new code.', 'woocommerce' ),
			array( 'status' => 410 )
		);
	}

	$current_state = isset( $record['state'] ) ? (string) $record['state'] : self::STATE_PENDING;
	if ( self::STATE_SCANNED !== $current_state ) {
		$this->release_token_approval_claim( $token_hash );
		return new \WP_Error(
			'qr_login_not_scanned',
			__( 'This QR login session is not waiting for approval.', 'woocommerce' ),
			array( 'status' => 409 )
		);
	}

	// Challenge expiry — normally 90 s after scan, capped by token expiry.
	if ( ! empty( $record['challenge']['expires_at'] ) && time() > (int) $record['challenge']['expires_at'] ) {
		$record['state']    = self::STATE_EXPIRED;
		$record['state_at'] = time();
		set_transient( $key, $record, 60 );
		$this->release_token_approval_claim( $token_hash );
		return new \WP_Error(
			'qr_login_expired',
			__( 'The QR login challenge has expired. Please generate a new code.', 'woocommerce' ),
			array( 'status' => 410 )
		);
	}

	$choice = (string) $request->get_param( 'choice' );
	$real   = isset( $record['challenge']['real'] ) ? (string) $record['challenge']['real'] : '';

	// Constant-time compare. Defends against PHP string-comparison fast
	// paths that can leak prefix-matching info via timing.
	if ( '' === $real || ! hash_equals( $real, $choice ) ) {
		$record['state']    = self::STATE_REJECTED;
		$record['state_at'] = time();
		set_transient( $key, $record, 60 );

		wc_get_logger()->warning(
			'QR login number-match rejected — wrong choice submitted',
			array(
				'source'  => 'qr-login-security',
				'user_id' => (int) $user_id,
				'ip'      => $this->get_client_ip(),
				'device'  => isset( $record['challenge']['device'] ) ? $record['challenge']['device'] : array(),
			)
		);

		$this->release_token_approval_claim( $token_hash );
		return rest_ensure_response( array( 'state' => self::STATE_REJECTED ) );
	}

	$record['state']          = self::STATE_APPROVED;
	$record['state_at']       = time();
	$record['exchange_grant'] = bin2hex( random_bytes( self::EXCHANGE_GRANT_BYTES ) );
	$ttl                      = max(
		1,
		isset( $record['expires_at'] ) ? (int) $record['expires_at'] - time() : self::CHALLENGE_TTL_SECONDS
	);

	set_transient( $key, $record, $ttl );
	$this->release_token_approval_claim( $token_hash );

	return rest_ensure_response( array( 'state' => self::STATE_APPROVED ) );
}